Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions backend/internal/handler/admin/user_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ func NewUserHandler(adminService service.AdminService, concurrencyService *servi

// CreateUserRequest represents admin create user request
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Username string `json:"username"`
Notes string `json:"notes"`
Balance float64 `json:"balance"`
Concurrency int `json:"concurrency"`
RPMLimit int `json:"rpm_limit"`
AllowedGroups []int64 `json:"allowed_groups"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Username string `json:"username"`
Notes string `json:"notes"`
Balance *float64 `json:"balance"`
Concurrency int `json:"concurrency"`
RPMLimit int `json:"rpm_limit"`
AllowedGroups []int64 `json:"allowed_groups"`
}

// UpdateUserRequest represents admin update user request
Expand Down
11 changes: 9 additions & 2 deletions backend/internal/service/admin_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ type CreateUserInput struct {
Password string
Username string
Notes string
Balance float64
Balance *float64
Concurrency int
RPMLimit int
AllowedGroups []int64
Expand Down Expand Up @@ -661,12 +661,19 @@ func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error)
}

func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) {
balance := 0.0
if input.Balance != nil {
balance = *input.Balance
} else if s.settingService != nil {
balance = s.settingService.GetDefaultBalance(ctx)
}

user := &User{
Email: input.Email,
Username: input.Username,
Notes: input.Notes,
Role: RoleUser, // Always create as regular user, never admin
Balance: input.Balance,
Balance: balance,
Concurrency: input.Concurrency,
RPMLimit: input.RPMLimit,
Status: StatusActive,
Expand Down
55 changes: 53 additions & 2 deletions backend/internal/service/admin_service_create_user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import (
func TestAdminService_CreateUser_Success(t *testing.T) {
repo := &userRepoStub{nextID: 10}
svc := &adminServiceImpl{userRepo: repo}
balance := 12.5

input := &CreateUserInput{
Email: "user@test.com",
Password: "strong-pass",
Username: "tester",
Notes: "note",
Balance: 12.5,
Balance: &balance,
Concurrency: 7,
AllowedGroups: []int64{3, 5},
}
Expand All @@ -32,7 +33,7 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
require.Equal(t, input.Email, user.Email)
require.Equal(t, input.Username, user.Username)
require.Equal(t, input.Notes, user.Notes)
require.Equal(t, input.Balance, user.Balance)
require.Equal(t, balance, user.Balance)
require.Equal(t, input.Concurrency, user.Concurrency)
require.Equal(t, input.AllowedGroups, user.AllowedGroups)
require.Equal(t, RoleUser, user.Role)
Expand All @@ -42,6 +43,56 @@ func TestAdminService_CreateUser_Success(t *testing.T) {
require.Equal(t, user, repo.created[0])
}

func TestAdminService_CreateUser_UsesDefaultBalanceWhenBalanceOmitted(t *testing.T) {
repo := &userRepoStub{nextID: 11}
cfg := &config.Config{
Default: config.DefaultConfig{
UserBalance: 0,
},
}
settingService := NewSettingService(&settingRepoStub{values: map[string]string{
SettingKeyDefaultBalance: "0.02",
}}, cfg)
svc := &adminServiceImpl{userRepo: repo, settingService: settingService}

user, err := svc.CreateUser(context.Background(), &CreateUserInput{
Email: "default-balance@test.com",
Password: "strong-pass",
})

require.NoError(t, err)
require.NotNil(t, user)
require.Equal(t, 0.02, user.Balance)
require.Len(t, repo.created, 1)
require.Equal(t, 0.02, repo.created[0].Balance)
}

func TestAdminService_CreateUser_ExplicitZeroBalanceOverridesDefault(t *testing.T) {
repo := &userRepoStub{nextID: 12}
cfg := &config.Config{
Default: config.DefaultConfig{
UserBalance: 0,
},
}
settingService := NewSettingService(&settingRepoStub{values: map[string]string{
SettingKeyDefaultBalance: "0.02",
}}, cfg)
svc := &adminServiceImpl{userRepo: repo, settingService: settingService}
balance := 0.0

user, err := svc.CreateUser(context.Background(), &CreateUserInput{
Email: "zero-balance@test.com",
Password: "strong-pass",
Balance: &balance,
})

require.NoError(t, err)
require.NotNil(t, user)
require.Equal(t, 0.0, user.Balance)
require.Len(t, repo.created, 1)
require.Equal(t, 0.0, repo.created[0].Balance)
}

func TestAdminService_CreateUser_EmailExists(t *testing.T) {
repo := &userRepoStub{createErr: ErrEmailExists}
svc := &adminServiceImpl{userRepo: repo}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/admin/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,11 @@ export async function getById(id: number): Promise<AdminUser> {
export async function create(userData: {
email: string
password: string
username?: string
notes?: string
balance?: number
concurrency?: number
rpm_limit?: number
allowed_groups?: number[] | null
}): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('/admin/users', userData)
Expand Down
14 changes: 10 additions & 4 deletions frontend/src/components/admin/user/UserCreateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input v-model.number="form.balance" type="number" step="any" class="input" />
<input v-model="form.balance" type="number" step="any" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
Expand Down Expand Up @@ -69,18 +69,24 @@ import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n()

const form = reactive({ email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1, rpm_limit: 0 })
const form = reactive({ email: '', password: '', username: '', notes: '', balance: '', concurrency: 1, rpm_limit: 0 })

const { loading, submit } = useForm({
form,
submitFn: async (data) => {
await adminAPI.users.create(data)
const { balance: rawBalance, ...rest } = data
const balance = String(rawBalance).trim()
const payload: typeof rest & { balance?: number } = { ...rest }
if (balance !== '') {
payload.balance = Number(balance)
}
await adminAPI.users.create(payload)
emit('success'); emit('close')
},
successMsg: t('admin.users.userCreated')
})

watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1, rpm_limit: 0 }) })
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: '', concurrency: 1, rpm_limit: 0 }) })

const generateRandomPassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
Expand Down
Loading