Add invitation model and related functionality

- Introduced the invitation model in bindata.go, including the new invitation.mod.yao file.
- Updated system models to include the invitation model for better integration.
- Added invitation code related error handling in default.go, enhancing user experience during invitation processes.
- Implemented invitation code management methods in the user provider interface, allowing for creation, usage, and deletion of invitation codes.
- Enhanced entry configuration to support invitation code verification, improving the user registration flow.
- Added a new endpoint for verifying invitation codes in user routing, streamlining the invitation redemption process.
This commit is contained in:
Max 2025-10-16 19:08:54 +08:00
parent c58bc8c4fe
commit 9a2a636314
11 changed files with 1322 additions and 194 deletions

File diff suppressed because one or more lines are too long

View file

@ -27,6 +27,7 @@ var systemModels = map[string]string{
"__yao.audit": "yao/models/audit.mod.yao",
"__yao.config": "yao/models/config.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": "yao/models/job/job.mod.yao",
"__yao.job.execution": "yao/models/job/execution.mod.yao",

View file

@ -44,6 +44,15 @@ const (
ErrFailedToDeleteTeam = "failed to delete team: %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
ErrMFANotEnabled = "MFA is not enabled for this user"
ErrMFAAlreadyEnabled = "MFA is already enabled for this user"
@ -185,6 +194,7 @@ type DefaultUser struct {
oauthAccountModel string
teamModel string
memberModel string
invitationModel string
cache store.Store
// ID Generation Configuration
@ -240,6 +250,7 @@ type DefaultUserOptions struct {
OAuthAccountModel string // bind to a specific oauth account model
TeamModel string // bind to a specific team model
MemberModel string // bind to a specific member model
InvitationModel string // bind to a specific invitation code model
Cache store.Store
// ID Generation Strategy
@ -308,6 +319,11 @@ func NewDefaultUser(options *DefaultUserOptions) *DefaultUser {
memberModel = "__yao.member"
}
invitationModel := options.InvitationModel
if invitationModel == "" {
invitationModel = "__yao.invitation"
}
// Set ID generation strategy with defaults
idStrategy := options.IDStrategy
if idStrategy == "" {
@ -397,6 +413,7 @@ func NewDefaultUser(options *DefaultUserOptions) *DefaultUser {
oauthAccountModel: oauthAccountModel,
teamModel: teamModel,
memberModel: memberModel,
invitationModel: invitationModel,
cache: options.Cache,
idStrategy: idStrategy,
idPrefix: idPrefix,

View 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
}

View 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))
})
}

View file

@ -329,6 +329,16 @@ type UserProvider interface {
// Member List and Search
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
// ============================================================================

View file

@ -386,22 +386,6 @@ func sendVerificationMessage(ctx context.Context, config *EntryConfig, usernameT
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
// This prevents modifying the global config when removing secrets
func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
@ -487,24 +471,7 @@ func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
}
}
// Deep copy Messenger config (without sensitive data)
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,
}
}
}
// Note: Messenger config is intentionally not copied to public config (backend only)
// Deep copy ThirdParty config
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
}
@ -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,
})
}
}

View file

@ -93,22 +93,23 @@ type ProviderRegisterConfig struct {
// EntryConfig represents the unified auth entry configuration (login + register)
// This merges signin and register configurations into a single entry point
type EntryConfig struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Default bool `json:"default,omitempty"`
SuccessURL string `json:"success_url,omitempty"`
FailureURL string `json:"failure_url,omitempty"`
LogoutRedirect string `json:"logout_redirect,omitempty"` // From signin config
ClientID string `json:"client_id,omitempty"` // From signin config
ClientSecret string `json:"client_secret,omitempty"` // From signin config (not exposed to frontend)
AutoLogin bool `json:"auto_login,omitempty"` // From register config
Role string `json:"role,omitempty"` // From register config
Type string `json:"type,omitempty"` // From register config - User type id
Form *FormConfig `json:"form,omitempty"`
Token *TokenConfig `json:"token,omitempty"` // From signin config
Messenger *MessengerConfig `json:"messenger,omitempty"` // From register config
InviteRequired bool `json:"invite_required,omitempty"` // From register config
ThirdParty *ThirdParty `json:"third_party,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Default bool `json:"default,omitempty"`
SuccessURL string `json:"success_url,omitempty"`
FailureURL string `json:"failure_url,omitempty"`
LogoutRedirect string `json:"logout_redirect,omitempty"` // From signin config
ClientID string `json:"client_id,omitempty"` // From signin config
ClientSecret string `json:"client_secret,omitempty"` // From signin config (not exposed to frontend)
AutoLogin bool `json:"auto_login,omitempty"` // From register config
Role string `json:"role,omitempty"` // From register config
Type string `json:"type,omitempty"` // From register config - User type id
Form *FormConfig `json:"form,omitempty"`
Token *TokenConfig `json:"token,omitempty"` // From signin config
Messenger *MessengerConfig `json:"messenger,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"`
}
// MessengerConfig represents the messenger configuration for user registration
@ -123,6 +124,16 @@ type MessengerChannelConfig struct {
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
type YaoClientConfig struct {
ClientID string `json:"client_id,omitempty"`

View file

@ -33,10 +33,11 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
group.POST("/entry/verify", GinEntryVerify) // Verify login/register email or mobile (public)
// 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/otp", oauth.Guard, GinSendOTP) // Send OTP
group.POST("/logout", oauth.Guard, GinLogout) // User logout
group.POST("/entry/register", oauth.Guard, GinEntryRegister) // Register a new 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("/logout", oauth.Guard, GinLogout) // User logout
// Logined User Settings
attachProfile(group, oauth) // User profile management

View file

@ -201,6 +201,7 @@ var testSystemModels = map[string]string{
"__yao.audit": "yao/models/audit.mod.yao",
"__yao.config": "yao/models/config.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": "yao/models/job/job.mod.yao",
"__yao.job.execution": "yao/models/job/execution.mod.yao",

View 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 }
}