feat: add public account overview visibility
This commit is contained in:
@@ -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
@@ -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
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, ¬null, &def, &pk)
|
_ = rows.Scan(&cid, &name, &typ, ¬null, &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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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 ({
|
||||||
|
|||||||
Reference in New Issue
Block a user