This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git


The following commit(s) were added to refs/heads/master by this push:
     new c39dfa9  feat: take the friction out of the first run
c39dfa9 is described below

commit c39dfa9f985f87fa40ff9ec8de13e514ea7c571b
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 11:43:10 2026 +0800

    feat: take the friction out of the first run
---
 README.md                                 |  62 ++++--
 README_zh.md                              |  58 +++--
 conf/app.conf                             |   5 +
 conf/conf.go                              |  33 ++-
 controllers/account.go                    |   8 +-
 controllers/agent_provider.go             |   6 +-
 controllers/proxy.go                      |  33 +++
 controllers/system.go                     |  13 ++
 main.go                                   |  16 +-
 object/provider.go                        |  33 +++
 object/setting.go                         |  27 +++
 routers/router.go                         |   1 +
 scripts/install.ps1                       |  31 ++-
 scripts/install.sh                        |  68 +++++-
 service/startup.go                        |  25 ++-
 util/daemon.go                            | 156 +++++++++++++
 util/{request.go => daemon_unix.go}       |  30 +--
 util/{request.go => daemon_windows.go}    |  32 ++-
 util/port.go                              |  15 +-
 util/request.go                           |  13 ++
 util/string.go                            |  10 +
 web/src/App.tsx                           |  14 ++
 web/src/backend/MiscBackend.ts            |   5 +
 web/src/components/EnvSnippet.tsx         |   4 +-
 web/src/components/ProviderCard.tsx       |   9 +-
 web/src/components/ProviderGridCard.tsx   | 211 ++++++++++++++++++
 web/src/components/shared/app-sidebar.tsx |   5 +-
 web/src/components/shared/page-header.tsx |  23 +-
 web/src/lib/providers.ts                  |  30 ++-
 web/src/lib/relayToken.ts                 |  48 ++++
 web/src/locales/en/data.json              |  13 +-
 web/src/locales/zh/data.json              |  13 +-
 web/src/nav.ts                            |   3 +
 web/src/pages/ProviderListPage.tsx        | 359 ++++++++++--------------------
 web/src/pages/SettingPage.tsx             |   6 +-
 web/src/types.ts                          |   1 +
 36 files changed, 1058 insertions(+), 361 deletions(-)

diff --git a/README.md b/README.md
index 34a2019..0462833 100644
--- a/README.md
+++ b/README.md
@@ -53,11 +53,13 @@ On Windows, in PowerShell:
 irm 
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/install.ps1
 | iex
 ```
 
-Either one downloads the build for this machine, unpacks it into 
`~/.local/share/casbin-gateway` (`%LOCALAPPDATA%\casbin-gateway` on Windows), 
puts a `casbin-gateway` command on your PATH, and starts it. Then open:
+Either one downloads the build for this machine, unpacks it into 
`~/.local/share/casbin-gateway` (`%LOCALAPPDATA%\casbin-gateway` on Windows), 
puts a `casbin-gateway` command on your PATH, starts it in the background, and 
arranges for it to start again when you log in. The terminal you installed from 
is yours again straight away. Then open:
 
-**http://localhost:17000** — sign in as `admin` with the password `123`, and 
change it from **My Account**.
+**http://localhost:17000** — no sign-in: Gateway serves this machine only and 
signs the local admin in on sight.
 
-That is the whole installation. Gateway keeps its data in a SQLite file inside 
its own directory, and signs you in against its own user table.
+That is the whole installation. Gateway keeps its data in a SQLite file inside 
its own directory.
+
+The password behind that account is `admin` / `123`, and it only matters if 
you open Gateway to the network — see [Serving other 
machines](#serving-other-machines).
 
 ### What to do next
 
@@ -76,15 +78,15 @@ Agents are found by reading the user accounts, home 
directories and install path
 This is what fills **LLM Records**, and what lets Gateway keep the vendor key 
instead of the agent.
 
 1. **Providers** → **Add**: pick the type (OpenAI- or Anthropic-compatible), 
paste the vendor base URL and API key, and list the models it serves.
-2. **Agents** → open an agent → pick that provider.
-3. Copy the environment snippet the page shows, and start the agent from a 
shell that has it:
+2. **Agents** → open an agent → pick that provider. For an agent whose 
configuration format Gateway knows, **Write configuration** puts it in the 
agent's own file — **Preview** shows exactly what that will be first, and 
**Restore** undoes it. Picking a different provider afterwards rewrites the 
file on the spot, so switching from then on is one click, from either page.
+3. For any other agent, copy the environment snippet the page shows and start 
the agent from a shell that has it:
 
 ```bash
 export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code";
-export ANTHROPIC_AUTH_TOKEN="casbin-gateway"
+export ANTHROPIC_AUTH_TOKEN="cg-..."
 ```
 
-The token is a placeholder — the agent refuses to start without one, and 
Gateway authenticates upstream with the provider's own key.
+The token is Gateway's own relay token, not a vendor key: the agent refuses to 
start without something in that variable, and Gateway authenticates upstream 
with the provider's key instead. The snippet on the page already has the real 
value filled in.
 
 ### No API key: keep the sign-in the agent already has
 
@@ -96,11 +98,21 @@ Codex is the exception: its ChatGPT sign-in talks to a 
different API than the ch
 
 ### Stopping, upgrading, removing
 
-- **Stop**: `Ctrl-C`. **Start again**: `casbin-gateway`, from any directory — 
the command is a wrapper that always starts Gateway in its install directory, 
where its data lives.
+- **Stop**: `casbin-gateway stop`. **Start again**: `casbin-gateway start`. 
**Check**: `casbin-gateway status`. All three work from any directory — the 
command is a wrapper that always starts Gateway in its install directory, where 
its data lives.
+- **Run in the foreground** instead, to watch it: `casbin-gateway`, stopped 
with `Ctrl-C`. In the background its console output goes to 
`logs/casbin-gateway.out`.
 - **Upgrade**: run the install command again. Your database and settings are 
untouched.
-- **Remove**: delete `~/.local/share/casbin-gateway` and 
`~/.local/bin/casbin-gateway` (on Windows, `%LOCALAPPDATA%\casbin-gateway` and 
its PATH entry).
+- **Remove**: delete `~/.local/share/casbin-gateway` and 
`~/.local/bin/casbin-gateway` (on Windows, `%LOCALAPPDATA%\casbin-gateway` and 
its PATH entry), plus the startup entry the installer names when it finishes.
+
+Set `INSTALL_DIR` to install somewhere else, `NO_START=1` to install without 
starting, or `NO_AUTOSTART=1` to install without starting at login.
+
+### Serving other machines
+
+Gateway binds `127.0.0.1` by default, because two things are wide open to 
whoever can reach the port: the UI signs the local admin in without asking, and 
`/v1` relays with the API keys stored here. Both are exactly what you want from 
a local tool and neither should be offered to a network.
+
+To serve other machines anyway, set `httpaddr = 0.0.0.0` in `conf/app.conf`, 
and then:
 
-Set `INSTALL_DIR` to install somewhere else, or `NO_START=1` to install 
without starting.
+1. **Change the admin password** from **My Account**. The auto sign-in stops 
at the first request that is not from this machine, so from then on the 
password is the only thing in the way.
+2. **Send the relay token** with every request to `/v1`. Gateway generates one 
on first start and shows it under **Settings → Security**; the environment 
snippets on the Providers and Agents pages already carry it, and it is what 
Gateway writes into the configuration of an agent it switches. Requests from 
this machine never need it.
 
 **These are nightly builds**, rebuilt from `master` on every push and 
published as the 
[`nightly`](https://github.com/apache/casbin-gateway/releases/tag/nightly) 
pre-release. They exist so that Gateway can be tried without a Go and Node 
toolchain; anything else should be built from a source release.
 
@@ -140,6 +152,7 @@ Everything is optional. Settings are changed on the 
**Settings** page of the web
 | Setting | Default | What it does |
 | --- | --- | --- |
 | `httpport` | `17000` | Port of the web UI and the REST API |
+| `httpaddr` | `127.0.0.1` | Interface the web UI binds to — see [Serving 
other machines](#serving-other-machines) |
 | `driverName` / `dataSourceName` | `sqlite` / `./data/casbin-gateway.db` | 
Where data is stored |
 | `gatewayEnabled` | `false` | Turns the reverse-proxy WAF on |
 | `gatewayHttpPort` / `gatewayHttpsPort` | `80` / `443` | Ports the proxy 
listens on |
@@ -150,19 +163,20 @@ Everything is optional. Settings are changed on the 
**Settings** page of the web
 Gateway prints what it is actually doing when it starts, so the result can be 
checked instead of the file:
 
 ```
-+----------------------------------------------------------------------------+
-| Casbin Gateway                                                              |
-+----------------+-----------------------------------------------------------+
-| Management UI  | http://localhost:17000                                     |
-| Settings       | Settings page, seeded from conf/app.conf                   |
-| Web UI files   | web/build                                                  |
-| Reverse proxy  | enabled                                                    |
-| Gateway HTTP   | :8080                                                      |
-| Gateway HTTPS  | :8443                                                      |
-| Database       | sqlite, file "./data/casbin-gateway.db" (connected)        |
-| Sign-in        | built-in user table, Casdoor is not configured             |
-| App dir        | ./data/apps                                                |
-+----------------+-----------------------------------------------------------+
++---------------------------------------------------------------------+
+| Casbin Gateway                                                      |
++---------------------------------------------------------------------+
+| Management UI | http://localhost:17000 (this machine only)          |
+| Settings      | Settings page, seeded from conf/app.conf            |
+| Web UI files  | web/build                                           |
+| Reverse proxy | enabled                                             |
+| Gateway HTTP  | :8080                                               |
+| Gateway HTTPS | :8443                                               |
+| Database      | sqlite, file "./data/casbin-gateway.db" (connected) |
+| Sign-in       | built-in user table, Casdoor is not configured      |
+| Relay auth    | this machine only, no token needed                  |
+| App dir       | ./data/apps                                         |
++---------------------------------------------------------------------+
 ```
 
 If a port is taken, Gateway says which process holds it and stops, rather than 
starting half-configured.
@@ -221,7 +235,7 @@ cd web && yarn install && yarn build
 go run main.go
 ```
 
-Then open http://localhost:17000 and sign in as `admin` with the password 
`123`, same as an installed Gateway. The SQLite database is created on first 
start; there is no database server to install.
+Then open http://localhost:17000, where you are signed in as the local admin, 
same as an installed Gateway. The SQLite database is created on first start; 
there is no database server to install.
 
 ### Frontend development
 
diff --git a/README_zh.md b/README_zh.md
index bd6d839..39aafdd 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -53,9 +53,9 @@ Windows,在 PowerShell 中:
 irm 
https://raw.githubusercontent.com/apache/casbin-gateway/master/scripts/install.ps1
 | iex
 ```
 
-两者都会下载适配本机的构建产物,解压到 `~/.local/share/casbin-gateway`(Windows 上是 
`%LOCALAPPDATA%\casbin-gateway`),把 `casbin-gateway` 命令加入 PATH,然后启动它。接着打开:
+两者都会下载适配本机的构建产物,解压到 `~/.local/share/casbin-gateway`(Windows 上是 
`%LOCALAPPDATA%\casbin-gateway`),把 `casbin-gateway` 命令加入 
PATH,在后台启动它,并设置成开机(登录)自启。安装用的那个终端窗口立刻就还给你。接着打开:
 
-**http://localhost:17000** —— 用 `admin` 和密码 `123` 登录,然后在 **My Account** 里改掉它。
+**http://localhost:17000** —— 不用登录:Gateway 只服务本机,本机访问会直接以管理员身份进入。
 
 安装到此为止。Gateway 把数据存在自己目录下的一个 SQLite 文件里,登录也走它自己的用户表。
 
@@ -76,15 +76,15 @@ Agent 是通过读取 **Gateway 所在机器**的用户账户、home 目录和
 这是 **LLM Records** 的数据来源,也是让 Gateway 而不是 Agent 持有厂商 Key 的方式。
 
 1. **Providers** → **Add**:选类型(OpenAI 兼容或 Anthropic 兼容),填厂商的 base URL 和 API 
Key,并列出它提供的模型。
-2. **Agents** → 打开一个 Agent → 选中该 Provider。
-3. 复制页面显示的环境变量片段,在设置了这些变量的 shell 里启动 Agent:
+2. **Agents** → 打开一个 Agent → 选中该 Provider。如果这个 Agent 的配置格式 Gateway 认识,点 
**Write configuration** 就会写进它自己的配置文件 —— **Preview** 会先原样列出将要写入的内容,**Restore** 
可以还原。写过一次之后再换 Provider,文件会立即被改写,所以之后切换只需要点一下,两个页面上都可以。
+3. 其他 Agent 则复制页面显示的环境变量片段,在设置了这些变量的 shell 里启动:
 
 ```bash
 export ANTHROPIC_BASE_URL="http://localhost:17000/v1/agents/claude-code";
-export ANTHROPIC_AUTH_TOKEN="casbin-gateway"
+export ANTHROPIC_AUTH_TOKEN="cg-..."
 ```
 
-这个 token 只是占位符 —— Agent 没有它就拒绝启动,而 Gateway 会用 Provider 自己的 Key 去认证上游。
+这个 token 是 Gateway 自己的中继令牌,不是厂商的 Key:Agent 没有它就拒绝启动,而 Gateway 会用 Provider 自己的 
Key 去认证上游。页面上的片段里已经填好了真实的值。
 
 ### 没有 API Key:沿用 Agent 已有的登录
 
@@ -96,11 +96,21 @@ Codex 是例外:它的 ChatGPT 登录走的是另一套 API,不是 Gateway 
 
 ### 停止、升级、卸载
 
-- **停止**:`Ctrl-C`。**再次启动**:在任意目录执行 `casbin-gateway` —— 
这个命令是一个包装脚本,总是在安装目录(数据所在的地方)里启动 Gateway。
+- **停止**:`casbin-gateway stop`。**再次启动**:`casbin-gateway 
start`。**查看状态**:`casbin-gateway status`。三条命令在任意目录都能用 —— 
这个命令是一个包装脚本,总是在安装目录(数据所在的地方)里启动 Gateway。
+- **前台运行**(想盯着它跑的时候):`casbin-gateway`,用 `Ctrl-C` 停止。后台运行时控制台输出写在 
`logs/casbin-gateway.out`。
 - **升级**:再跑一遍安装命令。数据库和设置不受影响。
-- **卸载**:删除 `~/.local/share/casbin-gateway` 和 
`~/.local/bin/casbin-gateway`(Windows 上是 `%LOCALAPPDATA%\casbin-gateway` 及其 
PATH 条目)。
+- **卸载**:删除 `~/.local/share/casbin-gateway` 和 
`~/.local/bin/casbin-gateway`(Windows 上是 `%LOCALAPPDATA%\casbin-gateway` 及其 
PATH 条目),以及安装脚本结束时告诉你的那个自启动条目。
 
-设置 `INSTALL_DIR` 可以装到别的位置,`NO_START=1` 则只安装不启动。
+设置 `INSTALL_DIR` 可以装到别的位置,`NO_START=1` 只安装不启动,`NO_AUTOSTART=1` 则不设置登录自启。
+
+### 让其他机器也能访问
+
+Gateway 默认只监听 `127.0.0.1`,因为有两样东西对能连上这个端口的人是完全敞开的:Web UI 会直接把本机管理员登进去,`/v1` 
会用这里存的 API Key 去转发。这两点正是本地工具该有的样子,但都不该暴露给网络。
+
+如果确实要让其他机器访问,在 `conf/app.conf` 里把 `httpaddr` 设成 `0.0.0.0`,然后:
+
+1. **改掉管理员密码**(**My Account** 里)。自动登录在遇到第一个非本机请求时就停止,从那以后密码是唯一的门槛。
+2. **每个发往 `/v1` 的请求都要带上中继令牌**。Gateway 在首次启动时生成它,显示在 **Settings → Security** 
里;Providers 和 Agents 页面上的环境变量片段已经填好了它,Gateway 为 Agent 写配置时写进去的也是它。来自本机的请求永远不需要它。
 
 **这些是 nightly 构建**,每次推送都从 `master` 重新构建,并作为 
[`nightly`](https://github.com/apache/casbin-gateway/releases/tag/nightly) 
预发布版本发布。它们的用途是让人不装 Go 和 Node 工具链就能试用 Gateway;其他场景都应该从源码发布版构建。
 
@@ -140,6 +150,7 @@ podman compose up -d
 | 配置项 | 默认值 | 作用 |
 | --- | --- | --- |
 | `httpport` | `17000` | Web UI 和 REST API 的端口 |
+| `httpaddr` | `127.0.0.1` | Web UI 监听的网卡 —— 见[让其他机器也能访问](#让其他机器也能访问) |
 | `driverName` / `dataSourceName` | `sqlite` / `./data/casbin-gateway.db` | 
数据存放位置 |
 | `gatewayEnabled` | `false` | 打开反向代理 WAF |
 | `gatewayHttpPort` / `gatewayHttpsPort` | `80` / `443` | 代理监听的端口 |
@@ -150,19 +161,20 @@ podman compose up -d
 Gateway 启动时会打印它实际在做什么,所以可以直接看结果而不用去查配置文件:
 
 ```
-+----------------------------------------------------------------------------+
-| Casbin Gateway                                                              |
-+----------------+-----------------------------------------------------------+
-| Management UI  | http://localhost:17000                                     |
-| Settings       | Settings page, seeded from conf/app.conf                   |
-| Web UI files   | web/build                                                  |
-| Reverse proxy  | enabled                                                    |
-| Gateway HTTP   | :8080                                                      |
-| Gateway HTTPS  | :8443                                                      |
-| Database       | sqlite, file "./data/casbin-gateway.db" (connected)        |
-| Sign-in        | built-in user table, Casdoor is not configured             |
-| App dir        | ./data/apps                                                |
-+----------------+-----------------------------------------------------------+
++---------------------------------------------------------------------+
+| Casbin Gateway                                                      |
++---------------------------------------------------------------------+
+| Management UI | http://localhost:17000 (this machine only)          |
+| Settings      | Settings page, seeded from conf/app.conf            |
+| Web UI files  | web/build                                           |
+| Reverse proxy | enabled                                             |
+| Gateway HTTP  | :8080                                               |
+| Gateway HTTPS | :8443                                               |
+| Database      | sqlite, file "./data/casbin-gateway.db" (connected) |
+| Sign-in       | built-in user table, Casdoor is not configured      |
+| Relay auth    | this machine only, no token needed                  |
+| App dir       | ./data/apps                                         |
++---------------------------------------------------------------------+
 ```
 
 如果端口被占用,Gateway 会指出是哪个进程占着它并停止启动,而不是半配置地跑起来。
@@ -221,7 +233,7 @@ cd web && yarn install && yarn build
 go run main.go
 ```
 
-然后打开 http://localhost:17000,用 `admin` 和密码 `123` 登录,和安装版一样。SQLite 
数据库在首次启动时创建,不需要安装数据库服务。
+然后打开 http://localhost:17000,本机访问会直接以管理员身份进入,和安装版一样。SQLite 
数据库在首次启动时创建,不需要安装数据库服务。
 
 ### 前端开发
 
diff --git a/conf/app.conf b/conf/app.conf
index a021897..2bea1d8 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -9,6 +9,11 @@ appname = casbin-gateway
 ; Port of the management UI and the REST API. Whatever is already listening on
 ; it is killed on startup, so a previous run does not have to be stopped by 
hand.
 httpport = 17000
+; Interface the management UI binds to. It is loopback by default: the local
+; admin is signed in without a password and the relay hands out the provider
+; keys, so exposing both to the network is opt-in. Set it to 0.0.0.0 to serve
+; other machines, and change the admin password before you do.
+httpaddr = 127.0.0.1
 runmode = dev
 SessionOn = true
 copyrequestbody = true
diff --git a/conf/conf.go b/conf/conf.go
index 7974809..d68639a 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -17,6 +17,7 @@ package conf
 import (
        _ "embed"
        "fmt"
+       "net"
        "os"
        "runtime"
        "strconv"
@@ -38,7 +39,7 @@ func init() {
 // is reloaded afterwards — beego.LoadAppConfig() builds a new config from the
 // file alone, so anything set here would otherwise be lost.
 func ApplyEnvOverrides() {
-       presetConfigItems := []string{"httpport", "appname"}
+       presetConfigItems := []string{"httpport", "httpaddr", "appname"}
        for _, key := range presetConfigItems {
                if value, ok := os.LookupEnv(key); ok {
                        err := beego.AppConfig.Set(key, value)
@@ -136,6 +137,36 @@ func GetHttpPort() int {
        return GetConfigIntDefault("httpport", 17000)
 }
 
+// GetHttpAddr is the interface the management UI and the REST API bind to. It
+// defaults to loopback: the UI signs the local admin in without a password and
+// the relay hands out the provider keys, so reaching those from the network is
+// opt-in rather than the default.
+func GetHttpAddr() string {
+       addr := strings.Trim(GetConfigString("httpaddr"), `"' `)
+       if addr == "" {
+               return "127.0.0.1"
+       }
+       return addr
+}
+
+// IsHttpAddrLoopback reports whether the management port is reachable only 
from
+// this machine, which is what lets the local admin skip the sign-in form.
+func IsHttpAddrLoopback() bool {
+       addr := GetHttpAddr()
+       if addr == "localhost" {
+               return true
+       }
+       ip := net.ParseIP(addr)
+       return ip != nil && ip.IsLoopback()
+}
+
+// GetRelayToken is the token an agent sends to the local relay. It is 
generated
+// on first start and stored with the settings, so it survives restarts and can
+// be rotated from the Settings page.
+func GetRelayToken() string {
+       return strings.Trim(GetConfigString("relayToken"), `"' `)
+}
+
 // IsGatewayEnabled reports whether the reverse-proxy gateway should bind the
 // gateway ports. It defaults to false: taking over ports 80 and 443 is opt-in,
 // because those usually belong to something else on the host.
diff --git a/controllers/account.go b/controllers/account.go
index 180baed..26dfc1e 100644
--- a/controllers/account.go
+++ b/controllers/account.go
@@ -46,7 +46,7 @@ func (c *ApiController) GetSigninOptions() {
        c.ResponseOk(map[string]interface{}{
                "casdoorAvailable": casdoorAvailable,
                "signinAvailable":  signinEnabled,
-               "autoSignin":       signinEnabled && 
object.IsAdminUsingDefaultPassword(),
+               "autoSignin":       signinEnabled && 
object.IsAdminUsingDefaultPassword() && util.IsLoopbackRequest(c.Ctx.Request),
                "authConfig": map[string]string{
                        "serverUrl":        
getUnquotedConfig("casdoorEndpoint"),
                        "clientId":         getUnquotedConfig("clientId"),
@@ -138,7 +138,9 @@ func (c *ApiController) Signout() {
 }
 
 // autoLoginAdmin signs the built-in admin in while the seeded password is 
still
-// in use, so that a fresh deployment is usable without any configuration.
+// in use, so that a fresh install is usable without any configuration. Only a
+// request from this machine is let through: the convenience is for the person
+// sitting at the keyboard, and the seeded password is public.
 // It returns false when the response has already been written.
 func (c *ApiController) autoLoginAdmin() bool {
        user, ok, err := object.VerifyUser("admin", "123")
@@ -158,7 +160,7 @@ func (c *ApiController) autoLoginAdmin() bool {
 func (c *ApiController) GetAccount() {
        if c.GetSessionUser() == nil {
                fromPath := c.Input().Get("fromPath")
-               if object.IsSigninEnabled() && fromPath != "/signin" && 
object.IsAdminUsingDefaultPassword() {
+               if object.IsSigninEnabled() && fromPath != "/signin" && 
object.IsAdminUsingDefaultPassword() && util.IsLoopbackRequest(c.Ctx.Request) {
                        if !c.autoLoginAdmin() {
                                return
                        }
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index d6175e7..09155af 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -25,10 +25,6 @@ import (
        "github.com/apache/casbin-gateway/object"
 )
 
-// gatewayToken is what an agent sends to the local proxy. The proxy 
authenticates
-// upstream with the provider's own key, so this value is never used for 
anything.
-const gatewayToken = "casbin-gateway"
-
 // PlanAgentProvider renders what a switch would write, without touching a 
file.
 func (c *ApiController) PlanAgentProvider() {
        if c.RequireAdmin() {
@@ -140,7 +136,7 @@ func agentEndpoint(agentId string) (agentprovider.Endpoint, 
error) {
        // sending its own credentials: a placeholder token written into the 
agent's
        // configuration would replace the sign-in it already has.
        if !object.UsesClientAuth(provider) {
-               endpoint.ApiKey = gatewayToken
+               endpoint.ApiKey = conf.GetRelayToken()
        }
        return endpoint, nil
 }
diff --git a/controllers/proxy.go b/controllers/proxy.go
index 663093c..157612c 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -26,6 +26,7 @@ import (
        "strings"
        "time"
 
+       "github.com/apache/casbin-gateway/conf"
        "github.com/apache/casbin-gateway/object"
        "github.com/apache/casbin-gateway/proxy"
        "github.com/apache/casbin-gateway/util"
@@ -231,6 +232,12 @@ func (c *ApiController) proxyByAgent(target proxyTarget) {
 }
 
 func (c *ApiController) readProxyRoute(target proxyTarget) (*proxyRoute, bool) 
{
+       if !c.allowRelay() {
+               c.writeProxyError(target.protocol, http.StatusUnauthorized, 
"authentication_error",
+                       "this relay is reachable from the network, so it needs 
the token shown next to the provider in Casbin Gateway")
+               return nil, false
+       }
+
        rawBody := c.Ctx.Input.RequestBody
 
        var fields routingFields
@@ -400,6 +407,32 @@ func reportProviderStatus(provider *object.Provider, 
statusCode int) {
        }
 }
 
+// allowRelay decides whether a request may use the providers stored here. A
+// request from this machine always may — that is the whole point of a local
+// gateway, and a client-auth provider carries the caller's own vendor
+// credential in the same header a token would use. Anything off-box has to
+// present the relay token instead.
+func (c *ApiController) allowRelay() bool {
+       if util.IsLoopbackRequest(c.Ctx.Request) {
+               return true
+       }
+
+       token := conf.GetRelayToken()
+       return token != "" && c.relayCredential() == token
+}
+
+// relayCredential is the token the client sent, in either of the two headers
+// the OpenAI and Anthropic clients use.
+func (c *ApiController) relayCredential() string {
+       header := c.Ctx.Request.Header
+       if key := strings.TrimSpace(header.Get("X-Api-Key")); key != "" {
+               return key
+       }
+
+       authorization := strings.TrimSpace(header.Get("Authorization"))
+       return strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
+}
+
 // clientAuthHeaders are forwarded verbatim by a provider that authenticates 
with
 // the caller's own credentials: the credential itself, plus what the vendors
 // expect beside a token issued to a CLI rather than to an API account. It is 
an
diff --git a/controllers/system.go b/controllers/system.go
index 28896a8..e4621f4 100644
--- a/controllers/system.go
+++ b/controllers/system.go
@@ -35,3 +35,16 @@ func (c *ApiController) GetGatewayStatus() {
                "httpsPort":      conf.GetGatewayHttpsPort(),
        })
 }
+
+// GetRelayToken hands the web UI the token an agent has to send to the relay,
+// so the snippets it shows can be pasted as they are.
+func (c *ApiController) GetRelayToken() {
+       if c.RequireSignedIn() {
+               return
+       }
+
+       c.ResponseOk(map[string]interface{}{
+               "relayToken": conf.GetRelayToken(),
+               "localOnly":  conf.IsHttpAddrLoopback(),
+       })
+}
diff --git a/main.go b/main.go
index a67561f..24ad4e9 100644
--- a/main.go
+++ b/main.go
@@ -16,6 +16,7 @@ package main
 
 import (
        "fmt"
+       "os"
        "time"
 
        "github.com/apache/casbin-gateway/agenthook"
@@ -35,12 +36,22 @@ import (
        _ "github.com/beego/beego/session/redis"
 )
 
+// daemonLogPath is where a background Gateway sends the console output nobody
+// is watching.
+const daemonLogPath = "./logs/casbin-gateway.out"
+
 func main() {
        // Hooks and MCP servers are launched by an agent as a short-lived child
        // process. They must exit before Gateway initializes its own services.
        agenthook.ServeIfInvoked()
        mcpserver.ServeIfInvoked()
 
+       // "start", "stop" and "status" manage a Gateway running in the 
background,
+       // so that using it does not mean keeping a terminal open.
+       if util.RunCommand(os.Args, conf.GetHttpPort(), daemonLogPath) {
+               return
+       }
+
        util.InitSelfGuard()
        object.InitFlag()
        object.InitAdapter()
@@ -93,6 +104,7 @@ func main() {
        beego.BConfig.WebConfig.Session.SessionGCMaxLifetime = 3600 * 24 * 365
 
        port := conf.GetHttpPort()
+       addr := conf.GetHttpAddr()
 
        // A previous run still holding one of these ports would keep this one 
from
        // starting, so it is stopped first. The gateway ports come before the
@@ -117,11 +129,11 @@ func main() {
        // and before the gateway below binds anything. The gap between probe 
and
        // bind is unavoidable but harmless: losing the race just puts us back 
to
        // beego's own error.
-       if err := util.CheckPortAvailable(port); err != nil {
+       if err := util.CheckPortAvailableOn(addr, port); err != nil {
                util.FatalListenError(port, `change "httpport" in 
conf/app.conf`, err)
        }
 
        service.Start()
 
-       beego.Run(fmt.Sprintf(":%v", port))
+       beego.Run(fmt.Sprintf("%s:%v", addr, port))
 }
diff --git a/object/provider.go b/object/provider.go
index b9e1fdf..40542b3 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -497,6 +497,17 @@ func AddProvider(provider *Provider) (bool, error) {
                return false, err
        }
 
+       name, suffix, err := freeProviderName(provider.Owner, provider.Name)
+       if err != nil {
+               return false, err
+       }
+       provider.Name = name
+       // The name the user sees follows the one that was free, so two 
accounts with
+       // the same vendor are told apart in the list rather than only in the 
URL.
+       if suffix > 1 && provider.DisplayName != "" {
+               provider.DisplayName = fmt.Sprintf("%s %d", 
provider.DisplayName, suffix)
+       }
+
        now := util.GetCurrentTime()
        if provider.CreatedTime == "" {
                provider.CreatedTime = now
@@ -514,6 +525,28 @@ func AddProvider(provider *Provider) (bool, error) {
        return affected != 0, err
 }
 
+// freeProviderName keeps the readable name the user asked for and appends a
+// number only when it is taken, so a second DeepSeek account becomes 
"deepseek-2"
+// rather than something nobody can read.
+func freeProviderName(owner string, name string) (string, int, error) {
+       for suffix := 1; suffix < 1000; suffix++ {
+               candidate := name
+               if suffix > 1 {
+                       candidate = fmt.Sprintf("%s-%d", name, suffix)
+               }
+
+               existing, err := getProvider(owner, candidate)
+               if err != nil {
+                       return "", 0, err
+               }
+               if existing == nil {
+                       return candidate, suffix, nil
+               }
+       }
+
+       return "", 0, fmt.Errorf("too many providers named %s", name)
+}
+
 func UpdateProvider(id string, provider *Provider) (bool, error) {
        owner, name := util.GetOwnerAndNameFromId(id)
        stored, err := getProvider(owner, name)
diff --git a/object/setting.go b/object/setting.go
index e453b4c..0d79005 100644
--- a/object/setting.go
+++ b/object/setting.go
@@ -55,6 +55,9 @@ type Setting struct {
        CasdoorApplication  string `xorm:"varchar(100)" 
json:"casdoorApplication"`
 
        ApiKeyEncryptionKey string `xorm:"varchar(200)" 
json:"apiKeyEncryptionKey"`
+       // RelayToken is what an agent sends to the local relay. It is 
generated on
+       // first start; clearing it on the Settings page issues a new one.
+       RelayToken string `xorm:"varchar(200)" json:"relayToken"`
 
        HttpProxy      string `xorm:"varchar(200)" json:"httpProxy"`
        AcmeEmail      string `xorm:"varchar(200)" json:"acmeEmail"`
@@ -104,6 +107,7 @@ func SyncSettingToConf(setting *Setting) {
                "casdoorApplication":  setting.CasdoorApplication,
 
                "apiKeyEncryptionKey": setting.ApiKeyEncryptionKey,
+               "relayToken":          setting.RelayToken,
 
                "httpProxy":      setting.HttpProxy,
                "acmeEmail":      setting.AcmeEmail,
@@ -124,6 +128,14 @@ func SyncSettingToConf(setting *Setting) {
        })
 }
 
+// relayTokenLength is long enough that the token cannot be guessed and short
+// enough to stay readable in a shell snippet.
+const relayTokenLength = 32
+
+func newRelayToken() string {
+       return "cg-" + util.GenerateToken(relayTokenLength)
+}
+
 func getSetting(owner string, name string) (*Setting, error) {
        setting := Setting{Owner: owner, Name: name}
        existed, err := ormer.Engine.Get(&setting)
@@ -155,6 +167,11 @@ func UpdateSetting(id string, setting *Setting) (bool, 
error) {
        }
 
        setting.Owner, setting.Name = owner, name
+       // An empty token would leave the relay open, so clearing the field is 
taken
+       // as "issue a new one" rather than "turn the check off".
+       if setting.RelayToken == "" {
+               setting.RelayToken = newRelayToken()
+       }
        _, err := ormer.Engine.ID(core.PK{owner, 
name}).AllCols().Update(setting)
        if err != nil {
                return false, err
@@ -181,6 +198,15 @@ func InitBuiltInSetting() {
                }
        }
 
+       // An installation that predates the relay token has an empty column, 
so the
+       // token is issued here rather than only when the row is created.
+       if setting.RelayToken == "" {
+               setting.RelayToken = newRelayToken()
+               if _, err = ormer.Engine.ID(core.PK{setting.Owner, 
setting.Name}).Cols("relay_token").Update(setting); err != nil {
+                       panic(err)
+               }
+       }
+
        SyncSettingToConf(setting)
 }
 
@@ -216,6 +242,7 @@ func newSettingFromConf() *Setting {
                CasdoorApplication:  
conf.GetConfigStringUnquoted("casdoorApplication"),
 
                ApiKeyEncryptionKey: 
conf.GetConfigStringUnquoted("apiKeyEncryptionKey"),
+               RelayToken:          newRelayToken(),
 
                HttpProxy:      conf.GetConfigStringUnquoted("httpProxy"),
                AcmeEmail:      conf.GetConfigStringUnquoted("acmeEmail"),
diff --git a/routers/router.go b/routers/router.go
index 7fea20d..1a2ba3c 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -39,6 +39,7 @@ func initAPI() {
        beego.Router("/api/update-account", &controllers.ApiController{}, 
"POST:UpdateAccount")
        beego.Router("/api/get-casdoor-providers", 
&controllers.ApiController{}, "GET:GetCasdoorProviders")
        beego.Router("/api/get-gateway-status", &controllers.ApiController{}, 
"GET:GetGatewayStatus")
+       beego.Router("/api/get-relay-token", &controllers.ApiController{}, 
"GET:GetRelayToken")
        beego.Router("/api/get-setting", &controllers.ApiController{}, 
"GET:GetSetting")
        beego.Router("/api/update-setting", &controllers.ApiController{}, 
"POST:UpdateSetting")
 
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index 219c8af..9d251b4 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -100,17 +100,40 @@ if ($UserPath -notlike "*$BinDir*") {
     Write-Info "Added $BinDir to your PATH, which takes effect in your next 
terminal"
 }
 
+# -- start with Windows -------------------------------------------------------
+# A shortcut in the Startup folder rather than a service: it needs no 
elevation,
+# shows up in Task Manager's Startup tab, and is undone by deleting the file.
+$StartupDir = [System.Environment]::GetFolderPath('Startup')
+$StartupLink = Join-Path $StartupDir 'Casbin Gateway.lnk'
+if (-not $env:NO_AUTOSTART) {
+    try {
+        $shell = New-Object -ComObject WScript.Shell
+        $shortcut = $shell.CreateShortcut($StartupLink)
+        $shortcut.TargetPath = Join-Path $InstallDir 'casbin-gateway.exe'
+        $shortcut.Arguments = 'start'
+        $shortcut.WorkingDirectory = $InstallDir
+        $shortcut.Description = 'Casbin Gateway'
+        $shortcut.Save()
+        Write-Info 'Casbin Gateway will start with Windows.'
+    }
+    catch {
+        Write-Info "Could not add the startup shortcut: $_"
+    }
+}
+
 Write-Info ''
 Write-Info "Casbin Gateway is installed in $InstallDir"
 Write-Info 'Its database, logs and temporary files stay in that directory.'
-Write-Info 'Sign in as "admin" with the password "123", and change it right 
away.'
+Write-Info 'It serves this machine only, and signs you in there as admin 
without a password.'
+Write-Info 'Stop it with "casbin-gateway stop", check on it with 
"casbin-gateway status".'
+Write-Info "Remove the startup entry by deleting $StartupLink"
 Write-Info ''
 
 if ($env:NO_START) {
-    Write-Info 'Start it with: casbin-gateway'
+    Write-Info 'Start it with: casbin-gateway start'
     return
 }
 
-Write-Info 'Starting Casbin Gateway, press Ctrl-C to stop it...'
+# "start" detaches and returns, so installing does not occupy this terminal.
 Set-Location $InstallDir
-& (Join-Path $InstallDir 'casbin-gateway.exe')
+& (Join-Path $InstallDir 'casbin-gateway.exe') start
diff --git a/scripts/install.sh b/scripts/install.sh
index e4ff7ea..6205071 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -13,6 +13,7 @@
 #   BIN_DIR       where the "casbin-gateway" command is placed
 #                 (default: $HOME/.local/bin)
 #   NO_START      set to any value to install without starting Gateway
+#   NO_AUTOSTART  set to any value to skip the login-time startup entry
 
 set -euo pipefail
 
@@ -23,6 +24,7 @@ BASENAME="casbin-gateway-nightly"
 INSTALL_DIR="${INSTALL_DIR:-${HOME}/.local/share/casbin-gateway}"
 BIN_DIR="${BIN_DIR:-${HOME}/.local/bin}"
 NO_START="${NO_START:-}"
+NO_AUTOSTART="${NO_AUTOSTART:-}"
 
 info() { printf '%s\n' "$*"; }
 die() { printf 'casbin-gateway: %s\n' "$*" >&2; exit 1; }
@@ -122,17 +124,75 @@ EOF
        esac
 fi
 
+# ── start at login 
────────────────────────────────────────────────────────────
+# A user-level unit on Linux and a LaunchAgent on macOS: neither needs root, 
and
+# both are undone by deleting one file.
+autostartNote=""
+install_autostart() {
+       if [[ -n "${NO_AUTOSTART}" ]]; then
+               return
+       fi
+
+       if [[ "${osName}" == "darwin" ]]; then
+               
plist="${HOME}/Library/LaunchAgents/org.apache.casbin-gateway.plist"
+               mkdir -p "$(dirname "${plist}")" || return
+               cat > "${plist}" <<EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" 
"http://www.apple.com/DTDs/PropertyList-1.0.dtd";>
+<plist version="1.0">
+<dict>
+       <key>Label</key><string>org.apache.casbin-gateway</string>
+       <key>ProgramArguments</key>
+       <array><string>${INSTALL_DIR}/casbin-gateway</string></array>
+       <key>WorkingDirectory</key><string>${INSTALL_DIR}</string>
+       <key>RunAtLoad</key><true/>
+       <key>KeepAlive</key><true/>
+</dict>
+</plist>
+EOF
+               launchctl unload "${plist}" >/dev/null 2>&1 || true
+               autostartNote="${plist}"
+               return
+       fi
+
+       command -v systemctl >/dev/null 2>&1 || return
+       unit="${HOME}/.config/systemd/user/casbin-gateway.service"
+       mkdir -p "$(dirname "${unit}")" || return
+       cat > "${unit}" <<EOF
+[Unit]
+Description=Casbin Gateway
+
+[Service]
+Type=simple
+WorkingDirectory=${INSTALL_DIR}
+ExecStart=${INSTALL_DIR}/casbin-gateway
+Restart=on-failure
+
+[Install]
+WantedBy=default.target
+EOF
+       systemctl --user daemon-reload >/dev/null 2>&1 || true
+       systemctl --user enable casbin-gateway.service >/dev/null 2>&1 || true
+       autostartNote="${unit}"
+}
+
+install_autostart
+
 info ""
 info "Casbin Gateway is installed in ${INSTALL_DIR}"
 info "Its database, logs and temporary files stay in that directory."
-info "Sign in as \"admin\" with the password \"123\", and change it right 
away."
+info "It serves this machine only, and signs you in there as admin without a 
password."
+info "Stop it with \"casbin-gateway stop\", check on it with \"casbin-gateway 
status\"."
+if [[ -n "${autostartNote}" ]]; then
+       info "It will start when you log in; remove ${autostartNote} to undo 
that."
+fi
 info ""
 
 if [[ -n "${NO_START}" ]]; then
-       info "Start it with: casbin-gateway"
+       info "Start it with: casbin-gateway start"
        exit 0
 fi
 
-info "Starting Casbin Gateway, press Ctrl-C to stop it..."
+# "start" detaches and returns, so installing does not occupy this terminal.
 cd "${INSTALL_DIR}"
-exec "${INSTALL_DIR}/casbin-gateway"
+exec "${INSTALL_DIR}/casbin-gateway" start
diff --git a/service/startup.go b/service/startup.go
index 1c52770..ef3e1ca 100644
--- a/service/startup.go
+++ b/service/startup.go
@@ -35,7 +35,7 @@ type summaryRow struct {
 // been a silent failure for someone starting Gateway for the first time.
 func PrintStartupSummary() {
        rows := []summaryRow{
-               {"Management UI", fmt.Sprintf("http://localhost:%d";, 
conf.GetHttpPort())},
+               {"Management UI", describeManagementUrl()},
                {"Settings", describeConf()},
                {"Web UI files", describeWebBuild()},
                {"Reverse proxy", describeGateway()},
@@ -43,12 +43,35 @@ func PrintStartupSummary() {
                {"Gateway HTTPS", 
describeGatewayPort(conf.GetGatewayHttpsPort())},
                {"Database", describeDatabase()},
                {"Sign-in", describeSignin()},
+               {"Relay auth", describeRelayAuth()},
                {"App dir", describeAppDir()},
        }
 
        printSummaryTable("Casbin Gateway", rows)
 }
 
+// describeManagementUrl says both where the UI is and who can reach it, since
+// the bind address decides whether the local admin is signed in automatically.
+func describeManagementUrl() string {
+       url := fmt.Sprintf("http://localhost:%d";, conf.GetHttpPort())
+       if conf.IsHttpAddrLoopback() {
+               return url + " (this machine only)"
+       }
+
+       return fmt.Sprintf("%s, bound to %s", url, conf.GetHttpAddr())
+}
+
+// describeRelayAuth explains what an agent has to send to /v1. A request off
+// this machine needs the token, which is also what Gateway writes into the
+// configuration of an agent it switches.
+func describeRelayAuth() string {
+       if conf.IsHttpAddrLoopback() {
+               return "this machine only, no token needed"
+       }
+
+       return "token required off this machine, see Settings"
+}
+
 // describeConf names where the settings were seeded from. They live in the
 // database from the first start onwards, so this is only the origin of the
 // values the Settings page now owns.
diff --git a/util/daemon.go b/util/daemon.go
new file mode 100644
index 0000000..c5a3b75
--- /dev/null
+++ b/util/daemon.go
@@ -0,0 +1,156 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package util
+
+import (
+       "fmt"
+       "net"
+       "os"
+       "os/exec"
+       "path/filepath"
+       "time"
+)
+
+const (
+       // EnvDaemonChildKey marks the copy started by "start", so it serves 
instead
+       // of spawning another one.
+       EnvDaemonChildKey = "CASBIN_GATEWAY_DAEMON"
+       // daemonStartTimeout bounds the wait for the detached copy to answer.
+       daemonStartTimeout = 30 * time.Second
+       daemonPollInterval = 200 * time.Millisecond
+)
+
+// RunCommand handles the sub-commands that manage a background Gateway, so 
that
+// running it does not have to occupy a terminal. It reports whether it handled
+// os.Args, in which case the caller must not go on to serve.
+func RunCommand(args []string, port int, logPath string) bool {
+       if os.Getenv(EnvDaemonChildKey) == "1" {
+               return false
+       }
+
+       switch commandOf(args) {
+       case "start":
+               startDetached(port, logPath)
+       case "stop":
+               stopDaemon(port)
+       case "status":
+               printStatus(port)
+       default:
+               return false
+       }
+       return true
+}
+
+func commandOf(args []string) string {
+       for _, arg := range args[1:] {
+               switch arg {
+               case "start", "--daemon", "-d":
+                       return "start"
+               case "stop", "--stop":
+                       return "stop"
+               case "status", "--status":
+                       return "status"
+               }
+       }
+       return ""
+}
+
+// startDetached launches a copy of this executable with no terminal attached 
and
+// waits until it answers, so that a failed start is reported here rather than
+// discovered later in a log nobody opened.
+func startDetached(port int, logPath string) {
+       executable, err := os.Executable()
+       if err != nil {
+               fmt.Printf("Casbin Gateway: cannot find its own executable: 
%v\n", err)
+               os.Exit(1)
+       }
+
+       if isServing(port) {
+               fmt.Printf("Casbin Gateway is already running on 
http://localhost:%d\n";, port)
+               return
+       }
+
+       if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
+               fmt.Printf("Casbin Gateway: cannot create %s: %v\n", 
filepath.Dir(logPath), err)
+               os.Exit(1)
+       }
+
+       logFile, err := os.OpenFile(logPath, 
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+       if err != nil {
+               fmt.Printf("Casbin Gateway: cannot write %s: %v\n", logPath, 
err)
+               os.Exit(1)
+       }
+       defer logFile.Close()
+
+       cmd := exec.Command(executable)
+       cmd.Stdout, cmd.Stderr = logFile, logFile
+       cmd.Env = append(os.Environ(), EnvDaemonChildKey+"=1")
+       detach(cmd)
+
+       if err := cmd.Start(); err != nil {
+               fmt.Printf("Casbin Gateway: could not start in the background: 
%v\n", err)
+               os.Exit(1)
+       }
+
+       deadline := time.Now().Add(daemonStartTimeout)
+       for time.Now().Before(deadline) {
+               if isServing(port) {
+                       fmt.Printf("Casbin Gateway is running in the background 
on http://localhost:%d (pid %d)\n", port, cmd.Process.Pid)
+                       fmt.Printf("  Log: %s   Stop it with: casbin-gateway 
stop\n", logPath)
+                       return
+               }
+               time.Sleep(daemonPollInterval)
+       }
+
+       fmt.Printf("Casbin Gateway did not answer on port %d within %v. See 
%s\n", port, daemonStartTimeout, logPath)
+       os.Exit(1)
+}
+
+func stopDaemon(port int) {
+       if !isServing(port) {
+               fmt.Printf("Casbin Gateway is not running on port %d\n", port)
+               return
+       }
+
+       if err := StopOldInstance(port); err != nil {
+               fmt.Printf("Casbin Gateway: could not stop the process on port 
%d: %v\n", port, err)
+               os.Exit(1)
+       }
+       fmt.Println("Casbin Gateway stopped")
+}
+
+func printStatus(port int) {
+       if isServing(port) {
+               holder := DescribePortHolder(port)
+               if holder == "" {
+                       holder = "running"
+               }
+               fmt.Printf("Casbin Gateway is running on http://localhost:%d 
(%s)\n", port, holder)
+               return
+       }
+
+       fmt.Printf("Casbin Gateway is not running on port %d\n", port)
+}
+
+// isServing reports whether something already answers on the port, which is 
the
+// only signal available to a process that did not start it.
+func isServing(port int) bool {
+       conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 
time.Second)
+       if err != nil {
+               return false
+       }
+       conn.Close()
+       return true
+}
diff --git a/util/request.go b/util/daemon_unix.go
similarity index 55%
copy from util/request.go
copy to util/daemon_unix.go
index 63506c1..8f56931 100644
--- a/util/request.go
+++ b/util/daemon_unix.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The casbin Authors. All Rights Reserved.
+// Copyright 2026 The casbin Authors. All Rights Reserved.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,29 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+//go:build !windows
+
 package util
 
 import (
-       "net"
-       "net/http"
-       "strings"
+       "os/exec"
+       "syscall"
 )
 
-func GetClientIp(r *http.Request) string {
-       forwarded := r.Header.Get("X-Forwarded-For")
-       if forwarded != "" {
-               clientIP := strings.Split(forwarded, ",")[0]
-               return strings.TrimSpace(clientIP)
-       }
-
-       realIP := r.Header.Get("X-Real-IP")
-       if realIP != "" {
-               return realIP
-       }
-
-       ip, _, err := net.SplitHostPort(r.RemoteAddr)
-       if err != nil {
-               return r.RemoteAddr
-       }
-       return ip
+// detach puts the child in its own session, so closing the terminal that
+// started it does not take it down.
+func detach(cmd *exec.Cmd) {
+       cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
 }
diff --git a/util/request.go b/util/daemon_windows.go
similarity index 56%
copy from util/request.go
copy to util/daemon_windows.go
index 63506c1..94d9010 100644
--- a/util/request.go
+++ b/util/daemon_windows.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The casbin Authors. All Rights Reserved.
+// Copyright 2026 The casbin Authors. All Rights Reserved.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -15,26 +15,20 @@
 package util
 
 import (
-       "net"
-       "net/http"
-       "strings"
+       "os/exec"
+       "syscall"
 )
 
-func GetClientIp(r *http.Request) string {
-       forwarded := r.Header.Get("X-Forwarded-For")
-       if forwarded != "" {
-               clientIP := strings.Split(forwarded, ",")[0]
-               return strings.TrimSpace(clientIP)
-       }
-
-       realIP := r.Header.Get("X-Real-IP")
-       if realIP != "" {
-               return realIP
-       }
+// detachedProcess and createNewProcessGroup keep the child alive after this
+// console closes, and without a console window of its own.
+const (
+       detachedProcess       = 0x00000008
+       createNewProcessGroup = 0x00000200
+)
 
-       ip, _, err := net.SplitHostPort(r.RemoteAddr)
-       if err != nil {
-               return r.RemoteAddr
+func detach(cmd *exec.Cmd) {
+       cmd.SysProcAttr = &syscall.SysProcAttr{
+               HideWindow:    true,
+               CreationFlags: detachedProcess | createNewProcessGroup,
        }
-       return ip
 }
diff --git a/util/port.go b/util/port.go
index c7ff651..133556e 100644
--- a/util/port.go
+++ b/util/port.go
@@ -34,14 +34,25 @@ const portLookupTimeout = 2 * time.Second
 // keep the listener and serve on it, so a port that was reported free cannot 
be
 // taken by someone else in between the check and the bind.
 func ListenTcp(port int) (net.Listener, error) {
-       return net.Listen("tcp", fmt.Sprintf(":%d", port))
+       return ListenTcpAddr("", port)
+}
+
+// ListenTcpAddr binds one interface rather than all of them. An empty addr
+// means every interface, which is what the reverse proxy wants.
+func ListenTcpAddr(addr string, port int) (net.Listener, error) {
+       return net.Listen("tcp", fmt.Sprintf("%s:%d", addr, port))
 }
 
 // CheckPortAvailable binds the port and immediately releases it. Use it only
 // where the port has to be handed to a server that insists on binding itself,
 // such as beego.Run().
 func CheckPortAvailable(port int) error {
-       listener, err := ListenTcp(port)
+       return CheckPortAvailableOn("", port)
+}
+
+// CheckPortAvailableOn is CheckPortAvailable for one interface.
+func CheckPortAvailableOn(addr string, port int) error {
+       listener, err := ListenTcpAddr(addr, port)
        if err != nil {
                return err
        }
diff --git a/util/request.go b/util/request.go
index 63506c1..682d9f6 100644
--- a/util/request.go
+++ b/util/request.go
@@ -38,3 +38,16 @@ func GetClientIp(r *http.Request) string {
        }
        return ip
 }
+
+// IsLoopbackRequest reports whether the request came from this machine, judged
+// by the socket alone. X-Forwarded-For and X-Real-IP are deliberately ignored:
+// they are set by the client and so cannot be trusted to grant anything.
+func IsLoopbackRequest(r *http.Request) bool {
+       host, _, err := net.SplitHostPort(r.RemoteAddr)
+       if err != nil {
+               host = r.RemoteAddr
+       }
+
+       ip := net.ParseIP(strings.Trim(host, "[]"))
+       return ip != nil && ip.IsLoopback()
+}
diff --git a/util/string.go b/util/string.go
index 1a59488..b6f78fb 100644
--- a/util/string.go
+++ b/util/string.go
@@ -142,6 +142,16 @@ func generateRandomString(length int) (string, error) {
        return string(b), nil
 }
 
+// GenerateToken returns a random opaque token, used where a value has to be
+// unguessable but is never a password anyone types.
+func GenerateToken(length int) string {
+       token, err := generateRandomString(length)
+       if err != nil {
+               panic(err)
+       }
+       return token
+}
+
 func GenerateTwoUniqueRandomStrings() (string, string, error) {
        len1 := 16 + int(big.NewInt(17).Int64())
        len2 := 16 + int(big.NewInt(17).Int64())
diff --git a/web/src/App.tsx b/web/src/App.tsx
index ac21834..f99cb32 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -18,6 +18,7 @@ import {useTranslation} from "react-i18next";
 import i18next from "i18next";
 
 import * as AccountBackend from "@/backend/AccountBackend";
+import * as MiscBackend from "@/backend/MiscBackend";
 import * as Conf from "@/Conf";
 import * as Setting from "@/Setting";
 import {findGroupOf, selectedKeyOf} from "@/nav";
@@ -65,6 +66,9 @@ export default function App() {
   useTranslation();
   // undefined while the account request is in flight, null when signed out.
   const [account, setAccount] = React.useState<Account | null | 
undefined>(undefined);
+  // The reverse-proxy pages are hidden until the proxy is on, so this decides
+  // whether the sidebar shows them at all.
+  const [gatewayEnabled, setGatewayEnabled] = React.useState(false);
   const [themeAlgorithm, setThemeAlgorithm] = 
React.useState<ThemeAlgorithm>(() => {
     const stored = Setting.readThemeAlgorithm();
     // Applied before the first paint so a dark-mode reload never flashes the
@@ -168,6 +172,15 @@ export default function App() {
       .catch(() => getAccount());
   }, [getAccount]);
 
+  React.useEffect(() => {
+    if (!account) {
+      return;
+    }
+    MiscBackend.getGatewayStatus()
+      .then(res => setGatewayEnabled(res.status === "ok" && 
(res.data?.gatewayEnabled ?? false)))
+      .catch(() => setGatewayEnabled(false));
+  }, [account]);
+
   const signout = () => {
     AccountBackend.signout().then(res => {
       if (res.status === "ok") {
@@ -220,6 +233,7 @@ export default function App() {
           openKeys={openKeys}
           onOpenKeysChange={setOpenKeys}
           isAdmin={Setting.isAdminUser(account)}
+          gatewayEnabled={gatewayEnabled}
         />
 
         <div
diff --git a/web/src/backend/MiscBackend.ts b/web/src/backend/MiscBackend.ts
index b5fd7bd..fe1d0cb 100644
--- a/web/src/backend/MiscBackend.ts
+++ b/web/src/backend/MiscBackend.ts
@@ -27,6 +27,11 @@ export function getGatewayStatus() {
   return request<GatewayStatus>("/api/get-gateway-status");
 }
 
+/** The token an agent has to send to the relay, and whether it is needed 
here. */
+export function getRelayToken() {
+  return request<{relayToken: string; localOnly: 
boolean}>("/api/get-relay-token");
+}
+
 export function getMetric(type: string, rangeType: string, count: number, 
top?: number) {
   return request<MetricPoint[]>(
     `/api/get-metrics${query({type: type, rangeType: rangeType, count: count, 
top: top})}`,
diff --git a/web/src/components/EnvSnippet.tsx 
b/web/src/components/EnvSnippet.tsx
index c215b96..000b656 100644
--- a/web/src/components/EnvSnippet.tsx
+++ b/web/src/components/EnvSnippet.tsx
@@ -22,6 +22,7 @@ import {Button} from "@/components/ui/button";
 import {SimpleTooltip} from "@/components/ui/tooltip";
 import {cn} from "@/lib/utils";
 import {envSnippet, shells, type Shell} from "@/lib/providers";
+import {useRelayToken} from "@/lib/relayToken";
 
 /**
  * The environment variables that point a client at one Gateway endpoint, ready
@@ -39,7 +40,8 @@ export function EnvSnippet({
   includeToken?: boolean;
 }) {
   const [shell, setShell] = React.useState<Shell>(defaultShell);
-  const snippet = envSnippet(protocol, baseUrl, shell, includeToken);
+  const token = useRelayToken();
+  const snippet = envSnippet(protocol, baseUrl, shell, includeToken, token);
 
   return (
     <div className="space-y-2">
diff --git a/web/src/components/ProviderCard.tsx 
b/web/src/components/ProviderCard.tsx
index 07c89ad..1967de7 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -311,6 +311,13 @@ export function ProviderCard({
                   </code>
                 ))}
 
+                {/* Once the file belongs to Gateway, picking another provider
+                    above rewrites it on the spot, so the button stops being 
the
+                    thing to click and only stays for a manual rewrite. */}
+                {providerConfig.applied ? (
+                  <p className="text-sm 
text-muted-foreground">{i18next.t("agent:Switch takes effect at once")}</p>
+                ) : null}
+
                 <div className="flex flex-wrap gap-2">
                   <ConfirmDialog
                     title={i18next.t("agent:Write the configuration of 
{agent}?").replace("{agent}", agent.name)}
@@ -318,7 +325,7 @@ export function ProviderCard({
                     confirmText={i18next.t("agent:Write configuration")}
                     onConfirm={() => onWrite(false)}
                   >
-                    <Button disabled={busy}>
+                    <Button variant={providerConfig.applied ? "outline" : 
"default"} disabled={busy}>
                       {providerConfig.applied
                         ? i18next.t("agent:Rewrite configuration")
                         : i18next.t("agent:Write configuration")}
diff --git a/web/src/components/ProviderGridCard.tsx 
b/web/src/components/ProviderGridCard.tsx
new file mode 100644
index 0000000..ca44055
--- /dev/null
+++ b/web/src/components/ProviderGridCard.tsx
@@ -0,0 +1,211 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {CircleX, Pencil, Trash2} from "lucide-react";
+import i18next from "i18next";
+
+import {ProviderIcon} from "@/components/ProviderIcon";
+import {QuotaBadge} from "@/components/ProviderQuota";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent} from "@/components/ui/card";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuLabel,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {SimpleTooltip} from "@/components/ui/tooltip";
+import {providerProtocol, usesClientAuth} from "@/lib/providers";
+import {cn} from "@/lib/utils";
+import type {Agent, Provider, ProviderHealth, ProviderQuota} from "@/types";
+
+export function providerIdOf(provider: Provider) {
+  return `${provider.owner}/${provider.name}`;
+}
+
+/** How many model badges fit before the rest become a count. */
+const visibleModels = 3;
+
+/** The one mark that says whether the proxy is happy with this provider. */
+function HealthDot({health}: {health: ProviderHealth | undefined}) {
+  if (!health) {
+    return (
+      <SimpleTooltip title={i18next.t("provider:Not used yet")}>
+        <span className="bg-muted-foreground/40 size-2 shrink-0 rounded-full" 
/>
+      </SimpleTooltip>
+    );
+  }
+
+  const healthy = health.healthy && health.consecutive === 0;
+  return (
+    <SimpleTooltip
+      title={
+        health.healthy
+          ? `${health.successes} / ${health.successes + health.failures}`
+          : `${health.lastError} · ${i18next.t("provider:Retried at")} 
${health.retryTime}`
+      }
+    >
+      <span
+        className={cn(
+          "size-2 shrink-0 rounded-full",
+          healthy ? "bg-success" : health.healthy ? "bg-muted-foreground" : 
"bg-warning",
+        )}
+      />
+    </SimpleTooltip>
+  );
+}
+
+/**
+ * One provider, as a card rather than a row: the name, where it points, what 
it
+ * serves, and the agents it answers for. Switching an agent over is what 
people
+ * come to this page to do, so it is one click from here.
+ */
+export function ProviderGridCard({
+  provider,
+  agents,
+  health,
+  quota,
+  busy,
+  onEdit,
+  onDelete,
+  onBind,
+}: {
+  provider: Provider;
+  agents: Agent[];
+  health: ProviderHealth | undefined;
+  quota: ProviderQuota | undefined;
+  busy: boolean;
+  onEdit: () => void;
+  onDelete: () => void;
+  onBind: (agent: Agent) => void;
+}) {
+  const id = providerIdOf(provider);
+  const protocol = providerProtocol(provider.type);
+  const models = provider.models ?? [];
+  const users = agents.filter(agent => agent.provider === id);
+  const others = agents.filter(agent => agent.provider !== id);
+
+  return (
+    <Card className="gap-0 py-0">
+      <CardContent className="flex h-full flex-col gap-3 p-4">
+        <div className="flex items-start gap-2">
+          <ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} 
alt={provider.name} size={22} />
+          <div className="min-w-0 flex-1">
+            <div className="flex min-w-0 items-center gap-2">
+              <button
+                type="button"
+                onClick={onEdit}
+                className="min-w-0 truncate text-left font-medium 
hover:underline"
+              >
+                {provider.displayName || provider.name}
+              </button>
+              <HealthDot health={health} />
+            </div>
+            <SimpleTooltip title={provider.baseUrl}>
+              <p className="text-muted-foreground truncate font-mono 
text-xs">{provider.baseUrl || "-"}</p>
+            </SimpleTooltip>
+          </div>
+        </div>
+
+        <div className="flex flex-wrap items-center gap-1.5">
+          <Badge variant={protocol === "anthropic" ? "info" : 
"success"}>{protocol}</Badge>
+          {usesClientAuth(provider) ? (
+            <Badge variant="muted">{i18next.t("provider:Caller's own 
login")}</Badge>
+          ) : null}
+          {provider.status === "enabled" ? null : (
+            <Badge variant="muted">
+              <CircleX />
+              {i18next.t("provider:Disabled")}
+            </Badge>
+          )}
+          {/* A card has no column to fill, so a balance nobody can read is 
left out. */}
+          {quota && (quota.error !== "" || quota.supported) ? <QuotaBadge 
quota={quota} /> : null}
+        </div>
+
+        <div className="flex flex-wrap gap-1">
+          {models.length === 0 ? (
+            <span className="text-muted-foreground text-xs">
+              {usesClientAuth(provider) ? i18next.t("provider:Any model") : 
"-"}
+            </span>
+          ) : (
+            <>
+              {models.slice(0, visibleModels).map(model => (
+                <Badge key={model} variant="muted">
+                  {model}
+                </Badge>
+              ))}
+              {models.length > visibleModels ? (
+                <SimpleTooltip title={models.slice(visibleModels).join(", ")}>
+                  <Badge variant="muted">{`+${models.length - 
visibleModels}`}</Badge>
+                </SimpleTooltip>
+              ) : null}
+            </>
+          )}
+        </div>
+
+        <p className="text-muted-foreground mt-auto truncate text-xs">
+          {users.length === 0
+            ? i18next.t("provider:Used by no agent")
+            : `${i18next.t("provider:Used by")} ${users.map(agent => 
agent.name).join(", ")}`}
+        </p>
+
+        <div className="flex flex-wrap items-center gap-2 border-t pt-3">
+          {others.length === 0 ? null : (
+            <DropdownMenu>
+              <DropdownMenuTrigger asChild>
+                <Button size="sm" variant="outline" disabled={busy}>
+                  {i18next.t("provider:Switch an agent over")}
+                </Button>
+              </DropdownMenuTrigger>
+              <DropdownMenuContent align="start">
+                <DropdownMenuLabel>{i18next.t("provider:Switch 
hint")}</DropdownMenuLabel>
+                {others.map(agent => (
+                  <DropdownMenuItem key={`${agent.agentId}/${agent.path}`} 
onSelect={() => onBind(agent)}>
+                    {agent.name}
+                  </DropdownMenuItem>
+                ))}
+              </DropdownMenuContent>
+            </DropdownMenu>
+          )}
+          <div className="ml-auto flex gap-2">
+            <SimpleTooltip title={i18next.t("general:Edit")}>
+              <Button size="icon-sm" variant="ghost" 
aria-label={i18next.t("general:Edit")} onClick={onEdit}>
+                <Pencil />
+              </Button>
+            </SimpleTooltip>
+            <ConfirmDialog
+              title={i18next
+                .t("general:Sure to delete {name} ?")
+                .replace("{name}", provider.displayName || provider.name)}
+              confirmText={i18next.t("general:Delete")}
+              onConfirm={onDelete}
+            >
+              <Button
+                size="icon-sm"
+                variant="ghost"
+                className="text-destructive"
+                aria-label={i18next.t("general:Delete")}
+              >
+                <Trash2 />
+              </Button>
+            </ConfirmDialog>
+          </div>
+        </div>
+      </CardContent>
+    </Card>
+  );
+}
diff --git a/web/src/components/shared/app-sidebar.tsx 
b/web/src/components/shared/app-sidebar.tsx
index 5345649..ac7aeb7 100644
--- a/web/src/components/shared/app-sidebar.tsx
+++ b/web/src/components/shared/app-sidebar.tsx
@@ -97,18 +97,21 @@ export function AppSidebar({
   openKeys,
   onOpenKeysChange,
   isAdmin,
+  gatewayEnabled,
 }: {
   collapsed: boolean;
   selectedKey: string;
   openKeys: string[];
   onOpenKeysChange: (keys: string[]) => void;
   isAdmin: boolean;
+  gatewayEnabled: boolean;
 }) {
   const toggleGroup = (key: string) => {
     onOpenKeysChange(openKeys.includes(key) ? openKeys.filter(item => item !== 
key) : [...openKeys, key]);
   };
 
-  const visible = (entry: {adminOnly?: boolean}) => !entry.adminOnly || 
isAdmin;
+  const visible = (entry: {adminOnly?: boolean; gatewayOnly?: boolean}) =>
+    (!entry.adminOnly || isAdmin) && (!entry.gatewayOnly || gatewayEnabled);
   const groups: NavGroup[] = navGroups
     .filter(visible)
     .map(group => (group.children ? {...group, children: 
group.children.filter(visible)} : group))
diff --git a/web/src/components/shared/page-header.tsx 
b/web/src/components/shared/page-header.tsx
index 7aee7ed..d7bbdbe 100644
--- a/web/src/components/shared/page-header.tsx
+++ b/web/src/components/shared/page-header.tsx
@@ -13,6 +13,7 @@
 // limitations under the License.
 
 import * as React from "react";
+import {ChevronDown} from "lucide-react";
 
 import {cn} from "@/lib/utils";
 import {Card, CardContent, CardDescription, CardHeader, CardTitle} from 
"@/components/ui/card";
@@ -52,23 +53,41 @@ export function Section({
   description,
   columns = 3,
   className,
+  collapsible = false,
   children,
 }: {
   title?: React.ReactNode;
   description?: React.ReactNode;
   columns?: 1 | 2 | 3;
   className?: string;
+  /** Starts folded, for a section most people never open. */
+  collapsible?: boolean;
   children: React.ReactNode;
 }) {
+  const [open, setOpen] = React.useState(!collapsible);
+
   return (
     <Card className={cn("gap-4 py-5", className)}>
       {title || description ? (
         <CardHeader className="px-5">
-          {title ? <CardTitle className="text-[15px]">{title}</CardTitle> : 
null}
+          {title ? (
+            collapsible ? (
+              <button
+                type="button"
+                onClick={() => setOpen(!open)}
+                className="flex w-full items-center gap-1.5 text-left"
+              >
+                <ChevronDown className={cn("size-4 transition-transform", open 
&& "rotate-180")} />
+                <CardTitle className="text-[15px]">{title}</CardTitle>
+              </button>
+            ) : (
+              <CardTitle className="text-[15px]">{title}</CardTitle>
+            )
+          ) : null}
           {description ? <CardDescription>{description}</CardDescription> : 
null}
         </CardHeader>
       ) : null}
-      <CardContent className="px-5">
+      <CardContent className={cn("px-5", !open && "hidden")}>
         <div
           className={cn(
             "grid gap-4",
diff --git a/web/src/lib/providers.ts b/web/src/lib/providers.ts
index d6efa3e..2ab910e 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -36,8 +36,9 @@ const protocolEnv: Record<string, {baseUrl: string; token: 
string}> = {
 };
 
 /**
- * A stand-in for the key a client refuses to start without. Gateway 
authenticates
- * upstream with the provider's own key, so this value is never used for 
anything.
+ * What a client sends the relay while its own key stays here. Gateway issues 
it
+ * on first start; this is only the fallback for a page that has not been told
+ * the real one yet.
  */
 const placeholderToken = "casbin-gateway";
 
@@ -49,11 +50,17 @@ export type Shell = (typeof shells)[number];
  * provider forwards the client's own credentials, so the token is left out
  * there: setting it would replace the sign-in the client already has.
  */
-export function envSnippet(protocol: string, baseUrl: string, shell: Shell, 
includeToken = true) {
+export function envSnippet(
+  protocol: string,
+  baseUrl: string,
+  shell: Shell,
+  includeToken = true,
+  token = placeholderToken,
+) {
   const env = protocolEnv[protocol] ?? protocolEnv.openai;
   const variables: [string, string][] = [[env.baseUrl, baseUrl]];
   if (includeToken) {
-    variables.push([env.token, placeholderToken]);
+    variables.push([env.token, token || placeholderToken]);
   }
 
   return variables
@@ -83,6 +90,21 @@ export function localShell(): Shell {
   return navigator.userAgent.includes("Windows") ? "PowerShell" : "bash";
 }
 
+/**
+ * The identifier stored for a provider, derived from the name the user sees so
+ * that it stays readable. The server appends a number when it is already 
taken,
+ * which is why nothing random is needed here.
+ */
+export function providerSlug(label: string) {
+  const slug = label
+    .toLowerCase()
+    .replace(/[^a-z0-9]+/g, "-")
+    .replace(/^-+|-+$/g, "")
+    .slice(0, 60);
+
+  return slug === "" ? "provider" : slug;
+}
+
 /**
  * Who a vendor is, which is the thing worth knowing when picking one: the 
maker
  * of the models, a platform that hosts models it did not make, or a site that
diff --git a/web/src/lib/relayToken.ts b/web/src/lib/relayToken.ts
new file mode 100644
index 0000000..45f5494
--- /dev/null
+++ b/web/src/lib/relayToken.ts
@@ -0,0 +1,48 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+
+import * as MiscBackend from "@/backend/MiscBackend";
+
+/** One request per page load: the token does not change while Gateway runs. */
+let pending: Promise<string> | null = null;
+
+function fetchRelayToken() {
+  if (pending === null) {
+    pending = MiscBackend.getRelayToken()
+      .then(res => (res.status === "ok" ? (res.data?.relayToken ?? "") : ""))
+      .catch(() => "");
+  }
+  return pending;
+}
+
+/** The token the snippets have to show, so what is copied actually works. */
+export function useRelayToken() {
+  const [token, setToken] = React.useState("");
+
+  React.useEffect(() => {
+    let live = true;
+    fetchRelayToken().then(value => {
+      if (live) {
+        setToken(value);
+      }
+    });
+    return () => {
+      live = false;
+    };
+  }, []);
+
+  return token;
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 202773a..1680ffb 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -120,9 +120,10 @@
     "Stop": "Stop",
     "Stop hint": "Ends every process of this installation, losing whatever it 
is working on.",
     "Stopped": "Stopped",
+    "Switch takes effect at once": "Picking another provider above rewrites 
this file at once, so switching takes one click.",
     "Target / Model": "Target / Model",
     "Time": "Time",
-    "Token hint": "Gateway authenticates upstream with the provider's own key, 
so this token is only there because the client insists on one - any value 
works.",
+    "Token hint": "Gateway authenticates upstream with the provider's own key. 
This value is Gateway's own relay token, which a request from another machine 
has to carry.",
     "Tool": "Tool",
     "Tool use ID": "Tool use ID",
     "Turn on monitoring to collect activity": "Turn on monitoring to collect 
activity",
@@ -332,6 +333,7 @@
     "Name": "Name",
     "Name cannot be empty": "Name cannot be empty",
     "New Record": "New Record",
+    "Next": "Next",
     "No Upgrade": "No Upgrade",
     "No data": "No data",
     "Nodes": "Nodes",
@@ -341,6 +343,7 @@
     "Owner": "Owner",
     "Password": "Password",
     "Path": "Path",
+    "Previous": "Previous",
     "Records": "Records",
     "Refresh": "Refresh",
     "Request Count Over Time": "Request Count Over Time",
@@ -531,6 +534,7 @@
     "Models": "Models",
     "Models from upstream": "Models from upstream",
     "Models hint": "The model names this provider serves, used to route 
requests that arrive by model name.",
+    "Name hint": "Whatever you will recognise this account by. A second 
account with the same vendor gets a number.",
     "New Provider": "New Provider",
     "No balance endpoint": "No balance endpoint",
     "No models hint": "With no model named, nothing that arrives by model name 
will be routed here. Press Fetch models, or type the names this provider should 
serve. An empty list only means \"any model\" for a provider that forwards the 
caller's own login.",
@@ -568,11 +572,16 @@
     "Source hint": "Where this provider gets its credentials. Picking one 
fills in the type, base URL and models, so a subscription needs nothing typed 
and a vendor needs only its key.",
     "Status": "Status",
     "Stored API key": "Stored API key",
+    "Switch an agent over": "Switch an agent over",
+    "Switch hint": "Point an agent at this provider",
+    "Switched": "Switched",
     "Test Connectivity": "Test Connectivity",
     "Type": "Type",
     "Usage": "Usage",
     "Usage hint": "Point any client that speaks this provider's API at Gateway 
with these variables.",
     "Used": "used",
+    "Used by": "Used by",
+    "Used by no agent": "No agent uses this provider yet",
     "Used path": "Used path"
   },
   "rule": {
@@ -660,6 +669,8 @@
     "RDS instance": "RDS instance",
     "RDS region": "RDS region",
     "Record mode": "Record mode",
+    "Relay token": "Relay token",
+    "Relay token hint": "What an agent sends to /v1. Requests from this 
machine never need it. Clear the field and save to issue a new one.",
     "Retention days": "Retention days",
     "Reverse proxy": "Reverse proxy",
     "Reverse proxy description": "The WAF itself. Ports 80 and 443 usually 
belong to something else on the host, and binding them needs root on Linux and 
macOS.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index a95a5d2..b0e7bc4 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -120,9 +120,10 @@
     "Stop": "停止",
     "Stop hint": "结束该安装的所有进程,正在进行的工作会丢失。",
     "Stopped": "未运行",
+    "Switch takes effect at once": "在上面换一个供应商会立即改写这个文件,所以切换只需要点一下。",
     "Target / Model": "目标 / 模型",
     "Time": "时间",
-    "Token hint": "网关是用供应商自己的密钥去认证上游的,这里的 token 只是因为客户端一定要有一个,填什么都行。",
+    "Token hint": "Gateway 是用供应商自己的密钥去认证上游的。这里的值是 Gateway 
自己的中继令牌,来自其他机器的请求必须带上它。",
     "Tool": "工具",
     "Tool use ID": "工具调用 ID",
     "Turn on monitoring to collect activity": "开启监控后才会收集活动",
@@ -332,6 +333,7 @@
     "Name": "名称",
     "Name cannot be empty": "名称不能为空",
     "New Record": "新建记录",
+    "Next": "下一页",
     "No Upgrade": "不升级",
     "No data": "暂无数据",
     "Nodes": "节点",
@@ -341,6 +343,7 @@
     "Owner": "所有者",
     "Password": "密码",
     "Path": "路径",
+    "Previous": "上一页",
     "Records": "日志",
     "Refresh": "刷新",
     "Request Count Over Time": "请求数趋势",
@@ -531,6 +534,7 @@
     "Models": "模型",
     "Models from upstream": "上游返回的模型",
     "Models hint": "这个供应商提供的模型名,按模型名路由的请求靠它匹配。",
+    "Name hint": "你自己认得出这个账号就行。同一家厂商的第二个账号会自动加编号。",
     "New Provider": "新建供应商",
     "No balance endpoint": "无余额接口",
     "No models hint": 
"没有填写模型名,按模型名路由的请求就不会走到这个供应商。点「获取模型」,或手动填写它该服务的模型名。只有转发调用方自身登录态的供应商,留空才表示「任意模型」。",
@@ -568,11 +572,16 @@
     "Source hint": "这个供应商的凭据从哪里来。选一个就会自动填好类型、base URL 和模型:订阅登录什么都不用填,厂商只用填一个 
Key。",
     "Status": "状态",
     "Stored API key": "Gateway 保存的 API Key",
+    "Switch an agent over": "切换某个智能体",
+    "Switch hint": "把某个智能体指向这个供应商",
+    "Switched": "已切换",
     "Test Connectivity": "测试连接",
     "Type": "类型",
     "Usage": "使用方法",
     "Usage hint": "任何说这个供应商所用 API 的客户端,配上下面的变量就能走网关。",
     "Used": "已用",
+    "Used by": "正在使用:",
+    "Used by no agent": "还没有智能体使用这个供应商",
     "Used path": "已用字段路径"
   },
   "rule": {
@@ -660,6 +669,8 @@
     "RDS instance": "RDS 实例",
     "RDS region": "RDS 地域",
     "Record mode": "记录模式",
+    "Relay token": "中继令牌",
+    "Relay token hint": "智能体访问 /v1 时携带的令牌。来自本机的请求不需要它。清空后保存即可重新生成。",
     "Retention days": "保留天数",
     "Reverse proxy": "反向代理",
     "Reverse proxy description": "即 WAF 本身。80 和 443 端口通常已被主机上的其他程序占用,在 Linux 和 
macOS 上绑定它们还需要 root 权限。",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 8f5843a..854d057 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -45,6 +45,8 @@ export interface NavGroup {
   icon?: LucideIcon;
   path?: string;
   adminOnly?: boolean;
+  /** Hidden until the reverse proxy is turned on, since nothing under it 
works before that. */
+  gatewayOnly?: boolean;
   children?: NavLeaf[];
 }
 
@@ -81,6 +83,7 @@ export const navGroups: NavGroup[] = [
   {
     key: "/advanced",
     label: "general:Advanced",
+    gatewayOnly: true,
     icon: SlidersHorizontal,
     children: [
       {key: "/sites", label: "general:Sites", path: "/sites", icon: Globe},
diff --git a/web/src/pages/ProviderListPage.tsx 
b/web/src/pages/ProviderListPage.tsx
index 9e5f881..eef1eff 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -13,32 +13,27 @@
 // limitations under the License.
 
 import * as React from "react";
-import {Link, useNavigate} from "react-router-dom";
+import {useNavigate} from "react-router-dom";
 import {
   ChevronDown,
-  CircleCheck,
-  CircleX,
   ExternalLink,
-  Pencil,
   Plug,
   Plus,
   RefreshCw,
-  Trash2,
   Wallet,
 } from "lucide-react";
 import i18next from "i18next";
 
+import * as AgentBackend from "@/backend/AgentBackend";
 import * as ProviderBackend from "@/backend/ProviderBackend";
 import * as Setting from "@/Setting";
-import {ProviderIcon, ProviderIconField} from "@/components/ProviderIcon";
+import {ProviderIconField} from "@/components/ProviderIcon";
+import {ProviderGridCard, providerIdOf} from "@/components/ProviderGridCard";
 import {ProviderModelsField} from "@/components/ProviderModelsField";
-import {QuotaBadge} from "@/components/ProviderQuota";
 import {ProviderSourcePicker, sourceTitle} from 
"@/components/ProviderSourcePicker";
 import {ProviderTestField, useProviderTest} from 
"@/components/ProviderTestField";
-import {ConfirmDialog} from "@/components/shared/confirm-dialog";
-import {DataTable, type Column, type SortOrder} from 
"@/components/shared/data-table";
+import {type SortOrder} from "@/components/shared/data-table";
 import {Field, FormDialog} from "@/components/shared/form-dialog";
-import {CodeText} from "@/components/shared/misc";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
 import {PasswordInput} from "@/components/shared/password-input";
 import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
@@ -47,25 +42,26 @@ import {Button} from "@/components/ui/button";
 import {Card, CardContent, CardDescription, CardHeader, CardTitle} from 
"@/components/ui/card";
 import {Input} from "@/components/ui/input";
 import {Textarea} from "@/components/ui/textarea";
-import {SimpleTooltip} from "@/components/ui/tooltip";
 import {
   authProvider,
   authClient,
   baseUrlPlaceholder,
   baseUrlPresets,
   customSource,
+  providerSlug,
   usesClientAuth,
   type ProviderSource,
 } from "@/lib/providers";
 import {cn} from "@/lib/utils";
-import type {Account, Provider, ProviderHealth, ProviderQuota} from "@/types";
+import type {Account, Agent, Provider, ProviderHealth, ProviderQuota} from 
"@/types";
 
 function newProvider(owner: string, label = "New Provider"): Provider {
-  const randomName = Setting.getRandomName();
   return {
     owner: owner,
-    name: `provider_${randomName}`,
-    displayName: `${label} - ${randomName}`,
+    // The server appends a number when this is taken, so a second account with
+    // the same vendor keeps a name someone can read.
+    name: providerSlug(label),
+    displayName: label,
     type: "openai",
     status: "enabled",
     models: [],
@@ -127,6 +123,8 @@ export default function ProviderListPage({account}: 
{account: Account}) {
   const [health, setHealth] = React.useState<ProviderHealth[]>([]);
   const [quotas, setQuotas] = React.useState<ProviderQuota[]>([]);
   const [refreshingQuotas, setRefreshingQuotas] = React.useState(false);
+  const [agents, setAgents] = React.useState<Agent[]>([]);
+  const [binding, setBinding] = React.useState("");
   const test = useProviderTest(form);
 
   const fetchProviders = React.useCallback(
@@ -190,6 +188,22 @@ export default function ProviderListPage({account}: 
{account: Account}) {
       .then(() => setRefreshingQuotas(false));
   }, []);
 
+  // Which agent a provider answers for is the thing this page is really about,
+  // so the agents are loaded next to them. A non-admin cannot list them, and 
the
+  // card simply leaves that line out.
+  const loadAgents = React.useCallback(() => {
+    if (!Setting.isAdminUser(account)) {
+      return;
+    }
+    AgentBackend.getAgents()
+      .then(res => setAgents(res.status === "ok" ? (res.data ?? []) : []))
+      .catch(() => setAgents([]));
+  }, [account]);
+
+  React.useEffect(() => {
+    loadAgents();
+  }, [loadAgents]);
+
   React.useEffect(() => {
     ProviderBackend.getProviderQuotas()
       .then(res => setQuotas(res.status === "ok" ? (res.data ?? []) : []))
@@ -217,7 +231,7 @@ export default function ProviderListPage({account}: 
{account: Account}) {
   // The upstream is probed before the provider is stored, so a key that was
   // pasted wrong is caught here rather than by the first agent that uses it.
   const submitProvider = () => {
-    if (form.name.trim() === "") {
+    if (form.displayName.trim() === "") {
       setNameError(i18next.t("general:Name cannot be empty"));
       return;
     }
@@ -225,9 +239,9 @@ export default function ProviderListPage({account}: 
{account: Account}) {
   };
 
   const addProvider = () => {
-    const name = form.name.trim();
+    const displayName = form.displayName.trim();
     setAdding(true);
-    ProviderBackend.addProvider({...form, name: name})
+    ProviderBackend.addProvider({...form, displayName: displayName, name: 
providerSlug(displayName)})
       .then(res => {
         setAdding(false);
         if (res.status === "error") {
@@ -244,6 +258,31 @@ export default function ProviderListPage({account}: 
{account: Account}) {
       });
   };
 
+  // Switching is one call: the server rewrites the configuration of an agent 
it
+  // already switched, so nothing else has to be clicked afterwards.
+  const bindAgent = (agent: Agent, provider: Provider) => {
+    setBinding(agent.agentId);
+    AgentBackend.updateAgentRouting(agent.agentId, {
+      provider: providerIdOf(provider),
+      fallbacks: [],
+      mode: agent.mode || "gateway",
+    })
+      .then(res => {
+        if (res.status === "ok") {
+          Setting.showMessage(
+            "success",
+            `${i18next.t("provider:Switched")}: ${agent.name} → 
${provider.displayName || provider.name}`,
+          );
+          loadAgents();
+        } else {
+          Setting.showMessage("error", res.msg || i18next.t("agent:Failed to 
update agent provider"));
+          loadAgents();
+        }
+      })
+      .catch(error => Setting.showMessage("error", `${error}`))
+      .then(() => setBinding(""));
+  };
+
   const deleteProvider = (provider: Provider) => {
     ProviderBackend.deleteProvider(provider)
       .then(res => {
@@ -259,189 +298,6 @@ export default function ProviderListPage({account}: 
{account: Account}) {
       );
   };
 
-  const columns: Column<Provider>[] = [
-    {
-      title: i18next.t("general:Name"),
-      key: "name",
-      dataIndex: "name",
-      width: "160px",
-      // The server sorts and paginates, so sorting locally would only reorder
-      // the current page.
-      sorter: true,
-      render: (text: string, record) => (
-        <div className="flex min-w-0 items-center gap-2">
-          <ProviderIcon icon={record.icon} baseUrl={record.baseUrl} alt={text} 
size={18} />
-          <SimpleTooltip title={text}>
-            <Link
-              to={`/providers/${record.owner}/${record.name}`}
-              className="text-primary block truncate font-medium 
hover:underline"
-            >
-              {text}
-            </Link>
-          </SimpleTooltip>
-        </div>
-      ),
-    },
-    {
-      title: i18next.t("general:Display name"),
-      key: "displayName",
-      dataIndex: "displayName",
-      width: "200px",
-      sorter: true,
-      render: (text: string) => (text ? <span className="block 
truncate">{text}</span> : "-"),
-    },
-    {
-      title: i18next.t("provider:Notes"),
-      key: "notes",
-      dataIndex: "notes",
-      width: "180px",
-      ellipsis: true,
-      render: (text: string) =>
-        text ? (
-          <SimpleTooltip title={text}>
-            <span className="text-muted-foreground block 
truncate">{text}</span>
-          </SimpleTooltip>
-        ) : (
-          "-"
-        ),
-    },
-    {
-      title: i18next.t("provider:Type"),
-      key: "type",
-      dataIndex: "type",
-      width: "110px",
-      render: (text: string) => <Badge variant={text === "openai" ? "success" 
: "info"}>{text}</Badge>,
-    },
-    {
-      title: i18next.t("provider:Base URL"),
-      key: "baseUrl",
-      dataIndex: "baseUrl",
-      width: "220px",
-      ellipsis: true,
-      render: (text: string) =>
-        text ? (
-          <SimpleTooltip title={text}>
-            <span className="inline-flex max-w-full">
-              <CodeText>{text}</CodeText>
-            </span>
-          </SimpleTooltip>
-        ) : (
-          "-"
-        ),
-    },
-    {
-      title: i18next.t("provider:Models"),
-      key: "models",
-      dataIndex: "models",
-      width: "220px",
-      render: (models: string[], record) =>
-        !models || models.length === 0 ? (
-          usesClientAuth(record) ? <Badge 
variant="muted">{i18next.t("provider:Any model")}</Badge> : "-"
-        ) : (
-          <div className="flex flex-wrap gap-1">
-            {models.map(model => (
-              <Badge key={model} variant="muted">
-                {model}
-              </Badge>
-            ))}
-          </div>
-        ),
-    },
-    {
-      title: i18next.t("provider:Priority"),
-      key: "priority",
-      dataIndex: "priority",
-      width: "100px",
-      sorter: true,
-    },
-    {
-      title: i18next.t("provider:Status"),
-      key: "status",
-      dataIndex: "status",
-      width: "120px",
-      render: (text: string) =>
-        text === "enabled" ? (
-          <Badge variant="success">
-            <CircleCheck />
-            {i18next.t("provider:Enabled")}
-          </Badge>
-        ) : (
-          <Badge variant="muted">
-            <CircleX />
-            {i18next.t("provider:Disabled")}
-          </Badge>
-        ),
-    },
-    {
-      title: i18next.t("provider:Balance"),
-      key: "quota",
-      width: "140px",
-      render: (_text, record) => (
-        <QuotaBadge quota={quotas.find(item => item.provider === 
`${record.owner}/${record.name}`)} />
-      ),
-    },
-    {
-      title: i18next.t("provider:Health"),
-      key: "health",
-      width: "150px",
-      render: (_text, record) => {
-        const item = health.find(entry => entry.provider === 
`${record.owner}/${record.name}`);
-        if (!item) {
-          return <span 
className="text-muted-foreground">{i18next.t("provider:Not used yet")}</span>;
-        }
-        // A provider that is out of its cooldown but whose last attempts 
failed
-        // is back in rotation without having proven anything yet.
-        const badge = !item.healthy ? (
-          <Badge variant="warning">
-            <CircleX />
-            {i18next.t("provider:Cooling down")}
-          </Badge>
-        ) : item.consecutive > 0 ? (
-          <Badge variant="muted">{i18next.t("provider:Recovering")}</Badge>
-        ) : (
-          <Badge variant="success">
-            <CircleCheck />
-            {i18next.t("provider:Healthy")}
-          </Badge>
-        );
-        return (
-          <SimpleTooltip
-            title={
-              item.healthy
-                ? `${item.successes} / ${item.successes + item.failures}`
-                : `${item.lastError} · ${i18next.t("provider:Retried at")} 
${item.retryTime}`
-            }
-          >
-            {badge}
-          </SimpleTooltip>
-        );
-      },
-    },
-    {
-      title: i18next.t("general:Action"),
-      key: "action",
-      width: "190px",
-      render: (_text, record) => (
-        <div className="flex gap-2">
-          <Button size="sm" variant="outline" onClick={() => 
navigate(`/providers/${record.owner}/${record.name}`)}>
-            <Pencil />
-            {i18next.t("general:Edit")}
-          </Button>
-          <ConfirmDialog
-            title={i18next.t("general:Sure to delete {name} 
?").replace("{name}", record.name)}
-            confirmText={i18next.t("general:Delete")}
-            onConfirm={() => deleteProvider(record)}
-          >
-            <Button size="sm" variant="outline" className="text-destructive">
-              <Trash2 />
-              {i18next.t("general:Delete")}
-            </Button>
-          </ConfirmDialog>
-        </div>
-      ),
-    },
-  ];
-
   return (
     <PageContainer>
       <PageHeader
@@ -469,30 +325,13 @@ export default function ProviderListPage({account}: 
{account: Account}) {
           </CardContent>
         </Card>
       ) : (
-        <DataTable
-          columns={columns}
-          dataSource={data}
-          // An admin sees providers across owners, where names may collide.
-          rowKey={record => `${record.owner}/${record.name}`}
-          loading={loading}
-          onSort={(field, order) => fetchProviders(1, pageSize, {field: field, 
order: order})}
-          serverPagination={{
-            page: page,
-            pageSize: pageSize,
-            total: total,
-            onChange: (nextPage, nextPageSize) => fetchProviders(nextPage, 
nextPageSize),
-          }}
-          title={i18next.t("provider:Providers")}
-          description={`${total} ${i18next.t("provider:Providers")}`}
-          emptyIcon={Plug}
-          toolbar={
-            <>
-              <Button
-                variant="outline"
-                size="sm"
-                onClick={() => loadQuotas(true)}
-                loading={refreshingQuotas}
-              >
+        <div className="space-y-3">
+          <div className="flex flex-wrap items-center justify-between gap-2">
+            <p className="text-muted-foreground text-sm">
+              {`${total} ${i18next.t("provider:Providers")}`}
+            </p>
+            <div className="flex flex-wrap gap-2">
+              <Button variant="outline" size="sm" onClick={() => 
loadQuotas(true)} loading={refreshingQuotas}>
                 <Wallet />
                 {i18next.t("provider:Refresh balances")}
               </Button>
@@ -500,9 +339,46 @@ export default function ProviderListPage({account}: 
{account: Account}) {
                 <RefreshCw />
                 {i18next.t("general:Refresh")}
               </Button>
-            </>
-          }
-        />
+            </div>
+          </div>
+
+          <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
+            {data.map(provider => (
+              <ProviderGridCard
+                key={providerIdOf(provider)}
+                provider={provider}
+                agents={agents}
+                health={health.find(item => item.provider === 
providerIdOf(provider))}
+                quota={quotas.find(item => item.provider === 
providerIdOf(provider))}
+                busy={binding !== ""}
+                onEdit={() => 
navigate(`/providers/${provider.owner}/${provider.name}`)}
+                onDelete={() => deleteProvider(provider)}
+                onBind={agent => bindAgent(agent, provider)}
+              />
+            ))}
+          </div>
+
+          {total > pageSize ? (
+            <div className="flex justify-center gap-2">
+              <Button
+                variant="outline"
+                size="sm"
+                disabled={page <= 1}
+                onClick={() => fetchProviders(page - 1)}
+              >
+                {i18next.t("general:Previous")}
+              </Button>
+              <Button
+                variant="outline"
+                size="sm"
+                disabled={page * pageSize >= total}
+                onClick={() => fetchProviders(page + 1)}
+              >
+                {i18next.t("general:Next")}
+              </Button>
+            </div>
+          ) : null}
+        </div>
       )}
 
       <FormDialog
@@ -539,23 +415,28 @@ export default function ProviderListPage({account}: 
{account: Account}) {
                 </Button>
               </div>
             </Field>
-            <Field label={i18next.t("general:Name")} htmlFor="provider-name" 
required error={nameError}>
+            <Field
+              label={i18next.t("general:Name")}
+              htmlFor="provider-display-name"
+              required
+              error={nameError}
+              hint={i18next.t("provider:Name hint")}
+            >
               <Input
-                id="provider-name"
-                value={form.name}
+                id="provider-display-name"
+                value={form.displayName}
                 onChange={event => {
-                  setFormField("name", event.target.value);
+                  // One field, because two names to invent is one too many. 
The
+                  // identifier follows what was typed until it is edited by 
hand.
+                  setForm(prev => ({
+                    ...prev,
+                    displayName: event.target.value,
+                    name: providerSlug(event.target.value),
+                  }));
                   setNameError("");
                 }}
               />
             </Field>
-            <Field label={i18next.t("general:Display name")} 
htmlFor="provider-display-name">
-              <Input
-                id="provider-display-name"
-                value={form.displayName}
-                onChange={event => setFormField("displayName", 
event.target.value)}
-              />
-            </Field>
             {usesClientAuth(form) ? null : (
               <Field
                 label={
diff --git a/web/src/pages/SettingPage.tsx b/web/src/pages/SettingPage.tsx
index 5228f59..484be03 100644
--- a/web/src/pages/SettingPage.tsx
+++ b/web/src/pages/SettingPage.tsx
@@ -169,7 +169,7 @@ export default function SettingPage({account}: {account: 
Account}) {
         {numberField("agentMonitorPollSeconds", i18next.t("setting:Agent poll 
seconds"), undefined, 1)}
       </Section>
 
-      <Section columns={2} title={i18next.t("setting:Sign-in")} 
description={i18next.t("setting:Sign-in description")}>
+      <Section columns={2} collapsible title={i18next.t("setting:Sign-in")} 
description={i18next.t("setting:Sign-in description")}>
         {textField("casdoorEndpoint", i18next.t("setting:Casdoor endpoint"))}
         {textField("clientId", i18next.t("setting:Client ID"))}
         {secretField("clientSecret", i18next.t("setting:Client secret"))}
@@ -179,9 +179,10 @@ export default function SettingPage({account}: {account: 
Account}) {
 
       <Section columns={2} title={i18next.t("setting:Security")}>
         {secretField("apiKeyEncryptionKey", i18next.t("setting:API key 
encryption key"), i18next.t("setting:API key encryption key hint"))}
+        {textField("relayToken", i18next.t("setting:Relay token"), 
i18next.t("setting:Relay token hint"))}
       </Section>
 
-      <Section columns={2} title={i18next.t("setting:Network and 
certificates")}>
+      <Section columns={2} collapsible title={i18next.t("setting:Network and 
certificates")}>
         {textField("httpProxy", i18next.t("setting:Outbound SOCKS5 proxy"), 
i18next.t("setting:Outbound SOCKS5 proxy hint"))}
         {textField("acmeEmail", i18next.t("setting:ACME email"), 
i18next.t("setting:ACME email hint"))}
         <Field label={i18next.t("setting:ACME private key")} 
htmlFor="setting-acmePrivateKey" className="md:col-span-2">
@@ -196,6 +197,7 @@ export default function SettingPage({account}: {account: 
Account}) {
 
       <Section
         columns={2}
+        collapsible
         title={i18next.t("setting:Application deployment")}
         description={i18next.t("setting:Application deployment description")}
       >
diff --git a/web/src/types.ts b/web/src/types.ts
index dcf8b57..c361fa5 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -434,6 +434,7 @@ export interface Setting {
   casdoorApplication: string;
 
   apiKeyEncryptionKey: string;
+  relayToken: string;
 
   httpProxy: string;
   acmeEmail: string;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to