Merge pull request #1209 from trheyi/main
Add invitation model and related functionality
This commit is contained in:
commit
36909d9f54
11 changed files with 1322 additions and 194 deletions
303
data/bindata.go
303
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -27,6 +27,7 @@ var systemModels = map[string]string{
|
||||||
"__yao.audit": "yao/models/audit.mod.yao",
|
"__yao.audit": "yao/models/audit.mod.yao",
|
||||||
"__yao.config": "yao/models/config.mod.yao",
|
"__yao.config": "yao/models/config.mod.yao",
|
||||||
"__yao.dsl": "yao/models/dsl.mod.yao",
|
"__yao.dsl": "yao/models/dsl.mod.yao",
|
||||||
|
"__yao.invitation": "yao/models/invitation.mod.yao",
|
||||||
"__yao.job.category": "yao/models/job/category.mod.yao",
|
"__yao.job.category": "yao/models/job/category.mod.yao",
|
||||||
"__yao.job": "yao/models/job/job.mod.yao",
|
"__yao.job": "yao/models/job/job.mod.yao",
|
||||||
"__yao.job.execution": "yao/models/job/execution.mod.yao",
|
"__yao.job.execution": "yao/models/job/execution.mod.yao",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,15 @@ const (
|
||||||
ErrFailedToDeleteTeam = "failed to delete team: %w"
|
ErrFailedToDeleteTeam = "failed to delete team: %w"
|
||||||
ErrFailedToDeleteMember = "failed to delete member: %w"
|
ErrFailedToDeleteMember = "failed to delete member: %w"
|
||||||
|
|
||||||
|
// Invitation Code related errors
|
||||||
|
ErrInvitationCodeNotFound = "invitation code not found"
|
||||||
|
ErrInvitationCodeAlreadyUsed = "invitation code has already been used"
|
||||||
|
ErrInvitationCodeExpired = "invitation code has expired"
|
||||||
|
ErrInvitationCodeNotPublished = "invitation code is not published"
|
||||||
|
ErrFailedToCreateInvitationCode = "failed to create invitation code: %w"
|
||||||
|
ErrFailedToUseInvitationCode = "failed to use invitation code: %w"
|
||||||
|
ErrFailedToDeleteInvitationCode = "failed to delete invitation code: %w"
|
||||||
|
|
||||||
// MFA related errors
|
// MFA related errors
|
||||||
ErrMFANotEnabled = "MFA is not enabled for this user"
|
ErrMFANotEnabled = "MFA is not enabled for this user"
|
||||||
ErrMFAAlreadyEnabled = "MFA is already enabled for this user"
|
ErrMFAAlreadyEnabled = "MFA is already enabled for this user"
|
||||||
|
|
@ -185,6 +194,7 @@ type DefaultUser struct {
|
||||||
oauthAccountModel string
|
oauthAccountModel string
|
||||||
teamModel string
|
teamModel string
|
||||||
memberModel string
|
memberModel string
|
||||||
|
invitationModel string
|
||||||
cache store.Store
|
cache store.Store
|
||||||
|
|
||||||
// ID Generation Configuration
|
// ID Generation Configuration
|
||||||
|
|
@ -240,6 +250,7 @@ type DefaultUserOptions struct {
|
||||||
OAuthAccountModel string // bind to a specific oauth account model
|
OAuthAccountModel string // bind to a specific oauth account model
|
||||||
TeamModel string // bind to a specific team model
|
TeamModel string // bind to a specific team model
|
||||||
MemberModel string // bind to a specific member model
|
MemberModel string // bind to a specific member model
|
||||||
|
InvitationModel string // bind to a specific invitation code model
|
||||||
Cache store.Store
|
Cache store.Store
|
||||||
|
|
||||||
// ID Generation Strategy
|
// ID Generation Strategy
|
||||||
|
|
@ -308,6 +319,11 @@ func NewDefaultUser(options *DefaultUserOptions) *DefaultUser {
|
||||||
memberModel = "__yao.member"
|
memberModel = "__yao.member"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invitationModel := options.InvitationModel
|
||||||
|
if invitationModel == "" {
|
||||||
|
invitationModel = "__yao.invitation"
|
||||||
|
}
|
||||||
|
|
||||||
// Set ID generation strategy with defaults
|
// Set ID generation strategy with defaults
|
||||||
idStrategy := options.IDStrategy
|
idStrategy := options.IDStrategy
|
||||||
if idStrategy == "" {
|
if idStrategy == "" {
|
||||||
|
|
@ -397,6 +413,7 @@ func NewDefaultUser(options *DefaultUserOptions) *DefaultUser {
|
||||||
oauthAccountModel: oauthAccountModel,
|
oauthAccountModel: oauthAccountModel,
|
||||||
teamModel: teamModel,
|
teamModel: teamModel,
|
||||||
memberModel: memberModel,
|
memberModel: memberModel,
|
||||||
|
invitationModel: invitationModel,
|
||||||
cache: options.Cache,
|
cache: options.Cache,
|
||||||
idStrategy: idStrategy,
|
idStrategy: idStrategy,
|
||||||
idPrefix: idPrefix,
|
idPrefix: idPrefix,
|
||||||
|
|
|
||||||
208
openapi/oauth/providers/user/invitation.go
Normal file
208
openapi/oauth/providers/user/invitation.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Invitation Code Resource (Official Platform Invitation Codes)
|
||||||
|
|
||||||
|
// CreateInvitationCodes creates invitation codes in batch
|
||||||
|
// Supports creating multiple invitation codes at once for efficiency
|
||||||
|
func (u *DefaultUser) CreateInvitationCodes(ctx context.Context, codeData []maps.MapStrAny) ([]string, error) {
|
||||||
|
if len(codeData) == 0 {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
codes := make([]string, 0, len(codeData))
|
||||||
|
m := model.Select(u.invitationModel)
|
||||||
|
|
||||||
|
// Validate and prepare data - collect all possible columns
|
||||||
|
columnsSet := make(map[string]bool)
|
||||||
|
columnsSet["code"] = true
|
||||||
|
columnsSet["status"] = true
|
||||||
|
columnsSet["is_published"] = true
|
||||||
|
columnsSet["code_type"] = true
|
||||||
|
|
||||||
|
for i := range codeData {
|
||||||
|
// Validate required fields
|
||||||
|
code, hasCode := codeData[i]["code"].(string)
|
||||||
|
if !hasCode || code == "" {
|
||||||
|
return nil, fmt.Errorf("code is required in codeData at index %d", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default values if not provided
|
||||||
|
if _, exists := codeData[i]["status"]; !exists {
|
||||||
|
codeData[i]["status"] = "draft"
|
||||||
|
}
|
||||||
|
if _, exists := codeData[i]["is_published"]; !exists {
|
||||||
|
codeData[i]["is_published"] = false
|
||||||
|
}
|
||||||
|
if _, exists := codeData[i]["code_type"]; !exists {
|
||||||
|
codeData[i]["code_type"] = "official"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect optional columns
|
||||||
|
for _, col := range []string{"owner_id", "description", "source", "expires_at", "metadata"} {
|
||||||
|
if _, exists := codeData[i][col]; exists {
|
||||||
|
columnsSet[col] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
codes = append(codes, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ordered column list
|
||||||
|
columns := []string{"code", "status", "is_published", "code_type"}
|
||||||
|
for _, col := range []string{"owner_id", "description", "source", "expires_at", "metadata"} {
|
||||||
|
if columnsSet[col] {
|
||||||
|
columns = append(columns, col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build values matrix
|
||||||
|
values := make([][]interface{}, 0, len(codeData))
|
||||||
|
for i := range codeData {
|
||||||
|
row := make([]interface{}, len(columns))
|
||||||
|
for j, col := range columns {
|
||||||
|
if val, exists := codeData[i][col]; exists {
|
||||||
|
row[j] = val
|
||||||
|
} else {
|
||||||
|
row[j] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values = append(values, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert
|
||||||
|
err := m.Insert(columns, values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(ErrFailedToCreateInvitationCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseInvitationCode marks an invitation code as used (redemption)
|
||||||
|
// This is called when a user successfully uses an invitation code during registration
|
||||||
|
func (u *DefaultUser) UseInvitationCode(ctx context.Context, code string, userID string) error {
|
||||||
|
m := model.Select(u.invitationModel)
|
||||||
|
|
||||||
|
// First, get the invitation code to validate it
|
||||||
|
invitations, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"id", "code", "status", "is_published", "expires_at", "used_by"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: code},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(ErrFailedToUseInvitationCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(invitations) == 0 {
|
||||||
|
return fmt.Errorf(ErrInvitationCodeNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
invitation := invitations[0]
|
||||||
|
|
||||||
|
// Check if already used
|
||||||
|
if usedBy := invitation["used_by"]; usedBy != nil && usedBy != "" {
|
||||||
|
return fmt.Errorf(ErrInvitationCodeAlreadyUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if published (handle both bool and int64 types from different databases)
|
||||||
|
isPublished := false
|
||||||
|
switch v := invitation["is_published"].(type) {
|
||||||
|
case bool:
|
||||||
|
isPublished = v
|
||||||
|
case int64:
|
||||||
|
isPublished = v != 0
|
||||||
|
case int:
|
||||||
|
isPublished = v != 0
|
||||||
|
}
|
||||||
|
if !isPublished {
|
||||||
|
return fmt.Errorf(ErrInvitationCodeNotPublished)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
status, ok := invitation["status"].(string)
|
||||||
|
if !ok || status != "active" {
|
||||||
|
return fmt.Errorf("invitation code status must be 'active' to use, current status: %s", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if expiresAt := invitation["expires_at"]; expiresAt != nil {
|
||||||
|
if expired, err := checkTimeExpired(expiresAt); err == nil && expired {
|
||||||
|
return fmt.Errorf(ErrInvitationCodeExpired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as used
|
||||||
|
updateData := maps.MapStrAny{
|
||||||
|
"used_by": userID,
|
||||||
|
"used_at": time.Now(),
|
||||||
|
"status": "used",
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := m.UpdateWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: code},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
}, updateData)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(ErrFailedToUseInvitationCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf(ErrInvitationCodeNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInvitationCode soft deletes an invitation code
|
||||||
|
func (u *DefaultUser) DeleteInvitationCode(ctx context.Context, code string) error {
|
||||||
|
// First check if invitation code exists
|
||||||
|
m := model.Select(u.invitationModel)
|
||||||
|
invitations, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"id", "code"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: code},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(ErrFailedToDeleteInvitationCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(invitations) == 0 {
|
||||||
|
return fmt.Errorf(ErrInvitationCodeNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceed with soft delete
|
||||||
|
affected, err := m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: code},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(ErrFailedToDeleteInvitationCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf(ErrInvitationCodeNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
528
openapi/oauth/providers/user/invitation_test.go
Normal file
528
openapi/oauth/providers/user/invitation_test.go
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/yaoapp/gou/model"
|
||||||
|
"github.com/yaoapp/kun/maps"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCreateInvitationCodes tests batch creation of invitation codes
|
||||||
|
func TestCreateInvitationCodes(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
provider := NewDefaultUser(&DefaultUserOptions{})
|
||||||
|
|
||||||
|
// Test Case 1: Create multiple invitation codes successfully
|
||||||
|
t.Run("Create multiple codes successfully", func(t *testing.T) {
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-BETA-001",
|
||||||
|
"code_type": "beta",
|
||||||
|
"description": "Beta testing code 1",
|
||||||
|
"owner_id": nil, // Official code
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TEST-BETA-002",
|
||||||
|
"code_type": "beta",
|
||||||
|
"description": "Beta testing code 2",
|
||||||
|
"owner_id": nil, // Official code
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TEST-PARTNER-001",
|
||||||
|
"code_type": "partner",
|
||||||
|
"description": "Partner code 1",
|
||||||
|
"owner_id": nil,
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, len(codes))
|
||||||
|
assert.Contains(t, codes, "TEST-BETA-001")
|
||||||
|
assert.Contains(t, codes, "TEST-BETA-002")
|
||||||
|
assert.Contains(t, codes, "TEST-PARTNER-001")
|
||||||
|
|
||||||
|
// Verify codes were created in database
|
||||||
|
m := model.Select("__yao.invitation")
|
||||||
|
for _, code := range codes {
|
||||||
|
invitations, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"code", "status", "code_type"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: code},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(invitations))
|
||||||
|
assert.Equal(t, "draft", invitations[0]["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
for _, code := range codes {
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: code},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 2: Create with default values
|
||||||
|
t.Run("Create with default values", func(t *testing.T) {
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-DEFAULT-001",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(codes))
|
||||||
|
|
||||||
|
// Verify default values
|
||||||
|
m := model.Select("__yao.invitation")
|
||||||
|
invitations, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"code", "status", "is_published", "code_type"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-DEFAULT-001"},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(invitations))
|
||||||
|
assert.Equal(t, "draft", invitations[0]["status"])
|
||||||
|
// is_published can be bool(false) or int64(0) or int(0) - all are valid
|
||||||
|
assert.NotNil(t, invitations[0]["is_published"])
|
||||||
|
assert.Equal(t, "official", invitations[0]["code_type"])
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-DEFAULT-001"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 3: Empty batch
|
||||||
|
t.Run("Empty batch", func(t *testing.T) {
|
||||||
|
codes, err := provider.CreateInvitationCodes(ctx, []maps.MapStrAny{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(codes))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 4: Missing required field (code)
|
||||||
|
t.Run("Missing required field", func(t *testing.T) {
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code_type": "beta",
|
||||||
|
"description": "Missing code field",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "code is required")
|
||||||
|
assert.Equal(t, 0, len(codes))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 5: Duplicate code (should fail)
|
||||||
|
t.Run("Duplicate code", func(t *testing.T) {
|
||||||
|
// Create first code
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-DUPLICATE",
|
||||||
|
"code_type": "official",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(codes))
|
||||||
|
|
||||||
|
// Try to create duplicate
|
||||||
|
codes, err = provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
m := model.Select("__yao.invitation")
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-DUPLICATE"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUseInvitationCode tests invitation code redemption
|
||||||
|
func TestUseInvitationCode(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
provider := NewDefaultUser(&DefaultUserOptions{})
|
||||||
|
m := model.Select("__yao.invitation")
|
||||||
|
|
||||||
|
// Test Case 1: Successfully use a valid invitation code
|
||||||
|
t.Run("Use valid code successfully", func(t *testing.T) {
|
||||||
|
// Create a valid, published, active invitation code
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-USE-001",
|
||||||
|
"code_type": "beta",
|
||||||
|
"status": "active",
|
||||||
|
"is_published": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Use the invitation code
|
||||||
|
err = provider.UseInvitationCode(ctx, "TEST-USE-001", "user_123")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify code was marked as used
|
||||||
|
invitations, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"code", "status", "used_by", "used_at"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-USE-001"},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(invitations))
|
||||||
|
assert.Equal(t, "used", invitations[0]["status"])
|
||||||
|
assert.Equal(t, "user_123", invitations[0]["used_by"])
|
||||||
|
assert.NotNil(t, invitations[0]["used_at"])
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-USE-001"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 2: Try to use non-existent code
|
||||||
|
t.Run("Use non-existent code", func(t *testing.T) {
|
||||||
|
err := provider.UseInvitationCode(ctx, "NONEXISTENT-CODE", "user_123")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), ErrInvitationCodeNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 3: Try to use already used code
|
||||||
|
t.Run("Use already used code", func(t *testing.T) {
|
||||||
|
// Create and use a code
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-USE-002",
|
||||||
|
"code_type": "beta",
|
||||||
|
"status": "active",
|
||||||
|
"is_published": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// First use
|
||||||
|
err = provider.UseInvitationCode(ctx, "TEST-USE-002", "user_123")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to use again
|
||||||
|
err = provider.UseInvitationCode(ctx, "TEST-USE-002", "user_456")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), ErrInvitationCodeAlreadyUsed)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-USE-002"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 4: Try to use unpublished code
|
||||||
|
t.Run("Use unpublished code", func(t *testing.T) {
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-USE-003",
|
||||||
|
"code_type": "beta",
|
||||||
|
"status": "active",
|
||||||
|
"is_published": false, // Not published
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = provider.UseInvitationCode(ctx, "TEST-USE-003", "user_123")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), ErrInvitationCodeNotPublished)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-USE-003"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 5: Try to use code with wrong status
|
||||||
|
t.Run("Use code with draft status", func(t *testing.T) {
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-USE-004",
|
||||||
|
"code_type": "beta",
|
||||||
|
"status": "draft", // Not active
|
||||||
|
"is_published": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = provider.UseInvitationCode(ctx, "TEST-USE-004", "user_123")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "status must be 'active'")
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-USE-004"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 6: Try to use expired code
|
||||||
|
t.Run("Use expired code", func(t *testing.T) {
|
||||||
|
// Create code that expired yesterday
|
||||||
|
yesterday := time.Now().Add(-24 * time.Hour)
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-USE-005",
|
||||||
|
"code_type": "beta",
|
||||||
|
"status": "active",
|
||||||
|
"is_published": true,
|
||||||
|
"expires_at": yesterday,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = provider.UseInvitationCode(ctx, "TEST-USE-005", "user_123")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), ErrInvitationCodeExpired)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-USE-005"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 7: Use code that has not expired yet
|
||||||
|
t.Run("Use code with future expiration", func(t *testing.T) {
|
||||||
|
// Create code that expires tomorrow
|
||||||
|
tomorrow := time.Now().Add(24 * time.Hour)
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-USE-006",
|
||||||
|
"code_type": "beta",
|
||||||
|
"status": "active",
|
||||||
|
"is_published": true,
|
||||||
|
"expires_at": tomorrow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = provider.UseInvitationCode(ctx, "TEST-USE-006", "user_123")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
m.DeleteWhere(model.QueryParam{
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-USE-006"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteInvitationCode tests invitation code deletion
|
||||||
|
func TestDeleteInvitationCode(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
provider := NewDefaultUser(&DefaultUserOptions{})
|
||||||
|
m := model.Select("__yao.invitation")
|
||||||
|
|
||||||
|
// Test Case 1: Successfully delete an invitation code
|
||||||
|
t.Run("Delete code successfully", func(t *testing.T) {
|
||||||
|
// Create a code
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-DELETE-001",
|
||||||
|
"code_type": "beta",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(codes))
|
||||||
|
|
||||||
|
// Delete the code
|
||||||
|
err = provider.DeleteInvitationCode(ctx, "TEST-DELETE-001")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify code was soft deleted (should not appear in normal queries)
|
||||||
|
invitations, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"code"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-DELETE-001"},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(invitations), "Code should be soft deleted")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 2: Try to delete non-existent code
|
||||||
|
t.Run("Delete non-existent code", func(t *testing.T) {
|
||||||
|
err := provider.DeleteInvitationCode(ctx, "NONEXISTENT-DELETE-CODE")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), ErrInvitationCodeNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 3: Delete used code
|
||||||
|
t.Run("Delete used code", func(t *testing.T) {
|
||||||
|
// Create and use a code
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-DELETE-002",
|
||||||
|
"code_type": "beta",
|
||||||
|
"status": "active",
|
||||||
|
"is_published": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Use the code
|
||||||
|
err = provider.UseInvitationCode(ctx, "TEST-DELETE-002", "user_123")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Delete the used code (should succeed)
|
||||||
|
err = provider.DeleteInvitationCode(ctx, "TEST-DELETE-002")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify deletion
|
||||||
|
invitations, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"code"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "TEST-DELETE-002"},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(invitations))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 4: Try to delete same code twice
|
||||||
|
t.Run("Delete code twice", func(t *testing.T) {
|
||||||
|
// Create a code
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "TEST-DELETE-003",
|
||||||
|
"code_type": "beta",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// First delete
|
||||||
|
err = provider.DeleteInvitationCode(ctx, "TEST-DELETE-003")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Second delete (should fail)
|
||||||
|
err = provider.DeleteInvitationCode(ctx, "TEST-DELETE-003")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), ErrInvitationCodeNotFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvitationCodeWorkflow tests the complete workflow: create -> use -> delete
|
||||||
|
func TestInvitationCodeWorkflow(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
provider := NewDefaultUser(&DefaultUserOptions{})
|
||||||
|
m := model.Select("__yao.invitation")
|
||||||
|
|
||||||
|
t.Run("Complete workflow", func(t *testing.T) {
|
||||||
|
// Step 1: Create multiple codes
|
||||||
|
codeData := []maps.MapStrAny{
|
||||||
|
{
|
||||||
|
"code": "WORKFLOW-001",
|
||||||
|
"code_type": "beta",
|
||||||
|
"status": "active",
|
||||||
|
"is_published": true,
|
||||||
|
"description": "Workflow test code 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "WORKFLOW-002",
|
||||||
|
"code_type": "beta",
|
||||||
|
"status": "active",
|
||||||
|
"is_published": true,
|
||||||
|
"description": "Workflow test code 2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
codes, err := provider.CreateInvitationCodes(ctx, codeData)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, len(codes))
|
||||||
|
|
||||||
|
// Step 2: Use first code
|
||||||
|
err = provider.UseInvitationCode(ctx, "WORKFLOW-001", "user_workflow_1")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Step 3: Verify first code is used
|
||||||
|
invitations, err := m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"code", "status", "used_by"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: "WORKFLOW-001"},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, len(invitations))
|
||||||
|
assert.Equal(t, "used", invitations[0]["status"])
|
||||||
|
assert.Equal(t, "user_workflow_1", invitations[0]["used_by"])
|
||||||
|
|
||||||
|
// Step 4: Delete second code (unused)
|
||||||
|
err = provider.DeleteInvitationCode(ctx, "WORKFLOW-002")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Step 5: Delete first code (used)
|
||||||
|
err = provider.DeleteInvitationCode(ctx, "WORKFLOW-001")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Step 6: Verify both codes are deleted
|
||||||
|
invitations, err = m.Get(model.QueryParam{
|
||||||
|
Select: []interface{}{"code"},
|
||||||
|
Wheres: []model.QueryWhere{
|
||||||
|
{Column: "code", Value: []string{"WORKFLOW-001", "WORKFLOW-002"}, OP: "in"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(invitations))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -329,6 +329,16 @@ type UserProvider interface {
|
||||||
// Member List and Search
|
// Member List and Search
|
||||||
PaginateMembers(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)
|
PaginateMembers(ctx context.Context, param model.QueryParam, page int, pagesize int) (maps.MapStr, error)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Invitation Code Resource (Official Platform Invitation Codes)
|
||||||
|
// ============================================================================
|
||||||
|
// Note: This is for official platform invitation codes required during beta testing.
|
||||||
|
// Not to be confused with user-to-user invitation functionality (coming later).
|
||||||
|
|
||||||
|
CreateInvitationCodes(ctx context.Context, codeData []maps.MapStrAny) ([]string, error)
|
||||||
|
UseInvitationCode(ctx context.Context, code string, userID string) error
|
||||||
|
DeleteInvitationCode(ctx context.Context, code string) error
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Utils
|
// Utils
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -386,22 +386,6 @@ func sendVerificationMessage(ctx context.Context, config *EntryConfig, usernameT
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendEntryVerificationCode generates and sends a verification code to the user's email or mobile
|
|
||||||
// Returns OTP ID and error
|
|
||||||
func sendEntryVerificationCode(ctx context.Context, config *EntryConfig, usernameType, username, locale string) (string, error) {
|
|
||||||
// Generate OTP
|
|
||||||
otpID, verificationCode := generateEntryOTP()
|
|
||||||
log.Debug("Generated OTP for %s: ID=%s", username, otpID)
|
|
||||||
|
|
||||||
// Send verification message
|
|
||||||
err := sendVerificationMessage(ctx, config, usernameType, username, verificationCode, locale)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return otpID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createPublicEntryConfig creates a deep copy of EntryConfig without sensitive data
|
// createPublicEntryConfig creates a deep copy of EntryConfig without sensitive data
|
||||||
// This prevents modifying the global config when removing secrets
|
// This prevents modifying the global config when removing secrets
|
||||||
func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
|
func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
|
||||||
|
|
@ -487,24 +471,7 @@ func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deep copy Messenger config (without sensitive data)
|
// Note: Messenger config is intentionally not copied to public config (backend only)
|
||||||
if config.Messenger != nil {
|
|
||||||
publicConfig.Messenger = &MessengerConfig{}
|
|
||||||
|
|
||||||
if config.Messenger.Mail != nil {
|
|
||||||
publicConfig.Messenger.Mail = &MessengerChannelConfig{
|
|
||||||
Channel: config.Messenger.Mail.Channel,
|
|
||||||
Template: config.Messenger.Mail.Template,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.Messenger.SMS != nil {
|
|
||||||
publicConfig.Messenger.SMS = &MessengerChannelConfig{
|
|
||||||
Channel: config.Messenger.SMS.Channel,
|
|
||||||
Template: config.Messenger.SMS.Template,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep copy ThirdParty config
|
// Deep copy ThirdParty config
|
||||||
if config.ThirdParty != nil {
|
if config.ThirdParty != nil {
|
||||||
|
|
@ -538,6 +505,18 @@ func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep copy Invite config
|
||||||
|
if config.Invite != nil {
|
||||||
|
publicConfig.Invite = &InvitePageConfig{
|
||||||
|
Title: config.Invite.Title,
|
||||||
|
Description: config.Invite.Description,
|
||||||
|
Placeholder: config.Invite.Placeholder,
|
||||||
|
ApplyLink: config.Invite.ApplyLink,
|
||||||
|
ApplyPrompt: config.Invite.ApplyPrompt,
|
||||||
|
ApplyText: config.Invite.ApplyText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return publicConfig
|
return publicConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1081,3 +1060,131 @@ func GinEntryLogin(c *gin.Context) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GinVerifyInvite is the handler for verifying and redeeming invitation code
|
||||||
|
// This endpoint is called with the temporary access token (scope: invite_verification)
|
||||||
|
// after user registration when invite is required
|
||||||
|
func GinVerifyInvite(c *gin.Context) {
|
||||||
|
// Parse request body
|
||||||
|
var req struct {
|
||||||
|
InvitationCode string `json:"invitation_code" binding:"required"`
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get authorized info from the temporary token
|
||||||
|
authInfo := oauth.GetAuthorizedInfo(c)
|
||||||
|
if authInfo == nil || authInfo.Scope != ScopeInviteVerification {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrInsufficientScope.Code,
|
||||||
|
ErrorDescription: "Invalid or missing token scope. Expected invite_verification scope",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user ID from auth info
|
||||||
|
userID := authInfo.UserID
|
||||||
|
if userID == "" {
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidToken.Code,
|
||||||
|
ErrorDescription: "User ID not found in token",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user provider
|
||||||
|
userProvider, err := oauth.OAuth.GetUserProvider()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to get user provider: %s", err.Error())
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: "Internal server error",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Redeem invitation code
|
||||||
|
err = userProvider.UseInvitationCode(ctx, req.InvitationCode, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to use invitation code: %s", err.Error())
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrInvalidRequest.Code,
|
||||||
|
ErrorDescription: fmt.Sprintf("Failed to verify invitation code: %s", err.Error()),
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user status to active
|
||||||
|
err = userProvider.UpdateUserStatus(ctx, userID, "active")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to update user status: %s", err.Error())
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: "Failed to activate user account",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate login context
|
||||||
|
loginCtx := makeLoginContext(c)
|
||||||
|
|
||||||
|
// Generate full login token
|
||||||
|
loginResponse, err := LoginByUserID(userID, loginCtx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to generate login token: %s", err.Error())
|
||||||
|
errorResp := &response.ErrorResponse{
|
||||||
|
Code: response.ErrServerError.Code,
|
||||||
|
ErrorDescription: "Failed to generate login credentials",
|
||||||
|
}
|
||||||
|
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create session ID
|
||||||
|
sid := utils.GetSessionID(c)
|
||||||
|
if sid == "" {
|
||||||
|
sid = generateSessionID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send session cookie
|
||||||
|
response.SendSessionCookie(c, sid)
|
||||||
|
|
||||||
|
// Handle different login statuses (in case MFA is enabled or team selection needed)
|
||||||
|
switch loginResponse.Status {
|
||||||
|
case LoginStatusMFA, LoginStatusTeamSelection:
|
||||||
|
// Return temporary token for next step verification
|
||||||
|
response.RespondWithSuccess(c, response.StatusOK, LoginSuccessResponse{
|
||||||
|
SessionID: sid,
|
||||||
|
AccessToken: loginResponse.AccessToken,
|
||||||
|
ExpiresIn: loginResponse.ExpiresIn,
|
||||||
|
MFAEnabled: loginResponse.MFAEnabled,
|
||||||
|
Status: loginResponse.Status,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
// Success - return full token set
|
||||||
|
response.RespondWithSuccess(c, response.StatusOK, LoginSuccessResponse{
|
||||||
|
SessionID: sid,
|
||||||
|
IDToken: loginResponse.IDToken,
|
||||||
|
AccessToken: loginResponse.AccessToken,
|
||||||
|
RefreshToken: loginResponse.RefreshToken,
|
||||||
|
ExpiresIn: loginResponse.ExpiresIn,
|
||||||
|
RefreshTokenExpiresIn: loginResponse.RefreshTokenExpiresIn,
|
||||||
|
MFAEnabled: loginResponse.MFAEnabled,
|
||||||
|
Status: loginResponse.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ type EntryConfig struct {
|
||||||
Token *TokenConfig `json:"token,omitempty"` // From signin config
|
Token *TokenConfig `json:"token,omitempty"` // From signin config
|
||||||
Messenger *MessengerConfig `json:"messenger,omitempty"` // From register config
|
Messenger *MessengerConfig `json:"messenger,omitempty"` // From register config
|
||||||
InviteRequired bool `json:"invite_required,omitempty"` // From register config
|
InviteRequired bool `json:"invite_required,omitempty"` // From register config
|
||||||
|
Invite *InvitePageConfig `json:"invite,omitempty"` // Invite code page configuration
|
||||||
ThirdParty *ThirdParty `json:"third_party,omitempty"`
|
ThirdParty *ThirdParty `json:"third_party,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,6 +124,16 @@ type MessengerChannelConfig struct {
|
||||||
Template string `json:"template,omitempty"` // Template name for this channel
|
Template string `json:"template,omitempty"` // Template name for this channel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvitePageConfig represents the invitation code page configuration
|
||||||
|
type InvitePageConfig struct {
|
||||||
|
Title string `json:"title,omitempty"` // Page title for invite code verification
|
||||||
|
Description string `json:"description,omitempty"` // Description text for invite code page
|
||||||
|
Placeholder string `json:"placeholder,omitempty"` // Placeholder text for invite code input
|
||||||
|
ApplyLink string `json:"apply_link,omitempty"` // Optional link to apply for invitation code
|
||||||
|
ApplyPrompt string `json:"apply_prompt,omitempty"` // Prompt text before apply link (e.g., "Don't have an invitation code?")
|
||||||
|
ApplyText string `json:"apply_text,omitempty"` // Text for apply link (e.g., "Apply for invitation code")
|
||||||
|
}
|
||||||
|
|
||||||
// YaoClientConfig represents the Yao OpenAPI Client config
|
// YaoClientConfig represents the Yao OpenAPI Client config
|
||||||
type YaoClientConfig struct {
|
type YaoClientConfig struct {
|
||||||
ClientID string `json:"client_id,omitempty"`
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
||||||
// Register a new user
|
// Register a new user
|
||||||
group.POST("/entry/register", oauth.Guard, GinEntryRegister) // Register a new user
|
group.POST("/entry/register", oauth.Guard, GinEntryRegister) // Register a new user
|
||||||
group.POST("/entry/login", oauth.Guard, GinEntryLogin) // Login a user
|
group.POST("/entry/login", oauth.Guard, GinEntryLogin) // Login a user
|
||||||
|
group.POST("/entry/invite/verify", oauth.Guard, GinVerifyInvite) // Verify invitation code (redeem)
|
||||||
group.POST("/entry/otp", oauth.Guard, GinSendOTP) // Send OTP
|
group.POST("/entry/otp", oauth.Guard, GinSendOTP) // Send OTP
|
||||||
group.POST("/logout", oauth.Guard, GinLogout) // User logout
|
group.POST("/logout", oauth.Guard, GinLogout) // User logout
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ var testSystemModels = map[string]string{
|
||||||
"__yao.audit": "yao/models/audit.mod.yao",
|
"__yao.audit": "yao/models/audit.mod.yao",
|
||||||
"__yao.config": "yao/models/config.mod.yao",
|
"__yao.config": "yao/models/config.mod.yao",
|
||||||
"__yao.dsl": "yao/models/dsl.mod.yao",
|
"__yao.dsl": "yao/models/dsl.mod.yao",
|
||||||
|
"__yao.invitation": "yao/models/invitation.mod.yao",
|
||||||
"__yao.job.category": "yao/models/job/category.mod.yao",
|
"__yao.job.category": "yao/models/job/category.mod.yao",
|
||||||
"__yao.job": "yao/models/job/job.mod.yao",
|
"__yao.job": "yao/models/job/job.mod.yao",
|
||||||
"__yao.job.execution": "yao/models/job/execution.mod.yao",
|
"__yao.job.execution": "yao/models/job/execution.mod.yao",
|
||||||
|
|
|
||||||
221
yao/models/invitation.mod.yao
Normal file
221
yao/models/invitation.mod.yao
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
{
|
||||||
|
"name": "Invitation Code",
|
||||||
|
"label": "Invitation Code",
|
||||||
|
"description": "Official invitation code management for platform access control",
|
||||||
|
"tags": ["invitation", "code", "access", "beta", "registration"],
|
||||||
|
"table": {
|
||||||
|
"name": "invitation_code",
|
||||||
|
"comment": "Official invitation code management for platform access control"
|
||||||
|
},
|
||||||
|
"columns": [
|
||||||
|
// ============================================================================
|
||||||
|
// Basic Fields
|
||||||
|
// ============================================================================
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"type": "ID",
|
||||||
|
"label": "ID",
|
||||||
|
"comment": "Primary key identifier",
|
||||||
|
"primary": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "code",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Invitation Code",
|
||||||
|
"comment": "Unique invitation code string",
|
||||||
|
"length": 100,
|
||||||
|
"unique": true,
|
||||||
|
"index": true,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Ownership Information
|
||||||
|
// ============================================================================
|
||||||
|
{
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Owner ID",
|
||||||
|
"comment": "User ID who owns this invitation code (null = official/system generated)",
|
||||||
|
"length": 255,
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Usage Information
|
||||||
|
// ============================================================================
|
||||||
|
{
|
||||||
|
"name": "used_by",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Used By",
|
||||||
|
"comment": "User ID who used this invitation code",
|
||||||
|
"length": 255,
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "used_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"label": "Used At",
|
||||||
|
"comment": "When the invitation code was used",
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Management
|
||||||
|
// ============================================================================
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"type": "enum",
|
||||||
|
"label": "Status",
|
||||||
|
"comment": "Invitation code status",
|
||||||
|
"option": [
|
||||||
|
"draft", // Code created but not published yet
|
||||||
|
"active", // Code is active and available for use
|
||||||
|
"used", // Code has been used by a user
|
||||||
|
"expired", // Code has expired
|
||||||
|
"revoked", // Code has been manually revoked/disabled
|
||||||
|
"suspended" // Code temporarily suspended
|
||||||
|
],
|
||||||
|
"default": "draft",
|
||||||
|
"index": true,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_published",
|
||||||
|
"type": "boolean",
|
||||||
|
"label": "Is Published",
|
||||||
|
"comment": "Whether the code is published and available for use",
|
||||||
|
"default": false,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "published_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"label": "Published At",
|
||||||
|
"comment": "When the invitation code was published",
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Expiration
|
||||||
|
// ============================================================================
|
||||||
|
{
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"label": "Expires At",
|
||||||
|
"comment": "When the invitation code expires",
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Code Configuration
|
||||||
|
// ============================================================================
|
||||||
|
{
|
||||||
|
"name": "code_type",
|
||||||
|
"type": "enum",
|
||||||
|
"label": "Code Type",
|
||||||
|
"comment": "Type of invitation code",
|
||||||
|
"option": [
|
||||||
|
"official", // Official code issued by platform
|
||||||
|
"beta", // Beta testing code
|
||||||
|
"partner", // Partner/affiliate code
|
||||||
|
"promotional", // Promotional code
|
||||||
|
"custom" // Custom code type
|
||||||
|
],
|
||||||
|
"default": "official",
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Additional Information
|
||||||
|
// ============================================================================
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"label": "Description",
|
||||||
|
"comment": "Description or notes about this invitation code",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Source",
|
||||||
|
"comment": "Source or channel where this code is distributed",
|
||||||
|
"length": 255,
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Metadata",
|
||||||
|
"comment": "Additional metadata and custom fields",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"name": "idx_invitation_code_status_published",
|
||||||
|
"columns": ["status", "is_published"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index on status and published flag for filtering active codes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_invitation_code_owner_status",
|
||||||
|
"columns": ["owner_id", "status"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index on owner and status for owner's code management"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_invitation_code_type_status",
|
||||||
|
"columns": ["code_type", "status", "is_published"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index on code type and status for filtering by type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_invitation_code_expires",
|
||||||
|
"columns": ["status", "expires_at"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index on status and expiration for cleanup jobs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_invitation_code_available",
|
||||||
|
"columns": ["status", "is_published", "used_by"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index for finding available codes (not used yet)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_invitation_code_published_at",
|
||||||
|
"columns": ["is_published", "published_at"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index on published flag and time for sorting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_invitation_code_source",
|
||||||
|
"columns": ["source", "code_type", "status"],
|
||||||
|
"type": "index",
|
||||||
|
"comment": "Index on source and type for analytics"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relations": {
|
||||||
|
"owner": {
|
||||||
|
"type": "hasOne",
|
||||||
|
"model": "__yao.user",
|
||||||
|
"key": "owner_id",
|
||||||
|
"foreign": "user_id"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "hasOne",
|
||||||
|
"model": "__yao.user",
|
||||||
|
"key": "used_by",
|
||||||
|
"foreign": "user_id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"values": [],
|
||||||
|
"option": { "timestamps": true, "soft_deletes": true, "permission": true }
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue