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
9 changes: 9 additions & 0 deletions cmd/capi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2517,6 +2517,8 @@ func (s *Server) bulkUpdateUsers(c *gin.Context) {
return
}

// An empty group id clears the assignment, matching the single-user patch.
groupID := strings.TrimSpace(body.Value)
switch body.Action {
case "set_status":
if !allowedString(body.Value, "active", "disabled", "limited", "overdue") {
Expand All @@ -2536,6 +2538,11 @@ func (s *Server) bulkUpdateUsers(c *gin.Context) {
validationError(c, "至少需要保留一个启用的管理员")
return
}
case "set_group":
if groupID != "" && s.findUserGroup(groupID) == nil {
validationError(c, "用户分组不存在")
return
}
case "adjust_balance":
if body.Amount == 0 || body.Amount < -1_000_000_000 || body.Amount > 1_000_000_000 {
validationError(c, "额度调整值必须非零,且在允许范围内")
Expand Down Expand Up @@ -2565,6 +2572,8 @@ func (s *Server) bulkUpdateUsers(c *gin.Context) {
case "set_role":
user.Role = body.Value
s.syncAccountAccessLocked(user)
case "set_group":
user.GroupID = groupID
case "adjust_balance":
user.Balance = round4(user.Balance + body.Amount)
s.state.QuotaLedger = append(s.state.QuotaLedger, QuotaEntry{
Expand Down
126 changes: 126 additions & 0 deletions cmd/capi/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4918,3 +4918,129 @@ func TestIsImmutableBuildAsset(t *testing.T) {
}
}
}

func TestBulkSetGroupAssignsAndClears(t *testing.T) {
withEnv(t, map[string]string{"PERSISTENCE": "memory"})
server, router := testServerRouter(t)
seedGatewayFixtures(server)

created := perform(router, http.MethodPost, "/api/groups", `{"name":"bulk_group","description":"批量"}`, nil)
if created.Code != http.StatusCreated {
t.Fatalf("create group status = %d body = %s", created.Code, created.Body.String())
}
var groupPayload struct {
Group UserGroup `json:"group"`
}
if err := json.Unmarshal(created.Body.Bytes(), &groupPayload); err != nil {
t.Fatalf("decode group: %v", err)
}
groupID := groupPayload.Group.ID

// An unknown group must be rejected before anything is written.
unknown := perform(router, http.MethodPost, "/api/users/bulk", `{"userIds":["usr_1002"],"action":"set_group","value":"grp_missing"}`, nil)
if unknown.Code != http.StatusBadRequest {
t.Fatalf("unknown group bulk status = %d body = %s", unknown.Code, unknown.Body.String())
}

assigned := perform(router, http.MethodPost, "/api/users/bulk", `{"userIds":["usr_1002","usr_1003"],"action":"set_group","value":"`+groupID+`"}`, nil)
if assigned.Code != http.StatusOK {
t.Fatalf("bulk set_group status = %d body = %s", assigned.Code, assigned.Body.String())
}
if !bytes.Contains(assigned.Body.Bytes(), []byte(`"groupId":"`+groupID+`"`)) {
t.Fatalf("bulk set_group response missing group id: %s", assigned.Body.String())
}

server.mu.Lock()
for _, id := range []string{"usr_1002", "usr_1003"} {
if user := server.findUser(id); user == nil || user.GroupID != groupID {
server.mu.Unlock()
t.Fatalf("user %s group after bulk assign = %#v", id, user)
}
}
server.mu.Unlock()

// An empty value clears the assignment, matching the single-user patch.
cleared := perform(router, http.MethodPost, "/api/users/bulk", `{"userIds":["usr_1002"],"action":"set_group","value":""}`, nil)
if cleared.Code != http.StatusOK {
t.Fatalf("bulk clear group status = %d body = %s", cleared.Code, cleared.Body.String())
}
server.mu.Lock()
defer server.mu.Unlock()
if user := server.findUser("usr_1002"); user == nil || user.GroupID != "" {
t.Fatalf("user group after bulk clear = %#v", user)
}
if user := server.findUser("usr_1003"); user == nil || user.GroupID != groupID {
t.Fatalf("unselected user should keep its group: %#v", user)
}
}

func TestDefaultRegistrationGroupAppliesToNewUsers(t *testing.T) {
withEnv(t, map[string]string{"PERSISTENCE": "memory"})
server, router := testServerRouter(t)

setup := perform(router, http.MethodPost, "/api/auth/setup", `{
"username":"root_admin",
"password":"correct-horse-battery",
"displayName":"Root Admin",
"email":"root@example.test",
"registrationEnabled":false,
"registrationMode":"username"
}`, nil)
if setup.Code != http.StatusCreated {
t.Fatalf("setup status = %d body = %s", setup.Code, setup.Body.String())
}

created := perform(router, http.MethodPost, "/api/groups", `{"name":"newcomers","description":"新用户"}`, nil)
if created.Code != http.StatusCreated {
t.Fatalf("create group status = %d body = %s", created.Code, created.Body.String())
}
var groupPayload struct {
Group UserGroup `json:"group"`
}
if err := json.Unmarshal(created.Body.Bytes(), &groupPayload); err != nil {
t.Fatalf("decode group: %v", err)
}
groupID := groupPayload.Group.ID

// The default group is reported back and persisted.
settings := perform(router, http.MethodGet, "/api/settings/auth", "", nil)
if settings.Code != http.StatusOK || !bytes.Contains(settings.Body.Bytes(), []byte(`"defaultGroupId"`)) {
t.Fatalf("auth settings status = %d body = %s", settings.Code, settings.Body.String())
}
updated := perform(router, http.MethodPatch, "/api/settings/auth", `{"registrationEnabled":true,"defaultGroupId":"`+groupID+`"}`, nil)
if updated.Code != http.StatusOK || !bytes.Contains(updated.Body.Bytes(), []byte(`"defaultGroupId":"`+groupID+`"`)) {
t.Fatalf("update default group status = %d body = %s", updated.Code, updated.Body.String())
}

// An unknown default group must be rejected.
badDefault := perform(router, http.MethodPatch, "/api/settings/auth", `{"registrationEnabled":true,"defaultGroupId":"grp_missing"}`, nil)
if badDefault.Code != http.StatusBadRequest {
t.Fatalf("unknown default group status = %d body = %s", badDefault.Code, badDefault.Body.String())
}

register := perform(router, http.MethodPost, "/api/auth/register", `{
"username":"newcomer",
"password":"safe-password-123",
"displayName":"Newcomer",
"email":"newcomer@example.test"
}`, nil)
if register.Code != http.StatusCreated {
t.Fatalf("register status = %d body = %s", register.Code, register.Body.String())
}

server.mu.Lock()
defer server.mu.Unlock()
var registered *User
for i := range server.state.Users {
if strings.EqualFold(server.state.Users[i].Email, "newcomer@example.test") {
registered = &server.state.Users[i]
break
}
}
if registered == nil {
t.Fatal("registered user not found")
}
if registered.GroupID != groupID {
t.Fatalf("new user group = %q, want %q", registered.GroupID, groupID)
}
}
43 changes: 43 additions & 0 deletions dist/assets/index-BW0kmUgI.js

Large diffs are not rendered by default.

43 changes: 0 additions & 43 deletions dist/assets/index-CkIGdnVN.js

This file was deleted.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CAPI</title>
<script type="module" crossorigin src="/assets/index-CkIGdnVN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BvkROHFS.css">
<script type="module" crossorigin src="/assets/index-BW0kmUgI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ssd4aOT1.css">
</head>
<body>
<div id="root"></div>
Expand Down
75 changes: 68 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ type AuthSettings = {
registrationEnabled: boolean;
registrationMode: RegistrationMode;
defaultBalance: number;
defaultGroupId: string;
};

type MaintenanceSettings = {
Expand Down Expand Up @@ -878,7 +879,7 @@ function App() {

async function bulkUpdateUsers(
userIds: string[],
action: "set_status" | "set_role" | "adjust_balance",
action: "set_status" | "set_role" | "adjust_balance" | "set_group",
options: { value?: string; amount?: number; reason?: string } = {}
) {
const data = await fetchJson<{ users: User[]; updated: number }>("/api/users/bulk", {
Expand Down Expand Up @@ -1332,7 +1333,7 @@ function App() {
{active === "drawing" && <DrawingView channels={channels} onCreate={createChannel} onImport={importOpenAIAccounts} onCheckAccounts={checkOpenAIAccounts} onDeduplicateAccounts={deduplicateOpenAIAccounts} onDeleteAccount={deleteOpenAIAccount} onUpdate={updateChannel} onStartOAuth={startOpenAIOAuth} onCompleteOAuth={completeOpenAIOAuth} />}
{active === "channels" && <ChannelsView channels={channels} groups={groups} onUpdate={updateChannel} onCreate={createChannel} onImport={importOpenAIAccounts} onDelete={deleteChannel} onSyncModels={syncChannelModels} onCheck={checkChannel} />}
{active === "logs" && <LogsView logs={logs} onCopy={copyAndToast} />}
{active === "settings" && <SettingsView models={models} channels={channels} />}
{active === "settings" && <SettingsView models={models} channels={channels} groups={groups} />}
</main>

{createdKeySecret && (
Expand Down Expand Up @@ -2015,7 +2016,7 @@ function UsersView({
onUpdate: (id: string, patch: Partial<User>) => void;
onBulkUpdate: (
ids: string[],
action: "set_status" | "set_role" | "adjust_balance",
action: "set_status" | "set_role" | "adjust_balance" | "set_group",
options?: { value?: string; amount?: number; reason?: string }
) => Promise<void>;
onCreateKey: (id: string) => void;
Expand All @@ -2028,6 +2029,7 @@ function UsersView({
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkAmount, setBulkAmount] = useState("10");
const [bulkReason, setBulkReason] = useState("");
const [bulkGroupId, setBulkGroupId] = useState("");
const [bulkBusy, setBulkBusy] = useState(false);
const [balanceAmount, setBalanceAmount] = useState("10");
const [balanceReason, setBalanceReason] = useState("");
Expand Down Expand Up @@ -2074,7 +2076,7 @@ function UsersView({
}

async function runBulk(
action: "set_status" | "adjust_balance",
action: "set_status" | "adjust_balance" | "set_group",
options: { value?: string; amount?: number; reason?: string }
) {
if (selectedIds.size === 0) return;
Expand Down Expand Up @@ -2179,6 +2181,26 @@ function UsersView({
<button type="button" className="danger-button" disabled={bulkBusy} onClick={() => runBulk("set_status", { value: "disabled" })}>
禁用
</button>
<select
className="bulk-group-select"
value={bulkGroupId}
disabled={bulkBusy}
aria-label="批量设置分组"
onChange={(event) => setBulkGroupId(event.target.value)}
>
<option value="">未分组</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
<button
type="button"
className="secondary-button"
disabled={bulkBusy}
onClick={() => runBulk("set_group", { value: bulkGroupId })}
>
设为分组
</button>
</div>
)}
<div className="table">
Expand Down Expand Up @@ -4199,11 +4221,12 @@ function LogDetail({ log, loading, onCopy }: { log: RequestLog | null; loading:
);
}

function SettingsView({ models, channels }: { models: ModelItem[]; channels: Channel[] }) {
function SettingsView({ models, channels, groups }: { models: ModelItem[]; channels: Channel[]; groups: UserGroup[] }) {
const [discord, setDiscord] = useState<DiscordSettings | null>(null);
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
const [registrationMode, setRegistrationMode] = useState<RegistrationMode>("username");
const [defaultBalance, setDefaultBalance] = useState("0");
const [defaultGroupId, setDefaultGroupId] = useState("");
const [checkInSettings, setCheckInSettings] = useState<CheckInSettings>({
enabled: true,
minReward: 0.1,
Expand Down Expand Up @@ -4256,6 +4279,7 @@ function SettingsView({ models, channels }: { models: ModelItem[]; channels: Cha
setRegistrationEnabled(authData.auth.registrationEnabled);
setRegistrationMode(normalizeRegistrationMode(authData.auth.registrationMode));
setDefaultBalance(String(authData.auth.defaultBalance || 0));
setDefaultGroupId(authData.auth.defaultGroupId || "");
setCheckInSettings(checkInData.checkIn);
setAccount(accountData.account);
setAccountUsername(accountData.account?.username || "");
Expand All @@ -4268,16 +4292,27 @@ function SettingsView({ models, channels }: { models: ModelItem[]; channels: Cha
.catch(() => setMessage("设置加载失败"));
}, []);

async function saveAuthSettings(nextEnabled = registrationEnabled, nextMode = registrationMode, nextDefaultBalance = Number(defaultBalance)) {
async function saveAuthSettings(
nextEnabled = registrationEnabled,
nextMode = registrationMode,
nextDefaultBalance = Number(defaultBalance),
nextDefaultGroupId = defaultGroupId
) {
if (nextEnabled === null) return;
try {
const data = await fetchJson<{ auth: AuthSettings }>("/api/settings/auth", {
method: "PATCH",
body: JSON.stringify({ registrationEnabled: nextEnabled, registrationMode: nextMode, defaultBalance: nextDefaultBalance })
body: JSON.stringify({
registrationEnabled: nextEnabled,
registrationMode: nextMode,
defaultBalance: nextDefaultBalance,
defaultGroupId: nextDefaultGroupId
})
});
setRegistrationEnabled(data.auth.registrationEnabled);
setRegistrationMode(normalizeRegistrationMode(data.auth.registrationMode));
setDefaultBalance(String(data.auth.defaultBalance || 0));
setDefaultGroupId(data.auth.defaultGroupId || "");
setMessage(data.auth.registrationEnabled ? "注册设置已保存" : "已关闭用户注册");
} catch (error) {
setMessage(error instanceof Error ? error.message : "注册设置保存失败");
Expand Down Expand Up @@ -4668,6 +4703,32 @@ response = client.chat.completions.create(
</button>
</div>
</div>
<div className="setting">
<span>
默认注册分组
<small>新注册用户自动归入该分组,决定他们能用哪些渠道</small>
</span>
<div className="setting-value auth-default-balance">
<select
value={defaultGroupId}
onChange={(event) => setDefaultGroupId(event.target.value)}
aria-label="默认注册分组"
>
{groups.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
{groups.length === 0 && <option value="">暂无分组</option>}
</select>
<button
type="button"
className="secondary-button"
disabled={groups.length === 0}
onClick={() => saveAuthSettings(registrationEnabled, registrationMode, Number(defaultBalance), defaultGroupId)}
>
保存
</button>
</div>
</div>
</div>
</Panel>
)}
Expand Down
Loading
Loading