feat: add public account overview visibility

This commit is contained in:
2026-08-14 14:38:22 +08:00
parent bf37999833
commit 915d26d51d
14 changed files with 660 additions and 62 deletions
+3 -1
View File
@@ -10,7 +10,7 @@
- 在本地 SQLite 中保留历史用量和限额快照 - 在本地 SQLite 中保留历史用量和限额快照
- 在限额重置前、重置后发送 Telegram 或邮件提醒 - 在限额重置前、重置后发送 Telegram 或邮件提醒
- 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息 - 通过 Telegram 菜单查询当前用量、重置时间、历史概览和账户信息
- 所有运行期配置均可通过前端完成 - 初始化后无需登录即可查看公开只读账号总览;只有登录管理员后才能添加账号、同步用量和修改运行期配置
- 前端适配 320px 起的手机浏览器,支持移动底部导航、iOS 安全区与深浅主题 - 前端适配 320px 起的手机浏览器,支持移动底部导航、iOS 安全区与深浅主题
- React 前端、Go 后端、Codex CLI 和 SQLite 运行在同一个容器中 - React 前端、Go 后端、Codex CLI 和 SQLite 运行在同一个容器中
@@ -46,6 +46,8 @@ http://服务器地址:8180
首次打开页面时创建管理员账号,并设置所在时区。用户名至少 3 位,密码至少 10 位。 首次打开页面时创建管理员账号,并设置所在时区。用户名至少 3 位,密码至少 10 位。
初始化完成后,首页会显示已勾选“公开显示到未登录总览”的账号;点击“登录后配置”并使用管理员账号登录,才能进入设置中心添加账号、同步数据、修改账号公开状态或配置提醒。未勾选公开的账号只会在登录后的总览中显示。
> 首次初始化没有额外安装码。创建管理员之前,不要将端口直接暴露到不可信网络。公网部署应使用 HTTPS 反向代理,并限制初始化阶段的访问来源。 > 首次初始化没有额外安装码。创建管理员之前,不要将端口直接暴露到不可信网络。公网部署应使用 HTTPS 反向代理,并限制初始化阶段的访问来源。
## 连接 Codex 账户 ## 连接 Codex 账户
+7 -6
View File
@@ -8,7 +8,7 @@
- `GET /health/live` 始终返回 `200 {"status":"ok"}`;`GET /health/ready` 在 SQLite 可用时返回 `200 {"status":"ok","appServer":bool}`,数据库不可用时返回 `503 {"error":string}`。`appServer` 表示至少一个账号的 app-server 已完成初始化。 - `GET /health/live` 始终返回 `200 {"status":"ok"}`;`GET /health/ready` 在 SQLite 可用时返回 `200 {"status":"ok","appServer":bool}`,数据库不可用时返回 `503 {"error":string}`。`appServer` 表示至少一个账号的 app-server 已完成初始化。
- JSON 请求体最多读取 1 MiB,拒绝未知字段;业务错误统一为 `{"error":string}`。未匹配 API 返回 `404 {"error":"接口不存在"}`。 - JSON 请求体最多读取 1 MiB,拒绝未知字段;业务错误统一为 `{"error":string}`。未匹配 API 返回 `404 {"error":"接口不存在"}`。
- 所有响应带 `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy: same-origin` 和同源 CSP。 - 所有响应带 `X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Referrer-Policy: same-origin` 和同源 CSP。
- `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用。status 的其他方法返回 405;其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。 - `GET /api/v1/system/status`、`POST /api/v1/setup`、`POST /api/v1/auth/login` 匿名可用;初始化完成后,`GET`/`HEAD /api/v1/accounts` 和 `GET`/`HEAD /api/v1/dashboard` 也提供已标记为公开账号的匿名只读总览。匿名总览会隐藏邮箱、认证方式、账号配置校验和内部错误字段;未公开账号对匿名请求按不存在处理。其余 API 要求有效 `session` cookie,非 `GET`/`HEAD` 请求还要求 `X-Requested-With: codex-helper`,否则分别返回 401 或 403。当前 dispatcher 只对部分路由显式限制 HTTP method;下文使用“任意方法”或“非 `GET`”的地方是对实际兼容行为的记录。
- session 有效期七天,cookie 为 `HttpOnly`、`SameSite=Strict`、`Path=/`;数据库只保存 token 摘要。登录失败按 `RemoteAddr` 在进程内限制为 15 分钟最多 10 次,超限返回 429。 - session 有效期七天,cookie 为 `HttpOnly`、`SameSite=Strict`、`Path=/`;数据库只保存 token 摘要。登录失败按 `RemoteAddr` 在进程内限制为 15 分钟最多 10 次,超限返回 429。
- 未命中静态文件的非 API GET 路径返回嵌入的 `index.html`,供前端路由回退。 - 未命中静态文件的非 API GET 路径返回嵌入的 `index.html`,供前端路由回退。
@@ -18,7 +18,7 @@
```text ```text
{id, displayName, email:string|null, planType:string|null, {id, displayName, email:string|null, planType:string|null,
expectedKind:"any"|"personal"|"team", expectedKind:"any"|"personal"|"team", publicVisible:bool,
actualKind:"unknown"|"personal"|"team", actualKind:"unknown"|"personal"|"team",
validationStatus:"pending"|"matched"|"mismatch"|"unknown", validationStatus:"pending"|"matched"|"mismatch"|"unknown",
possibleDuplicate:bool, connected:bool, createdAt, updatedAt} possibleDuplicate:bool, connected:bool, createdAt, updatedAt}
@@ -57,14 +57,15 @@
| 方法与路径 | 请求与响应 | | 方法与路径 | 请求与响应 |
| --- | --- | | --- | --- |
| `GET /api/v1/accounts` | 返回 `200 Account[]`,按 ID 升序。 | | `GET /api/v1/accounts` | 初始化后匿名可读;匿名只返回 `publicVisible=true` 的账号,按 ID 升序;匿名响应隐藏 `email`、`expectedKind`、`actualKind`、`validationStatus`、`possibleDuplicate` 和创建/更新时间。登录后返回全部账号及完整字段。 |
| `POST /api/v1/accounts` | body `{displayName,expectedKind}`;空名称默认为 `新账号`,空类型默认为 `any`;成功返回 `201 Account`。 | | `POST /api/v1/accounts` | body `{displayName,expectedKind,publicVisible}`;空名称默认为 `新账号`,空类型默认为 `any`,`publicVisible` 省略时默认为 `false`;成功返回 `201 Account`。 |
| `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?}`;名称不能为空,省略类型时保留旧值;成功返回 `200 {ok:true}`。 | | `PUT /api/v1/accounts/{id}` | body `{displayName,expectedKind?,publicVisible?}`;名称不能为空,省略类型或 `publicVisible` 时分别保留旧值;成功返回 `200 {ok:true}`。 |
| `DELETE /api/v1/accounts/{id}` | 停止该账号进程,删除账号及级联历史,再删除对应凭据目录;成功返回 `200 {ok:true}`。 | | `DELETE /api/v1/accounts/{id}` | 停止该账号进程,删除账号及级联历史,再删除对应凭据目录;成功返回 `200 {ok:true}`。 |
| `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 | | `POST /api/v1/accounts/{id}/login/device` | 启动并初始化 app-server,调用 `account/login/start` 的 `chatgptDeviceCode` 流程;返回含 `verificationUrl`、`userCode` 和 `loginId` 的结果。 |
| `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 | | `POST /api/v1/accounts/{id}/logout` | 调用 `account/logout` 并将连接状态置为 false;返回 `200 {ok:true}`。 |
| `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 | | `POST /api/v1/accounts/{id}/sync` | 同步指定账号;成功 `200 {ok:true}`,上游失败 502。 |
| `任意方法 /api/v1/dashboard?accountId={id}` | 返回内存中的 `Dashboard`;非 `GET`/`HEAD` 还需来源头。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 | | `GET`/`HEAD /api/v1/dashboard?accountId={id}` | 初始化后匿名可读公开账号,返回内存中的 `Dashboard`;匿名访问未公开账号返回 404,匿名响应隐藏邮箱、认证方式和内部错误字段。登录后可读取全部账号。省略或无效的零值 ID 使用账号 1,前端使用 `GET`。 |
| `任意非读方法 /api/v1/dashboard?accountId={id}` | 要求 session;非 `GET`/`HEAD` 还需来源头。保持兼容的读取行为,省略或无效的零值 ID 使用账号 1。 |
| `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 | | `POST /api/v1/sync?accountId={id}` | 旧兼容入口,同步指定账号;省略或零值 ID 使用账号 1。 |
账号不存在返回 404 `账号不存在`;非法路径 ID 返回 400 `账号 ID 无效`;无效 `expectedKind` 返回 400 `连接类型无效`。未知账号套餐不得猜测为个人或团队。 账号不存在返回 404 `账号不存在`;非法路径 ID 返回 400 `账号 ID 无效`;无效 `expectedKind` 返回 400 `连接类型无效`。未知账号套餐不得猜测为个人或团队。
+100 -28
View File
@@ -43,6 +43,14 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
a.login(w, r) a.login(w, r)
return return
} }
if p == "accounts" && readOnlyMethod(r.Method) {
a.accountsAPI(w, r)
return
}
if p == "dashboard" && readOnlyMethod(r.Method) {
a.dashboardAPI(w, r)
return
}
if !a.require(w, r) { if !a.require(w, r) {
return return
} }
@@ -53,17 +61,11 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]string{"username": username}) jsonOut(w, 200, map[string]string{"username": username})
case p == "auth/logout" && r.Method == "POST": case p == "auth/logout" && r.Method == "POST":
a.logout(w, r) a.logout(w, r)
case p == "accounts" && r.Method == "GET":
x, e := a.store.Accounts()
if e != nil {
jsonOut(w, 500, map[string]string{"error": e.Error()})
} else {
jsonOut(w, 200, x)
}
case p == "accounts" && r.Method == "POST": case p == "accounts" && r.Method == "POST":
var in struct { var in struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
ExpectedKind string `json:"expectedKind"` ExpectedKind string `json:"expectedKind"`
PublicVisible bool `json:"publicVisible"`
} }
if decode(r, &in) != nil { if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "请求格式错误"}) jsonOut(w, 400, map[string]string{"error": "请求格式错误"})
@@ -80,7 +82,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": "连接类型无效"}) jsonOut(w, 400, map[string]string{"error": "连接类型无效"})
break break
} }
x, e := a.store.CreateAccount(in.DisplayName, in.ExpectedKind) x, e := a.store.CreateAccountWithVisibility(in.DisplayName, in.ExpectedKind, in.PublicVisible)
if e == nil { if e == nil {
a.addRuntime(x.ID) a.addRuntime(x.ID)
jsonOut(w, 201, x) jsonOut(w, 201, x)
@@ -90,25 +92,7 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(p, "accounts/"): case strings.HasPrefix(p, "accounts/"):
a.accountAPI(w, r, p) a.accountAPI(w, r, p)
case p == "dashboard": case p == "dashboard":
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64) a.dashboardAPI(w, r)
if id == 0 {
id = 1
}
rt := a.runtime(id)
if rt == nil {
jsonOut(w, 404, map[string]string{"error": "账号不存在"})
} else {
rt.syncing.Lock()
d := rt.dash
rt.syncing.Unlock()
if d.Limits == nil {
d.Limits = []LimitBucket{}
}
if d.Usage == nil {
d.Usage = []UsagePoint{}
}
jsonOut(w, 200, d)
}
case p == "sync" && r.Method == "POST": case p == "sync" && r.Method == "POST":
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64) id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
if id == 0 { if id == 0 {
@@ -162,6 +146,93 @@ func (a *App) api(w http.ResponseWriter, r *http.Request) {
} }
} }
func readOnlyMethod(method string) bool {
return method == http.MethodGet || method == http.MethodHead
}
func (a *App) accountsAPI(w http.ResponseWriter, r *http.Request) {
if !a.store.Initialized() {
jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"})
return
}
x, e := a.store.Accounts()
if e != nil {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": e.Error()})
return
}
if !a.authed(r) {
visible := make([]store.Account, 0, len(x))
for _, account := range x {
if !account.PublicVisible {
continue
}
visible = append(visible, publicAccount(account))
}
x = visible
}
jsonOut(w, http.StatusOK, x)
}
func publicAccount(account store.Account) store.Account {
account.Email = nil
account.ExpectedKind = "any"
account.ActualKind = "unknown"
account.ValidationStatus = "unknown"
account.PossibleDuplicate = false
account.CreatedAt = 0
account.UpdatedAt = 0
return account
}
func (a *App) dashboardAPI(w http.ResponseWriter, r *http.Request) {
if !a.store.Initialized() {
jsonOut(w, http.StatusConflict, map[string]string{"error": "请先初始化"})
return
}
id, _ := strconv.ParseInt(r.URL.Query().Get("accountId"), 10, 64)
if id == 0 {
id = 1
}
account, accountErr := a.store.Account(id)
if accountErr != nil {
if accountErr == sql.ErrNoRows {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"})
} else {
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": accountErr.Error()})
}
return
}
if !a.authed(r) && !account.PublicVisible {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"})
return
}
rt := a.runtime(id)
if rt == nil {
jsonOut(w, http.StatusNotFound, map[string]string{"error": "账号不存在"})
return
}
rt.syncing.Lock()
d := rt.dash
rt.syncing.Unlock()
if d.Limits == nil {
d.Limits = []LimitBucket{}
}
if d.Usage == nil {
d.Usage = []UsagePoint{}
}
if !a.authed(r) {
d = publicDashboard(d)
}
jsonOut(w, http.StatusOK, d)
}
func publicDashboard(d Dashboard) Dashboard {
d.Account.Email = nil
d.Account.AuthMode = nil
d.LastError = ""
return d
}
func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) { func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
parts := strings.Split(p, "/") parts := strings.Split(p, "/")
if len(parts) < 2 { if len(parts) < 2 {
@@ -187,6 +258,7 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
var in struct { var in struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
ExpectedKind string `json:"expectedKind"` ExpectedKind string `json:"expectedKind"`
PublicVisible *bool `json:"publicVisible"`
} }
if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" { if decode(r, &in) != nil || strings.TrimSpace(in.DisplayName) == "" {
jsonOut(w, 400, map[string]string{"error": "名称不能为空"}) jsonOut(w, 400, map[string]string{"error": "名称不能为空"})
@@ -205,7 +277,7 @@ func (a *App) accountAPI(w http.ResponseWriter, r *http.Request, p string) {
jsonOut(w, 400, map[string]string{"error": "连接类型无效"}) jsonOut(w, 400, map[string]string{"error": "连接类型无效"})
return return
} }
e = a.store.UpdateAccountSettings(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind) e = a.store.UpdateAccountSettingsWithVisibility(id, strings.TrimSpace(in.DisplayName), in.ExpectedKind, in.PublicVisible)
if e == nil { if e == nil {
jsonOut(w, 200, map[string]bool{"ok": true}) jsonOut(w, 200, map[string]bool{"ok": true})
} }
+208
View File
@@ -6,11 +6,14 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings"
"sync" "sync"
"testing" "testing"
"time" "time"
"codex-helper/internal/security" "codex-helper/internal/security"
"codex-helper/internal/store"
) )
func TestSystemStatusRejectsNonGETMethods(t *testing.T) { func TestSystemStatusRejectsNonGETMethods(t *testing.T) {
@@ -47,6 +50,9 @@ func TestSystemStatusReturnsBuildVersion(t *testing.T) {
func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) { func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
a := newReminderTestApp(t) a := newReminderTestApp(t)
if err := a.store.Set("initialized", "true"); err != nil {
t.Fatal(err)
}
a.runtimes[1] = &accountRuntime{} a.runtimes[1] = &accountRuntime{}
_, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken("test-session"), time.Now().Add(time.Hour).Unix(), time.Now().Unix()) _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken("test-session"), time.Now().Add(time.Hour).Unix(), time.Now().Unix())
if err != nil { if err != nil {
@@ -71,6 +77,208 @@ func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
} }
} }
func TestAnonymousOverviewIsReadOnly(t *testing.T) {
a := newReminderTestApp(t)
if err := a.store.Set("initialized", "true"); err != nil {
t.Fatal(err)
}
email := "owner@example.com"
plan := "plus"
if err := a.store.UpdateAccount(1, &email, &plan, true); err != nil {
t.Fatal(err)
}
publicVisible := true
if err := a.store.UpdateAccountSettingsWithVisibility(1, "默认账号", "any", &publicVisible); err != nil {
t.Fatal(err)
}
a.runtimes[1] = &accountRuntime{
dash: Dashboard{
AccountID: 1,
DisplayName: "默认账号",
Account: AccountView{Email: &email, PlanType: &plan, Connected: true},
Limits: []LimitBucket{},
Usage: []UsagePoint{},
FetchedAt: time.Now().Unix(),
},
}
accountsRecorder := httptest.NewRecorder()
a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil))
if accountsRecorder.Code != http.StatusOK {
t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String())
}
var accounts []struct {
Email *string `json:"email"`
ExpectedKind string `json:"expectedKind"`
PublicVisible bool `json:"publicVisible"`
ValidationState string `json:"validationStatus"`
}
if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &accounts); err != nil {
t.Fatal(err)
}
if len(accounts) != 1 || accounts[0].Email != nil || !accounts[0].PublicVisible || accounts[0].ExpectedKind != "any" || accounts[0].ValidationState != "unknown" {
t.Fatalf("anonymous account data = %#v; sensitive account fields were not redacted", accounts)
}
dashboardRecorder := httptest.NewRecorder()
a.api(dashboardRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil))
if dashboardRecorder.Code != http.StatusOK {
t.Fatalf("anonymous dashboard status = %d, body = %s", dashboardRecorder.Code, dashboardRecorder.Body.String())
}
var publicDashboardBody Dashboard
if err := json.Unmarshal(dashboardRecorder.Body.Bytes(), &publicDashboardBody); err != nil {
t.Fatal(err)
}
if publicDashboardBody.Account.Email != nil || publicDashboardBody.Account.AuthMode != nil {
t.Fatalf("anonymous dashboard account = %#v; identity fields were not redacted", publicDashboardBody.Account)
}
for _, path := range []string{"/api/v1/settings/general", "/api/v1/accounts", "/api/v1/accounts/1/sync"} {
recorder := httptest.NewRecorder()
method := http.MethodGet
if path == "/api/v1/accounts" || strings.HasSuffix(path, "/sync") {
method = http.MethodPost
}
a.api(recorder, httptest.NewRequest(method, path, nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("anonymous %s status = %d, body = %s; configuration must require login", path, recorder.Code, recorder.Body.String())
}
}
session := "test-session"
if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil {
t.Fatal(err)
}
privateRecorder := httptest.NewRecorder()
privateRequest := httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId=1", nil)
privateRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
a.api(privateRecorder, privateRequest)
if privateRecorder.Code != http.StatusOK {
t.Fatalf("authenticated dashboard status = %d, body = %s", privateRecorder.Code, privateRecorder.Body.String())
}
var privateDashboardBody Dashboard
if err := json.Unmarshal(privateRecorder.Body.Bytes(), &privateDashboardBody); err != nil {
t.Fatal(err)
}
if privateDashboardBody.Account.Email == nil || *privateDashboardBody.Account.Email != email {
t.Fatalf("authenticated dashboard email = %v; want %q", privateDashboardBody.Account.Email, email)
}
configRecorder := httptest.NewRecorder()
configRequest := httptest.NewRequest(http.MethodPut, "/api/v1/settings/general", strings.NewReader(`{"timezone":"UTC","theme":"system","syncMinutes":5,"retentionDays":90,"beforeMinutes":30,"notifyBefore":true,"notifyAfter":true}`))
configRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
configRequest.Header.Set("X-Requested-With", "codex-helper")
a.api(configRecorder, configRequest)
if configRecorder.Code != http.StatusOK {
t.Fatalf("authenticated settings status = %d, body = %s", configRecorder.Code, configRecorder.Body.String())
}
}
func TestAccountVisibilityFiltersAnonymousOverviewAndCanBeUpdated(t *testing.T) {
a := newReminderTestApp(t)
if err := a.store.Set("initialized", "true"); err != nil {
t.Fatal(err)
}
publicAccount, err := a.store.CreateAccountWithVisibility("公开账号", "team", true)
if err != nil {
t.Fatal(err)
}
privateAccount, err := a.store.CreateAccount("私有账号", "personal")
if err != nil {
t.Fatal(err)
}
a.runtimes[publicAccount.ID] = &accountRuntime{}
a.runtimes[privateAccount.ID] = &accountRuntime{}
accountsRecorder := httptest.NewRecorder()
a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil))
if accountsRecorder.Code != http.StatusOK {
t.Fatalf("anonymous accounts status = %d, body = %s", accountsRecorder.Code, accountsRecorder.Body.String())
}
var visible []struct {
ID int64 `json:"id"`
PublicVisible bool `json:"publicVisible"`
}
if err := json.Unmarshal(accountsRecorder.Body.Bytes(), &visible); err != nil {
t.Fatal(err)
}
if len(visible) != 1 || visible[0].ID != publicAccount.ID || !visible[0].PublicVisible {
t.Fatalf("anonymous accounts = %#v; want only public account %d", visible, publicAccount.ID)
}
privateDashboard := httptest.NewRecorder()
privatePath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(privateAccount.ID, 10)
a.api(privateDashboard, httptest.NewRequest(http.MethodGet, privatePath, nil))
if privateDashboard.Code != http.StatusNotFound {
t.Fatalf("anonymous private dashboard status = %d, body = %s", privateDashboard.Code, privateDashboard.Body.String())
}
publicDashboard := httptest.NewRecorder()
publicPath := "/api/v1/dashboard?accountId=" + strconv.FormatInt(publicAccount.ID, 10)
a.api(publicDashboard, httptest.NewRequest(http.MethodGet, publicPath, nil))
if publicDashboard.Code != http.StatusOK {
t.Fatalf("anonymous public dashboard status = %d, body = %s", publicDashboard.Code, publicDashboard.Body.String())
}
session := "visibility-session"
if _, err := a.store.DB.Exec("INSERT INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil {
t.Fatal(err)
}
createRecorder := httptest.NewRecorder()
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/accounts", strings.NewReader(`{"displayName":"接口公开账号","expectedKind":"team","publicVisible":true}`))
createRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
createRequest.Header.Set("X-Requested-With", "codex-helper")
a.api(createRecorder, createRequest)
if createRecorder.Code != http.StatusCreated {
t.Fatalf("authenticated account creation status = %d, body = %s", createRecorder.Code, createRecorder.Body.String())
}
var created store.Account
if err := json.Unmarshal(createRecorder.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if !created.PublicVisible {
t.Fatalf("created account = %#v; want publicVisible=true", created)
}
authenticatedAccounts := httptest.NewRecorder()
authenticatedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil)
authenticatedRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
a.api(authenticatedAccounts, authenticatedRequest)
if authenticatedAccounts.Code != http.StatusOK {
t.Fatalf("authenticated accounts status = %d, body = %s", authenticatedAccounts.Code, authenticatedAccounts.Body.String())
}
var all []struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(authenticatedAccounts.Body.Bytes(), &all); err != nil {
t.Fatal(err)
}
if len(all) != 4 {
t.Fatalf("authenticated accounts = %#v; want default, public, private, and newly created accounts", all)
}
anonymousUpdate := httptest.NewRecorder()
anonymousUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`))
a.api(anonymousUpdate, anonymousUpdateRequest)
if anonymousUpdate.Code != http.StatusUnauthorized {
t.Fatalf("anonymous visibility update status = %d, body = %s", anonymousUpdate.Code, anonymousUpdate.Body.String())
}
authenticatedUpdate := httptest.NewRecorder()
authenticatedUpdateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/accounts/"+strconv.FormatInt(privateAccount.ID, 10), strings.NewReader(`{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`))
authenticatedUpdateRequest.AddCookie(&http.Cookie{Name: "session", Value: session})
authenticatedUpdateRequest.Header.Set("X-Requested-With", "codex-helper")
a.api(authenticatedUpdate, authenticatedUpdateRequest)
if authenticatedUpdate.Code != http.StatusOK {
t.Fatalf("authenticated visibility update status = %d, body = %s", authenticatedUpdate.Code, authenticatedUpdate.Body.String())
}
updated, err := a.store.Account(privateAccount.ID)
if err != nil {
t.Fatal(err)
}
if !updated.PublicVisible {
t.Fatalf("updated account = %#v; want publicVisible=true", updated)
}
}
func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) { func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) {
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC) now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC) reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)
+44 -6
View File
@@ -95,13 +95,14 @@ func (s *Store) migrateAccounts() error {
email TEXT, email TEXT,
plan_type TEXT, plan_type TEXT,
expected_kind TEXT NOT NULL DEFAULT 'any', expected_kind TEXT NOT NULL DEFAULT 'any',
public_visible INTEGER NOT NULL DEFAULT 0,
connected INTEGER NOT NULL DEFAULT 0, connected INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
)`); err != nil { )`); err != nil {
return err return err
} }
var hasExpectedKind bool var hasExpectedKind, hasPublicVisible bool
rows, qerr := tx.Query("PRAGMA table_info(accounts)") rows, qerr := tx.Query("PRAGMA table_info(accounts)")
if qerr != nil { if qerr != nil {
return qerr return qerr
@@ -112,6 +113,7 @@ func (s *Store) migrateAccounts() error {
var def any var def any
_ = rows.Scan(&cid, &name, &typ, &notnull, &def, &pk) _ = rows.Scan(&cid, &name, &typ, &notnull, &def, &pk)
hasExpectedKind = hasExpectedKind || name == "expected_kind" hasExpectedKind = hasExpectedKind || name == "expected_kind"
hasPublicVisible = hasPublicVisible || name == "public_visible"
} }
rows.Close() rows.Close()
if !hasExpectedKind { if !hasExpectedKind {
@@ -119,6 +121,11 @@ func (s *Store) migrateAccounts() error {
return err return err
} }
} }
if !hasPublicVisible {
if _, err = tx.Exec("ALTER TABLE accounts ADD COLUMN public_visible INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
}
var count int var count int
if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil { if err = tx.QueryRow("SELECT COUNT(*) FROM accounts").Scan(&count); err != nil {
return err return err
@@ -181,6 +188,7 @@ type Account struct {
Email *string `json:"email"` Email *string `json:"email"`
PlanType *string `json:"planType"` PlanType *string `json:"planType"`
ExpectedKind string `json:"expectedKind"` ExpectedKind string `json:"expectedKind"`
PublicVisible bool `json:"publicVisible"`
ActualKind string `json:"actualKind"` ActualKind string `json:"actualKind"`
ValidationStatus string `json:"validationStatus"` ValidationStatus string `json:"validationStatus"`
PossibleDuplicate bool `json:"possibleDuplicate"` PossibleDuplicate bool `json:"possibleDuplicate"`
@@ -190,7 +198,7 @@ type Account struct {
} }
func (s *Store) Accounts() ([]Account, error) { func (s *Store) Accounts() ([]Account, error) {
rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,connected,created_at,updated_at FROM accounts ORDER BY id") rows, e := s.DB.Query("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts ORDER BY id")
if e != nil { if e != nil {
return nil, e return nil, e
} }
@@ -198,7 +206,7 @@ func (s *Store) Accounts() ([]Account, error) {
out := []Account{} out := []Account{}
for rows.Next() { for rows.Next() {
var a Account var a Account
if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil { if e = rows.Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt); e != nil {
return nil, e return nil, e
} }
a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType) a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType)
@@ -217,21 +225,51 @@ func (s *Store) Accounts() ([]Account, error) {
} }
return out, rows.Err() return out, rows.Err()
} }
func (s *Store) Account(id int64) (Account, error) {
var a Account
err := s.DB.QueryRow("SELECT id,display_name,email,plan_type,expected_kind,public_visible,connected,created_at,updated_at FROM accounts WHERE id=?", id).
Scan(&a.ID, &a.DisplayName, &a.Email, &a.PlanType, &a.ExpectedKind, &a.PublicVisible, &a.Connected, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return Account{}, err
}
a.ActualKind, a.ValidationStatus = AccountKind(a.PlanType), validationStatus(a.ExpectedKind, a.Connected, a.PlanType)
return a, nil
}
func (s *Store) CreateAccount(name string, kinds ...string) (Account, error) { func (s *Store) CreateAccount(name string, kinds ...string) (Account, error) {
return s.createAccount(name, false, kinds...)
}
func (s *Store) CreateAccountWithVisibility(name, expectedKind string, publicVisible bool) (Account, error) {
return s.createAccount(name, publicVisible, expectedKind)
}
func (s *Store) createAccount(name string, publicVisible bool, kinds ...string) (Account, error) {
expectedKind := "any" expectedKind := "any"
if len(kinds) > 0 { if len(kinds) > 0 {
expectedKind = kinds[0] expectedKind = kinds[0]
} }
now := time.Now().Unix() now := time.Now().Unix()
r, e := s.DB.Exec("INSERT INTO accounts(display_name,expected_kind,created_at,updated_at) VALUES(?,?,?,?)", name, expectedKind, now, now) r, e := s.DB.Exec("INSERT INTO accounts(display_name,expected_kind,public_visible,created_at,updated_at) VALUES(?,?,?,?,?)", name, expectedKind, publicVisible, now, now)
if e != nil { if e != nil {
return Account{}, e return Account{}, e
} }
id, _ := r.LastInsertId() id, _ := r.LastInsertId()
return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil return Account{ID: id, DisplayName: name, ExpectedKind: expectedKind, PublicVisible: publicVisible, ActualKind: "unknown", ValidationStatus: "pending", CreatedAt: now, UpdatedAt: now}, nil
} }
func (s *Store) UpdateAccountSettings(id int64, name, expectedKind string) error { func (s *Store) UpdateAccountSettings(id int64, name, expectedKind string) error {
r, e := s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id) return s.UpdateAccountSettingsWithVisibility(id, name, expectedKind, nil)
}
func (s *Store) UpdateAccountSettingsWithVisibility(id int64, name, expectedKind string, publicVisible *bool) error {
var r sql.Result
var e error
if publicVisible == nil {
r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,updated_at=? WHERE id=?", name, expectedKind, time.Now().Unix(), id)
} else {
r, e = s.DB.Exec("UPDATE accounts SET display_name=?,expected_kind=?,public_visible=?,updated_at=? WHERE id=?", name, expectedKind, *publicVisible, time.Now().Unix(), id)
}
if e != nil { if e != nil {
return e return e
} }
+43 -2
View File
@@ -33,7 +33,7 @@ func TestAccountsAndPerAccountUsage(t *testing.T) {
} }
defer s.DB.Close() defer s.DB.Close()
accounts, err := s.Accounts() accounts, err := s.Accounts()
if err != nil || len(accounts) != 1 || accounts[0].ID != 1 { if err != nil || len(accounts) != 1 || accounts[0].ID != 1 || accounts[0].PublicVisible {
t.Fatalf("default accounts = %#v, %v", accounts, err) t.Fatalf("default accounts = %#v, %v", accounts, err)
} }
second, err := s.CreateAccount("Team workspace") second, err := s.CreateAccount("Team workspace")
@@ -97,11 +97,52 @@ func TestExistingAccountsGainExpectedKind(t *testing.T) {
if err != nil || len(accounts) != 1 { if err != nil || len(accounts) != 1 {
t.Fatalf("accounts = %#v, %v", accounts, err) t.Fatalf("accounts = %#v, %v", accounts, err)
} }
if accounts[0].ExpectedKind != "any" || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "matched" { if accounts[0].ExpectedKind != "any" || accounts[0].PublicVisible || accounts[0].ActualKind != "team" || accounts[0].ValidationStatus != "matched" {
t.Fatalf("migrated account = %#v", accounts[0]) t.Fatalf("migrated account = %#v", accounts[0])
} }
} }
func TestAccountVisibilitySettings(t *testing.T) {
s, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.DB.Close()
private, err := s.CreateAccount("私有账号")
if err != nil {
t.Fatal(err)
}
public, err := s.CreateAccountWithVisibility("公开账号", "team", true)
if err != nil {
t.Fatal(err)
}
if private.PublicVisible || !public.PublicVisible {
t.Fatalf("created accounts = %#v, %#v", private, public)
}
if err := s.UpdateAccountSettings(public.ID, "公开账号重命名", "team"); err != nil {
t.Fatal(err)
}
unchanged, err := s.Account(public.ID)
if err != nil {
t.Fatal(err)
}
if !unchanged.PublicVisible {
t.Fatal("legacy settings update unexpectedly changed public visibility")
}
visible := false
if err := s.UpdateAccountSettingsWithVisibility(public.ID, "公开账号重命名", "team", &visible); err != nil {
t.Fatal(err)
}
updated, err := s.Account(public.ID)
if err != nil {
t.Fatal(err)
}
if updated.PublicVisible {
t.Fatal("explicit false visibility update was not persisted")
}
}
func ptr(value string) *string { return &value } func ptr(value string) *string { return &value }
func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) { func TestLegacyUsageMigratesToDefaultAccount(t *testing.T) {
@@ -22,4 +22,6 @@ Codex OAuth 凭据由 app-server 写入各账号隔离的 `CODEX_HOME`,不经
## HTTP 边界 ## HTTP 边界
初始化完成后,标记为公开的账号列表和 Dashboard 以匿名只读方式开放,供公开总览加载;未标记账号对匿名请求不可见。匿名响应不返回邮箱、Codex 认证方式、账号配置校验字段或内部错误。新增账号、设备码登录、同步、删除、公开状态修改、提醒和所有设置接口仍必须经过 `require` 的 session 与来源校验。
JSON 解码限制为 1 MiB 并拒绝未知字段。统一安全头包括限制性 CSP、`nosniff`、禁止 iframe 和 same-origin referrer。前端路由、按钮禁用和邮箱掩码均不是服务端授权边界;所有新敏感端点必须在后端经过 `require`,改变 API 方法时还要核对来源头逻辑。 JSON 解码限制为 1 MiB 并拒绝未知字段。统一安全头包括限制性 CSP、`nosniff`、禁止 iframe 和 same-origin referrer。前端路由、按钮禁用和邮箱掩码均不是服务端授权边界;所有新敏感端点必须在后端经过 `require`,改变 API 方法时还要核对来源头逻辑。
@@ -6,7 +6,7 @@
- `settings` 保存通用、SMTP、Telegram、绑定码及安装标记;秘密单独以密文 key 保存。 - `settings` 保存通用、SMTP、Telegram、绑定码及安装标记;秘密单独以密文 key 保存。
- `admin` 与 `sessions` 保存唯一管理员和登录会话。 - `admin` 与 `sessions` 保存唯一管理员和登录会话。
- `accounts` 保存 Codex 连接元数据与期望套餐类型。 - `accounts` 保存 Codex 连接元数据、期望套餐类型和 `public_visible`;该字段默认 `0`,旧账号迁移后保持私有,只有管理员明确开启后才进入匿名总览。
- `daily_usage` 和 `limit_snapshots` 按 `account_id` 保存历史,删除账号时级联删除。 - `daily_usage` 和 `limit_snapshots` 按 `account_id` 保存历史,删除账号时级联删除。
- `notifications` 保存稳定去重键、调度时间、结构化消息、状态、次数和错误。 - `notifications` 保存稳定去重键、调度时间、结构化消息、状态、次数和错误。
- `telegram_updates` 保存 Bot API offset。 - `telegram_updates` 保存 Bot API offset。
+1 -1
View File
@@ -4,7 +4,7 @@
## 状态与路由 ## 状态与路由
应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;未登录渲染登录页;登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。 应用启动先请求 `system/status`,已初始化时再请求 `auth/me`。未初始化渲染安装页;初始化后未登录渲染公开只读 `/` 总览和 `/login` 登录页,登录后由 `BrowserRouter` 提供 `/` 总览和 `/settings` 设置,未登录访问 `/settings` 回到公开总览,未知路径回到 `/`。状态响应中的构建版本以 `v<version>` 徽标显示在安装页、登录页、公开总览和登录后侧栏的品牌区域;登录后侧栏使用放大的品牌图标,图标、名称和版本徽标保持单行排列。这些分支只负责交互,服务端 session 才是安全边界。
主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后刷新;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图;顶部“刷新全部”和卡片内的账号级刷新都会先调用对应的 sync,再重新读取 Dashboard。 主题以服务端通用设置为持久来源,`localStorage` 仅用于首屏缓存;system 模式会跟随系统主题变化。总览先加载账号列表,再并发加载每个账号的 Dashboard,并在每轮请求完成 30 秒后刷新;每个账号独立维护请求、加载和错误状态,校验响应账号,避免迟到响应覆盖其他账号。总览默认只展示账号摘要,展开卡片后显示完整限额、统计和 Token 图;顶部“刷新全部”和卡片内的账号级刷新都会先调用对应的 sync,再重新读取 Dashboard。
+1 -1
View File
@@ -4,7 +4,7 @@
## API 与认证 ## API 与认证
- [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 是路径、状态码、响应字段和兼容文案的契约。匿名入口只能是当前 status、setup 和 login。 - [`backend/CONTRACT.md`](../../backend/CONTRACT.md) 是路径、状态码、响应字段和兼容文案的契约。匿名入口包括 status、setup、login,以及初始化完成后已标记公开账号的列表和 Dashboard 只读总览;新增或修改配置、账号、同步和凭据接口仍必须要求 session。
- 所有受保护端点必须回查 session;非只读请求还必须验证 `X-Requested-With`。前端路由与按钮不能代替后端门禁。 - 所有受保护端点必须回查 session;非只读请求还必须验证 `X-Requested-With`。前端路由与按钮不能代替后端门禁。
- session 原 token 只进入 cookie,SQLite 只保存摘要;密码保持 argon2id。错误和日志不得包含密码、cookie、Bot Token、SMTP 密码或 Codex token。 - session 原 token 只进入 cookie,SQLite 只保存摘要;密码保持 argon2id。错误和日志不得包含密码、cookie、Bot Token、SMTP 密码或 Codex token。
- 初始化是事务性单管理员创建。新增自动初始化能力前必须保留并发与首次公网暴露的安全边界。 - 初始化是事务性单管理员创建。新增自动初始化能力前必须保留并发与首次公网暴露的安全边界。
+80 -5
View File
@@ -2,6 +2,7 @@ import React, { lazy, Suspense, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { import {
BrowserRouter, BrowserRouter,
Link,
Navigate, Navigate,
Route, Route,
Routes, Routes,
@@ -18,6 +19,7 @@ import {
Flame, Flame,
Github, Github,
Gauge, Gauge,
LogIn,
Mail, Mail,
LogOut, LogOut,
Moon, Moon,
@@ -83,7 +85,13 @@ function App() {
{authenticated ? ( {authenticated ? (
<Route path="*" element={<Shell version={status.version} />} /> <Route path="*" element={<Shell version={status.version} />} />
) : ( ) : (
<Route path="*" element={<Login version={status.version} />} /> <>
<Route path="/login" element={<Login version={status.version} />} />
<Route
path="*"
element={<PublicShell version={status.version} />}
/>
</>
)} )}
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -206,10 +214,32 @@ function Login({ version }: { version: string }) {
</label> </label>
{e && <p className="error">{e}</p>} {e && <p className="error">{e}</p>}
<button>登录</button> <button>登录</button>
<p className="hint public-login-link">
<Link to="/">查看公开总览</Link>
</p>
</form> </form>
</main> </main>
); );
} }
function PublicShell({ version }: { version: string }) {
const nav = useNavigate();
return (
<div className="public-app">
<header className="public-header">
<Brand version={version} />
<button className="secondary" onClick={() => nav("/login")}>
<LogIn /> 登录后配置
</button>
</header>
<main className="public-content">
<Routes>
<Route path="/" element={<Dashboard publicView />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
);
}
function Shell({ version }: { version: string }) { function Shell({ version }: { version: string }) {
const { logout } = useAuth(); const { logout } = useAuth();
const nav = useNavigate(); const nav = useNavigate();
@@ -358,7 +388,7 @@ const emptyDashboardCardState = (): DashboardCardState => ({
refresh: "idle", refresh: "idle",
}); });
function Dashboard() { function Dashboard({ publicView = false }: { publicView?: boolean }) {
const [accounts, setAccounts] = useState<Account[] | null>(null), const [accounts, setAccounts] = useState<Account[] | null>(null),
[cards, setCards] = useState<Record<number, DashboardCardState>>({}), [cards, setCards] = useState<Record<number, DashboardCardState>>({}),
[expanded, setExpanded] = useState<Record<number, boolean>>({}), [expanded, setExpanded] = useState<Record<number, boolean>>({}),
@@ -539,7 +569,12 @@ function Dashboard() {
if (!accounts.length) if (!accounts.length)
return ( return (
<> <>
<Header title="用量总览" sub="请先在设置中心添加账号" /> <Header
title="用量总览"
sub={
publicView ? "当前还没有可公开展示的账号" : "请先在设置中心添加账号"
}
/>
<div className="panel empty">尚未添加 Codex 账号</div> <div className="panel empty">尚未添加 Codex 账号</div>
</> </>
); );
@@ -569,6 +604,7 @@ function Dashboard() {
key={account.id} key={account.id}
account={account} account={account}
state={cards[account.id] || emptyDashboardCardState()} state={cards[account.id] || emptyDashboardCardState()}
publicView={publicView}
expanded={Boolean(expanded[account.id])} expanded={Boolean(expanded[account.id])}
onToggle={() => onToggle={() =>
setExpanded((current) => ({ setExpanded((current) => ({
@@ -587,12 +623,14 @@ function Dashboard() {
function AccountOverviewCard({ function AccountOverviewCard({
account, account,
state, state,
publicView,
expanded, expanded,
onToggle, onToggle,
onRefresh, onRefresh,
}: { }: {
account: Account; account: Account;
state: DashboardCardState; state: DashboardCardState;
publicView: boolean;
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
onRefresh: () => void; onRefresh: () => void;
@@ -619,7 +657,7 @@ function AccountOverviewCard({
<span className="account-summary-identity"> <span className="account-summary-identity">
<strong>{title}</strong> <strong>{title}</strong>
<span> <span>
{email ? maskEmail(email) : "尚未登录"} ·{" "} {publicView ? "公开只读" : email ? maskEmail(email) : "尚未登录"} ·{" "}
{connected ? "已连接" : "未连接"} {connected ? "已连接" : "未连接"}
</span> </span>
</span> </span>
@@ -638,6 +676,7 @@ function AccountOverviewCard({
dashboard={dashboard} dashboard={dashboard}
state={state} state={state}
onRefresh={onRefresh} onRefresh={onRefresh}
publicView={publicView}
/> />
) : ( ) : (
<div className="account-detail-state"> <div className="account-detail-state">
@@ -701,10 +740,12 @@ function AccountDashboardDetails({
dashboard, dashboard,
state, state,
onRefresh, onRefresh,
publicView,
}: { }: {
dashboard: Dash; dashboard: Dash;
state: DashboardCardState; state: DashboardCardState;
onRefresh: () => void; onRefresh: () => void;
publicView: boolean;
}) { }) {
return ( return (
<> <>
@@ -747,7 +788,9 @@ function AccountDashboardDetails({
icon={<Mail />} icon={<Mail />}
label="登录邮箱" label="登录邮箱"
value={ value={
dashboard.account.email publicView
? "登录后查看"
: dashboard.account.email
? maskEmail(dashboard.account.email) ? maskEmail(dashboard.account.email)
: "尚未连接" : "尚未连接"
} }
@@ -1124,6 +1167,7 @@ function CodexSettings() {
[deviceLogin, setDeviceLogin] = useState<DeviceLogin | null>(null), [deviceLogin, setDeviceLogin] = useState<DeviceLogin | null>(null),
[active, setActive] = useState(0), [active, setActive] = useState(0),
[newKind, setNewKind] = useState<"personal" | "team">("team"), [newKind, setNewKind] = useState<"personal" | "team">("team"),
[newPublicVisible, setNewPublicVisible] = useState(false),
[busy, setBusy] = useState(false), [busy, setBusy] = useState(false),
[err, setErr] = useState(""); [err, setErr] = useState("");
const load = async (signal?: AbortSignal) => { const load = async (signal?: AbortSignal) => {
@@ -1195,6 +1239,7 @@ function CodexSettings() {
const x = await post("accounts", (value) => decodeAccounts([value])[0], { const x = await post("accounts", (value) => decodeAccounts([value])[0], {
displayName: `账号 ${xs.length + 1}`, displayName: `账号 ${xs.length + 1}`,
expectedKind: newKind, expectedKind: newKind,
publicVisible: newPublicVisible,
}); });
await load(); await load();
setActive(x.id); setActive(x.id);
@@ -1238,6 +1283,14 @@ function CodexSettings() {
<option value="personal">个人订阅</option> <option value="personal">个人订阅</option>
</select> </select>
</label> </label>
<label className="checks">
<input
type="checkbox"
checked={newPublicVisible}
onChange={(e) => setNewPublicVisible(e.target.checked)}
/>
公开显示到未登录总览
</label>
<button disabled={busy} onClick={add}> <button disabled={busy} onClick={add}>
{busy && active === 0 ? "服务启动中…" : "添加账号"} {busy && active === 0 ? "服务启动中…" : "添加账号"}
</button> </button>
@@ -1263,6 +1316,7 @@ function CodexSettings() {
await put(`accounts/${x.id}`, decodeOK, { await put(`accounts/${x.id}`, decodeOK, {
displayName: name, displayName: name,
expectedKind: x.expectedKind, expectedKind: x.expectedKind,
publicVisible: x.publicVisible,
}); });
void load().catch((error) => void load().catch((error) =>
setErr(toErrorMessage(error)), setErr(toErrorMessage(error)),
@@ -1296,6 +1350,7 @@ function CodexSettings() {
await put(`accounts/${x.id}`, decodeOK, { await put(`accounts/${x.id}`, decodeOK, {
displayName: x.displayName, displayName: x.displayName,
expectedKind: e.target.value, expectedKind: e.target.value,
publicVisible: x.publicVisible,
}); });
void load().catch((error) => setErr(toErrorMessage(error))); void load().catch((error) => setErr(toErrorMessage(error)));
}} }}
@@ -1305,6 +1360,26 @@ function CodexSettings() {
<option value="team">Team / Business</option> <option value="team">Team / Business</option>
</select> </select>
</label> </label>
<label className="checks">
<input
type="checkbox"
checked={x.publicVisible}
onChange={async (e) => {
try {
setErr("");
await put(`accounts/${x.id}`, decodeOK, {
displayName: x.displayName,
expectedKind: x.expectedKind,
publicVisible: e.target.checked,
});
await load();
} catch (q) {
setErr(toErrorMessage(q));
}
}}
/>
公开显示到未登录总览
</label>
<div className="account-buttons"> <div className="account-buttons">
<button <button
className="secondary" className="secondary"
+44
View File
@@ -221,6 +221,35 @@ button svg {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
} }
.public-app {
min-height: 100vh;
min-height: 100dvh;
}
.public-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 20px;
width: 100%;
max-width: 1500px;
margin: 0 auto 0;
padding: 24px 42px;
border-bottom: 1px solid var(--border);
}
.public-header button {
white-space: nowrap;
}
.public-content {
width: 100%;
max-width: 1500px;
margin: 0 auto;
padding: 42px;
}
.public-login-link {
margin: -4px 0 0;
text-align: center;
}
.mobile-topbar, .mobile-topbar,
.mobile-bottom-nav { .mobile-bottom-nav {
display: none; display: none;
@@ -939,6 +968,21 @@ a {
padding: 22px max(14px, env(safe-area-inset-right)) 24px padding: 22px max(14px, env(safe-area-inset-right)) 24px
max(14px, env(safe-area-inset-left)); max(14px, env(safe-area-inset-left));
} }
.public-header {
min-height: calc(58px + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 12px 0 16px;
}
.public-header .logo {
min-width: 0;
gap: 7px;
}
.public-header .logo svg {
flex: none;
}
.public-content {
padding: 22px max(14px, env(safe-area-inset-right)) 24px
max(14px, env(safe-area-inset-left));
}
header { header {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
+2
View File
@@ -26,6 +26,7 @@ export interface Account {
email: string | null; email: string | null;
planType: string | null; planType: string | null;
expectedKind: ExpectedKind; expectedKind: ExpectedKind;
publicVisible: boolean;
actualKind: "unknown" | "personal" | "team"; actualKind: "unknown" | "personal" | "team";
validationStatus: ValidationStatus; validationStatus: ValidationStatus;
possibleDuplicate: boolean; possibleDuplicate: boolean;
@@ -144,6 +145,7 @@ export const decodeAccount: Decoder<Account> = (value) => {
["any", "personal", "team"], ["any", "personal", "team"],
"expectedKind", "expectedKind",
), ),
publicVisible: boolean(x.publicVisible, "publicVisible"),
actualKind: enumValue( actualKind: enumValue(
x.actualKind, x.actualKind,
["unknown", "personal", "team"], ["unknown", "personal", "team"],
+118 -5
View File
@@ -23,6 +23,7 @@ const responses: Record<string, unknown> = {
email: "test@example.com", email: "test@example.com",
planType: "plus", planType: "plus",
expectedKind: "personal", expectedKind: "personal",
publicVisible: false,
actualKind: "personal", actualKind: "personal",
validationStatus: "matched", validationStatus: "matched",
possibleDuplicate: false, possibleDuplicate: false,
@@ -180,6 +181,54 @@ test("shows every account as a collapsible summary on the overview", async ({
await expect(page.locator(".account-detail")).toHaveCount(0); await expect(page.locator(".account-detail")).toHaveCount(0);
}); });
test("shows a read-only overview before login", async ({ page }) => {
await page.route("**/api/v1/**", async (route) => {
const key = new URL(route.request().url()).pathname.replace("/api/v1/", "");
if (key === "system/status")
return route.fulfill({
json: { initialized: true, appServer: true, version: "test" },
});
if (key === "auth/me")
return route.fulfill({ status: 401, json: { error: "未登录" } });
if (key === "accounts") return route.fulfill({ json: responses.accounts });
if (key === "dashboard")
return route.fulfill({
json: {
accountId: 1,
displayName: "默认账号",
account: {
email: null,
planType: "plus",
connected: true,
},
limits: [],
summary: {},
usage: [],
fetchedAt: 1_900_000_000,
stale: false,
},
});
return route.fulfill({ json: responses[key] ?? {} });
});
await page.goto("/");
await expect(page.getByRole("button", { name: "登录后配置" })).toBeVisible();
await expect(page.locator(".account-overview")).toHaveCount(1);
await expect(page.getByRole("button", { name: "刷新全部" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "设置中心" })).toHaveCount(0);
const account = page.locator('[data-account-id="1"]');
await account.getByRole("button", { name: "展开默认账号详情" }).click();
await expect(account.getByText("登录后查看")).toBeVisible();
await expect(account.locator(".account-detail .refresh")).toHaveCount(0);
await page.goto("/settings");
await expect(page).toHaveURL(/\/$/);
await page.getByRole("button", { name: "登录后配置" }).click();
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
});
test("shows the build version in the authenticated brand", async ({ page }) => { test("shows the build version in the authenticated brand", async ({ page }) => {
await openSettings(page); await openSettings(page);
const mobile = (page.viewportSize()?.width ?? 0) <= 800; const mobile = (page.viewportSize()?.width ?? 0) <= 800;
@@ -260,7 +309,7 @@ test("shows the build version on login", async ({ page }) => {
await page.route("**/api/v1/auth/me", (route) => await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({ status: 401, json: { error: "unauthorized" } }), route.fulfill({ status: 401, json: { error: "unauthorized" } }),
); );
await page.goto("/"); await page.goto("/login");
const badge = page.locator(".login .version-badge"); const badge = page.locator(".login .version-badge");
await expect(badge).toBeVisible(); await expect(badge).toBeVisible();
await expect(badge).toHaveText("vtest"); await expect(badge).toHaveText("vtest");
@@ -570,6 +619,71 @@ test("deleting a newly added account clears its device authorization", async ({
).toBeVisible(); ).toBeVisible();
}); });
test("sets public visibility when adding and editing an account", async ({
page,
}) => {
let accounts: Array<(typeof responses.accounts)[number]> = [];
let createdBody: { publicVisible?: boolean } | undefined;
let updatedBody: { publicVisible?: boolean } | undefined;
await page.route("**/api/v1/**", async (route) => {
const request = route.request();
const key = new URL(request.url()).pathname.replace("/api/v1/", "");
if (key === "accounts" && request.method() === "GET")
return route.fulfill({ json: accounts });
if (key === "accounts" && request.method() === "POST") {
createdBody = request.postDataJSON() as { publicVisible?: boolean };
const account = {
...responses.accounts[0],
id: 2,
displayName: "账号 1",
email: "",
connected: false,
validationStatus: "pending" as const,
publicVisible: createdBody.publicVisible === true,
};
accounts = [account];
return route.fulfill({ json: account });
}
if (key === "accounts/2/login/device")
return route.fulfill({
json: {
verificationUrl: "https://auth.openai.com/codex/device",
userCode: "PLTJ-7M6I6",
},
});
if (key === "accounts/2" && request.method() === "PUT") {
updatedBody = request.postDataJSON() as { publicVisible?: boolean };
accounts = [
{
...accounts[0],
publicVisible: updatedBody.publicVisible === true,
},
];
return route.fulfill({ json: { ok: true } });
}
return route.fulfill({ json: responses[key] ?? {} });
});
await page.goto("/settings");
await page.getByRole("tab", { name: "Codex" }).click();
const addVisibility = page.locator(
".add-account-controls input[type=checkbox]",
);
await expect(addVisibility).not.toBeChecked();
await addVisibility.check();
await page.getByRole("button", { name: "添加账号" }).click();
await expect.poll(() => createdBody?.publicVisible).toBe(true);
const accountCard = page.locator(".account-card");
const accountVisibility = accountCard.locator('input[type="checkbox"]');
await expect(accountVisibility).toBeChecked();
await accountVisibility.evaluate((checkbox) =>
(checkbox as HTMLInputElement).click(),
);
await expect.poll(() => updatedBody?.publicVisible).toBe(false);
await expect(accountVisibility).not.toBeChecked();
});
test("Codex account cards fit within the viewport", async ({ page }) => { test("Codex account cards fit within the viewport", async ({ page }) => {
await openSettings(page); await openSettings(page);
await page.getByRole("tab", { name: "Codex" }).click(); await page.getByRole("tab", { name: "Codex" }).click();
@@ -645,12 +759,12 @@ test("Codex account emails are masked everywhere they are displayed", async ({
await expect(page.locator("body")).not.toContainText("test@example.com"); await expect(page.locator("body")).not.toContainText("test@example.com");
}); });
test("returns to login when an authenticated request receives 401", async ({ test("shows the public overview when the session is unavailable", async ({
page, page,
}) => { }) => {
await page.route("**/api/v1/**", async (route) => { await page.route("**/api/v1/**", async (route) => {
const key = new URL(route.request().url()).pathname.replace("/api/v1/", ""); const key = new URL(route.request().url()).pathname.replace("/api/v1/", "");
if (key === "accounts/1/sync") if (key === "auth/me")
return route.fulfill({ status: 401, json: { error: "未登录" } }); return route.fulfill({ status: 401, json: { error: "未登录" } });
if (key === "dashboard") if (key === "dashboard")
return route.fulfill({ return route.fulfill({
@@ -669,8 +783,7 @@ test("returns to login when an authenticated request receives 401", async ({
}); });
await page.goto("/"); await page.goto("/");
await expect(page.locator(".account-overview")).toBeVisible(); await expect(page.locator(".account-overview")).toBeVisible();
await page.getByRole("button", { name: "刷新全部" }).click(); await expect(page.getByRole("button", { name: "登录后配置" })).toBeVisible();
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
}); });
test("keeps each account dashboard in its own card when responses finish out of order", async ({ test("keeps each account dashboard in its own card when responses finish out of order", async ({