mirror of
https://gitcode.com/JianFeeeee/webui4frpc.git
synced 2026-09-20 00:47:57 +00:00
feat: session-cookie login/logout + three-tier roles (superadmin/admin/viewer) with read-only UI + remove pink theme
- cookie-based auth (/login /logout) replacing Basic Auth for UI, enabling logout - roles: superadmin (account management only), admin (full except accounts), viewer/audit (read-only status+cluster, export logs) - readonly accounts hide edit buttons (added remote node, group ops, canvas layout/save/import, cluster manage, install) instead of greying them - auditors see status+cluster only; ordinary admins lose the accounts nav; last-admin guard covers superadmin - remove pink theme entirely (switcher, [data-theme=pink], leftover localStorage), keep white/blue
This commit is contained in:
@ -253,12 +253,13 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
h := &httpapi.Handler{
|
h := &httpapi.Handler{
|
||||||
Store: st,
|
Store: st,
|
||||||
Process: pm,
|
Process: pm,
|
||||||
WorkDir: wd,
|
WorkDir: wd,
|
||||||
BinDir: filepath.Join(wd, "bin"),
|
BinDir: filepath.Join(wd, "bin"),
|
||||||
Cluster: reg,
|
Sessions: httpapi.NewSessionStore(),
|
||||||
Ring: ring,
|
Cluster: reg,
|
||||||
|
Ring: ring,
|
||||||
// SelfAddr must be reachable by peers. 0.0.0.0/empty on a real host
|
// SelfAddr must be reachable by peers. 0.0.0.0/empty on a real host
|
||||||
// would break peer reconnect, so fall back to the hostname (container
|
// would break peer reconnect, so fall back to the hostname (container
|
||||||
// name inside docker is a routable addr on the compose network).
|
// name inside docker is a routable addr on the compose network).
|
||||||
|
|||||||
@ -18,14 +18,15 @@ import (
|
|||||||
// and bootstrap), authenticated via a constant-time compare.
|
// and bootstrap), authenticated via a constant-time compare.
|
||||||
// - "key": an API key, authenticated via Bearer + sha256 lookup.
|
// - "key": an API key, authenticated via Bearer + sha256 lookup.
|
||||||
//
|
//
|
||||||
// Level is the effective access tier: "read" | "write" | "admin". For users it
|
// Level is the effective access tier: "read" | "write" | "admin" | "superadmin".
|
||||||
// derives from the role (viewer -> read, admin -> admin); for keys it is the
|
// For users it derives from the role (viewer -> read, admin -> admin,
|
||||||
// key's scope; for the service identity it is always admin (the flags are the
|
// superadmin -> superadmin); for keys it is the key's scope; for the service
|
||||||
// built-in operator).
|
// identity (the -user/-password flags, the built-in operator) it is always
|
||||||
|
// superadmin so account bootstrap/management keeps working.
|
||||||
type identity struct {
|
type identity struct {
|
||||||
Type string `json:"type"` // "user" | "service" | "key"
|
Type string `json:"type"` // "user" | "service" | "key"
|
||||||
Name string `json:"name"` // username / flag user / key label
|
Name string `json:"name"` // username / flag user / key label
|
||||||
Level string `json:"level"` // "read" | "write" | "admin"
|
Level string `json:"level"` // "read" | "write" | "admin" | "superadmin"
|
||||||
UserID int64 `json:"userId"` // users.id for Type=="user"|"key"
|
UserID int64 `json:"userId"` // users.id for Type=="user"|"key"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,7 +41,8 @@ func identityFrom(r *http.Request) identity {
|
|||||||
return identity{}
|
return identity{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// levelRank orders the access tiers: read < write < admin. Unknown = 0.
|
// levelRank orders the access tiers: read < write < admin < superadmin.
|
||||||
|
// Unknown = 0.
|
||||||
func levelRank(level string) int {
|
func levelRank(level string) int {
|
||||||
switch level {
|
switch level {
|
||||||
case "read":
|
case "read":
|
||||||
@ -49,14 +51,21 @@ func levelRank(level string) int {
|
|||||||
return 2
|
return 2
|
||||||
case "admin":
|
case "admin":
|
||||||
return 3
|
return 3
|
||||||
|
case "superadmin":
|
||||||
|
return 4
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// roleLevel maps a user role to an access level (viewer -> read, admin -> admin).
|
// roleLevel maps a user role to an access level. viewer -> read, admin ->
|
||||||
|
// admin, superadmin -> superadmin. A superadmin is the only role that may
|
||||||
|
// manage accounts (the users/apikeys routes require "superadmin").
|
||||||
func roleLevel(role string) string {
|
func roleLevel(role string) string {
|
||||||
if role == "admin" {
|
switch role {
|
||||||
|
case "admin":
|
||||||
return "admin"
|
return "admin"
|
||||||
|
case "superadmin":
|
||||||
|
return "superadmin"
|
||||||
}
|
}
|
||||||
return "read"
|
return "read"
|
||||||
}
|
}
|
||||||
@ -76,22 +85,47 @@ func forbidden(w http.ResponseWriter) {
|
|||||||
_, _ = w.Write([]byte(`{"error":"forbidden: insufficient scope"}`))
|
_, _ = w.Write([]byte(`{"error":"forbidden: insufficient scope"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
// auth wraps a handler with authentication AND authorization. It accepts either
|
// auth wraps a handler with authentication AND authorization. Requests split
|
||||||
// HTTP Basic (users table bcrypt, with a flag-creds admin fast path that keeps
|
// into two worlds:
|
||||||
// inter-node cluster traffic working) or a Bearer API key (sha256-looked-up).
|
// - SPA traffic (X-W4F-UI header, set by web/src/api.ts) authenticates ONLY
|
||||||
// Identities below minLevel get 403; missing/bad credentials get 401.
|
// by the session cookie issued at POST /api/manager/login. Basic/Bearer
|
||||||
|
// headers are ignored so the browser never falls back to cached Basic
|
||||||
|
// credentials — that is what makes logout work.
|
||||||
|
// - everything else (curl/API clients, inter-node relay) resolves the
|
||||||
|
// principal from the session cookie, then HTTP Basic (users table bcrypt,
|
||||||
|
// with a flag-creds admin fast path that keeps inter-node cluster traffic
|
||||||
|
// working), then a Bearer API key (sha256-looked-up).
|
||||||
|
//
|
||||||
|
// Identities below minLevel get 403; missing/bad credentials get 401. The 401
|
||||||
|
// deliberately omits WWW-Authenticate: that header would make the browser pop
|
||||||
|
// its Basic-Auth prompt and cache the credentials, which can never be cleared
|
||||||
|
// from JS and would defeat the session-based logout.
|
||||||
//
|
//
|
||||||
// The flag-creds fast path is checked BEFORE the users table: inter-node token
|
// The flag-creds fast path is checked BEFORE the users table: inter-node token
|
||||||
// relay and the browser-cached Basic header hit it on every request, and a
|
// relay and API Basic clients hit it on every request, and a bcrypt verify per
|
||||||
// bcrypt verify per hop would be wasteful; the flags are the built-in operator
|
// hop would be wasteful; the flags are the built-in operator and are synced
|
||||||
// and are synced into a system=1 admin row by SyncSystemUser anyway, so this
|
// into a system=1 admin row by SyncSystemUser anyway, so this shortcut grants
|
||||||
// shortcut grants no privilege that the flags themselves do not already confer.
|
// no privilege that the flags themselves do not already confer.
|
||||||
func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc {
|
func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc {
|
||||||
need := levelRank(minLevel)
|
need := levelRank(minLevel)
|
||||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var id identity
|
var id identity
|
||||||
switch {
|
switch {
|
||||||
|
case r.Header.Get("X-W4F-UI") == "1":
|
||||||
|
// SPA requests: the session cookie is the ONLY accepted credential.
|
||||||
|
// Basic/Bearer headers are ignored here — including Basic creds the
|
||||||
|
// browser cached before sessions existed — so a cleared cookie truly
|
||||||
|
// signs the browser out (logout cannot work through cached Basic).
|
||||||
|
if h.Sessions != nil {
|
||||||
|
id, _ = h.Sessions.validate(r)
|
||||||
|
}
|
||||||
|
case h.Sessions != nil:
|
||||||
|
if sid, ok := h.Sessions.validate(r); ok {
|
||||||
|
id = sid
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fallthrough
|
||||||
case strings.HasPrefix(r.Header.Get("Authorization"), "Basic "):
|
case strings.HasPrefix(r.Header.Get("Authorization"), "Basic "):
|
||||||
u, p, ok := r.BasicAuth()
|
u, p, ok := r.BasicAuth()
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -99,7 +133,7 @@ func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc
|
|||||||
}
|
}
|
||||||
if subtle.ConstantTimeCompare([]byte(u), []byte(h.User)) == 1 &&
|
if subtle.ConstantTimeCompare([]byte(u), []byte(h.User)) == 1 &&
|
||||||
subtle.ConstantTimeCompare([]byte(p), []byte(h.Password)) == 1 {
|
subtle.ConstantTimeCompare([]byte(p), []byte(h.Password)) == 1 {
|
||||||
id = identity{Type: "service", Name: h.User, Level: "admin"}
|
id = identity{Type: "service", Name: h.User, Level: "superadmin"}
|
||||||
} else if usr, ok := h.Store.VerifyUserPassword(u, p); ok {
|
} else if usr, ok := h.Store.VerifyUserPassword(u, p); ok {
|
||||||
id = identity{Type: "user", Name: usr.Username, Level: roleLevel(usr.Role), UserID: usr.ID}
|
id = identity{Type: "user", Name: usr.Username, Level: roleLevel(usr.Role), UserID: usr.ID}
|
||||||
_ = h.Store.TouchUserLogin(usr.ID)
|
_ = h.Store.TouchUserLogin(usr.ID)
|
||||||
@ -112,7 +146,7 @@ func (h *Handler) auth(minLevel string) func(http.HandlerFunc) http.HandlerFunc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if id.Level == "" {
|
if id.Level == "" {
|
||||||
w.Header().Set("WWW-Authenticate", `Basic realm="webui-frpc", Bearer realm="webui4frpc-apikey"`)
|
// No WWW-Authenticate here on purpose (see comment above).
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
468
internal/httpapi/dist/favicon.svg
vendored
468
internal/httpapi/dist/favicon.svg
vendored
@ -1,257 +1,257 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
|
||||||
<defs>
|
<defs>
|
||||||
<!-- 主字母像素块(黑色) -->
|
<rect id="p" width="14" height="14" fill="#000000" />
|
||||||
<rect id="p" width="12" height="12" fill="#000000" />
|
<rect id="q" width="7" height="7" fill="#2563eb" />
|
||||||
<!-- frpc 小像素块(蓝色) -->
|
|
||||||
<rect id="q" width="5" height="5" fill="#2563eb" />
|
|
||||||
<!-- 装饰小方块(浅灰) -->
|
|
||||||
<rect id="d" width="8" height="8" fill="#94a3b8" />
|
<rect id="d" width="8" height="8" fill="#94a3b8" />
|
||||||
<!-- 小十字星装饰 -->
|
|
||||||
<rect id="s" width="6" height="6" fill="#94a3b8" />
|
<rect id="s" width="6" height="6" fill="#94a3b8" />
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- 圆角白色背景(模拟 App 图标外框) -->
|
<!-- 圆角白色背景 -->
|
||||||
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
|
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
|
||||||
|
|
||||||
<!-- ========== 主字母整体居中(横向间距更合理) ========== -->
|
<!-- ========== 主字母 WUI ========== -->
|
||||||
<g transform="translate(70, 160)">
|
<g transform="translate(70, 100)">
|
||||||
<!-- ===== W(基于原始结构,高度约 150px) ===== -->
|
<!-- W -->
|
||||||
<g transform="translate(0, 0)">
|
<g transform="translate(0, 0)">
|
||||||
<!-- 左竖线(占2列) -->
|
|
||||||
<use href="#p" x="0" y="0" />
|
<use href="#p" x="0" y="0" />
|
||||||
<use href="#p" x="12" y="0" />
|
<use href="#p" x="14" y="0" />
|
||||||
<use href="#p" x="0" y="12" />
|
<use href="#p" x="0" y="14" />
|
||||||
<use href="#p" x="12" y="12" />
|
<use href="#p" x="14" y="14" />
|
||||||
<use href="#p" x="0" y="24" />
|
<use href="#p" x="0" y="28" />
|
||||||
<use href="#p" x="12" y="24" />
|
<use href="#p" x="14" y="28" />
|
||||||
<use href="#p" x="0" y="36" />
|
<use href="#p" x="0" y="42" />
|
||||||
<use href="#p" x="12" y="36" />
|
<use href="#p" x="14" y="42" />
|
||||||
<use href="#p" x="0" y="48" />
|
<use href="#p" x="0" y="56" />
|
||||||
<use href="#p" x="12" y="48" />
|
<use href="#p" x="14" y="56" />
|
||||||
<use href="#p" x="0" y="60" />
|
<use href="#p" x="0" y="70" />
|
||||||
<use href="#p" x="12" y="60" />
|
<use href="#p" x="14" y="70" />
|
||||||
<use href="#p" x="0" y="72" />
|
|
||||||
<use href="#p" x="12" y="72" />
|
|
||||||
<use href="#p" x="0" y="84" />
|
<use href="#p" x="0" y="84" />
|
||||||
<use href="#p" x="12" y="84" />
|
<use href="#p" x="14" y="84" />
|
||||||
<use href="#p" x="0" y="96" />
|
<use href="#p" x="0" y="98" />
|
||||||
<use href="#p" x="12" y="96" />
|
<use href="#p" x="14" y="98" />
|
||||||
<use href="#p" x="0" y="108" />
|
<use href="#p" x="0" y="112" />
|
||||||
<use href="#p" x="12" y="108" />
|
<use href="#p" x="14" y="112" />
|
||||||
<use href="#p" x="0" y="120" />
|
<use href="#p" x="0" y="126" />
|
||||||
<use href="#p" x="12" y="120" />
|
<use href="#p" x="14" y="126" />
|
||||||
<use href="#p" x="0" y="132" />
|
<use href="#p" x="0" y="140" />
|
||||||
<use href="#p" x="12" y="132" />
|
<use href="#p" x="14" y="140" />
|
||||||
|
<use href="#p" x="0" y="154" />
|
||||||
<!-- 右竖线(占2列) -->
|
<use href="#p" x="14" y="154" />
|
||||||
<use href="#p" x="84" y="0" />
|
<use href="#p" x="0" y="168" />
|
||||||
<use href="#p" x="96" y="0" />
|
<use href="#p" x="14" y="168" />
|
||||||
<use href="#p" x="84" y="12" />
|
<use href="#p" x="98" y="0" />
|
||||||
<use href="#p" x="96" y="12" />
|
<use href="#p" x="112" y="0" />
|
||||||
<use href="#p" x="84" y="24" />
|
<use href="#p" x="98" y="14" />
|
||||||
<use href="#p" x="96" y="24" />
|
<use href="#p" x="112" y="14" />
|
||||||
<use href="#p" x="84" y="36" />
|
<use href="#p" x="98" y="28" />
|
||||||
<use href="#p" x="96" y="36" />
|
<use href="#p" x="112" y="28" />
|
||||||
<use href="#p" x="84" y="48" />
|
<use href="#p" x="98" y="42" />
|
||||||
<use href="#p" x="96" y="48" />
|
<use href="#p" x="112" y="42" />
|
||||||
<use href="#p" x="84" y="60" />
|
<use href="#p" x="98" y="56" />
|
||||||
<use href="#p" x="96" y="60" />
|
<use href="#p" x="112" y="56" />
|
||||||
<use href="#p" x="84" y="72" />
|
<use href="#p" x="98" y="70" />
|
||||||
<use href="#p" x="96" y="72" />
|
<use href="#p" x="112" y="70" />
|
||||||
|
<use href="#p" x="98" y="84" />
|
||||||
|
<use href="#p" x="112" y="84" />
|
||||||
|
<use href="#p" x="98" y="98" />
|
||||||
|
<use href="#p" x="112" y="98" />
|
||||||
|
<use href="#p" x="98" y="112" />
|
||||||
|
<use href="#p" x="112" y="112" />
|
||||||
|
<use href="#p" x="98" y="126" />
|
||||||
|
<use href="#p" x="112" y="126" />
|
||||||
|
<use href="#p" x="98" y="140" />
|
||||||
|
<use href="#p" x="112" y="140" />
|
||||||
|
<use href="#p" x="98" y="154" />
|
||||||
|
<use href="#p" x="112" y="154" />
|
||||||
|
<use href="#p" x="98" y="168" />
|
||||||
|
<use href="#p" x="112" y="168" />
|
||||||
|
<use href="#p" x="28" y="70" />
|
||||||
|
<use href="#p" x="56" y="70" />
|
||||||
|
<use href="#p" x="84" y="70" />
|
||||||
|
<use href="#p" x="28" y="84" />
|
||||||
|
<use href="#p" x="56" y="84" />
|
||||||
<use href="#p" x="84" y="84" />
|
<use href="#p" x="84" y="84" />
|
||||||
<use href="#p" x="96" y="84" />
|
<use href="#p" x="28" y="98" />
|
||||||
<use href="#p" x="84" y="96" />
|
<use href="#p" x="56" y="98" />
|
||||||
<use href="#p" x="96" y="96" />
|
<use href="#p" x="84" y="98" />
|
||||||
<use href="#p" x="84" y="108" />
|
<use href="#p" x="28" y="112" />
|
||||||
<use href="#p" x="96" y="108" />
|
<use href="#p" x="56" y="112" />
|
||||||
<use href="#p" x="84" y="120" />
|
<use href="#p" x="84" y="112" />
|
||||||
<use href="#p" x="96" y="120" />
|
<use href="#p" x="42" y="126" />
|
||||||
<use href="#p" x="84" y="132" />
|
<use href="#p" x="70" y="126" />
|
||||||
<use href="#p" x="96" y="132" />
|
<use href="#p" x="42" y="140" />
|
||||||
|
<use href="#p" x="70" y="140" />
|
||||||
<!-- 中间 V 形(从 y=60 开始) -->
|
<use href="#p" x="42" y="154" />
|
||||||
<use href="#p" x="24" y="60" />
|
<use href="#p" x="70" y="154" />
|
||||||
<use href="#p" x="48" y="60" />
|
<use href="#p" x="56" y="154" />
|
||||||
<use href="#p" x="72" y="60" />
|
<use href="#p" x="56" y="168" />
|
||||||
<use href="#p" x="24" y="72" />
|
|
||||||
<use href="#p" x="48" y="72" />
|
|
||||||
<use href="#p" x="72" y="72" />
|
|
||||||
<use href="#p" x="24" y="84" />
|
|
||||||
<use href="#p" x="48" y="84" />
|
|
||||||
<use href="#p" x="72" y="84" />
|
|
||||||
<use href="#p" x="24" y="96" />
|
|
||||||
<use href="#p" x="48" y="96" />
|
|
||||||
<use href="#p" x="72" y="96" />
|
|
||||||
<!-- 底部汇合 -->
|
|
||||||
<use href="#p" x="36" y="108" />
|
|
||||||
<use href="#p" x="60" y="108" />
|
|
||||||
<use href="#p" x="36" y="120" />
|
|
||||||
<use href="#p" x="60" y="120" />
|
|
||||||
<use href="#p" x="36" y="132" />
|
|
||||||
<use href="#p" x="60" y="132" />
|
|
||||||
<use href="#p" x="48" y="132" />
|
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== U(高度与 W 一致,内部空间增大) ===== -->
|
<!-- U -->
|
||||||
<g transform="translate(124, 0)">
|
<g transform="translate(144, 0)">
|
||||||
<!-- 左竖线(占2列) -->
|
|
||||||
<use href="#p" x="0" y="0" />
|
<use href="#p" x="0" y="0" />
|
||||||
<use href="#p" x="12" y="0" />
|
<use href="#p" x="14" y="0" />
|
||||||
<use href="#p" x="0" y="12" />
|
<use href="#p" x="0" y="14" />
|
||||||
<use href="#p" x="12" y="12" />
|
<use href="#p" x="14" y="14" />
|
||||||
<use href="#p" x="0" y="24" />
|
<use href="#p" x="0" y="28" />
|
||||||
<use href="#p" x="12" y="24" />
|
<use href="#p" x="14" y="28" />
|
||||||
<use href="#p" x="0" y="36" />
|
<use href="#p" x="0" y="42" />
|
||||||
<use href="#p" x="12" y="36" />
|
<use href="#p" x="14" y="42" />
|
||||||
<use href="#p" x="0" y="48" />
|
<use href="#p" x="0" y="56" />
|
||||||
<use href="#p" x="12" y="48" />
|
<use href="#p" x="14" y="56" />
|
||||||
<use href="#p" x="0" y="60" />
|
<use href="#p" x="0" y="70" />
|
||||||
<use href="#p" x="12" y="60" />
|
<use href="#p" x="14" y="70" />
|
||||||
<use href="#p" x="0" y="72" />
|
|
||||||
<use href="#p" x="12" y="72" />
|
|
||||||
<use href="#p" x="0" y="84" />
|
<use href="#p" x="0" y="84" />
|
||||||
<use href="#p" x="12" y="84" />
|
<use href="#p" x="14" y="84" />
|
||||||
<use href="#p" x="0" y="96" />
|
<use href="#p" x="0" y="98" />
|
||||||
<use href="#p" x="12" y="96" />
|
<use href="#p" x="14" y="98" />
|
||||||
<use href="#p" x="0" y="108" />
|
<use href="#p" x="0" y="112" />
|
||||||
<use href="#p" x="12" y="108" />
|
<use href="#p" x="14" y="112" />
|
||||||
<use href="#p" x="0" y="120" />
|
<use href="#p" x="0" y="126" />
|
||||||
<use href="#p" x="12" y="120" />
|
<use href="#p" x="14" y="126" />
|
||||||
<use href="#p" x="0" y="132" />
|
<use href="#p" x="0" y="140" />
|
||||||
<use href="#p" x="12" y="132" />
|
<use href="#p" x="14" y="140" />
|
||||||
|
<use href="#p" x="0" y="154" />
|
||||||
|
<use href="#p" x="14" y="154" />
|
||||||
|
<use href="#p" x="0" y="168" />
|
||||||
|
<use href="#p" x="14" y="168" />
|
||||||
|
<use href="#p" x="98" y="0" />
|
||||||
|
<use href="#p" x="112" y="0" />
|
||||||
|
<use href="#p" x="98" y="14" />
|
||||||
|
<use href="#p" x="112" y="14" />
|
||||||
|
<use href="#p" x="98" y="28" />
|
||||||
|
<use href="#p" x="112" y="28" />
|
||||||
|
<use href="#p" x="98" y="42" />
|
||||||
|
<use href="#p" x="112" y="42" />
|
||||||
|
<use href="#p" x="98" y="56" />
|
||||||
|
<use href="#p" x="112" y="56" />
|
||||||
|
<use href="#p" x="98" y="70" />
|
||||||
|
<use href="#p" x="112" y="70" />
|
||||||
|
<use href="#p" x="98" y="84" />
|
||||||
|
<use href="#p" x="112" y="84" />
|
||||||
|
<use href="#p" x="98" y="98" />
|
||||||
|
<use href="#p" x="112" y="98" />
|
||||||
|
<use href="#p" x="98" y="112" />
|
||||||
|
<use href="#p" x="112" y="112" />
|
||||||
|
<use href="#p" x="98" y="126" />
|
||||||
|
<use href="#p" x="112" y="126" />
|
||||||
|
<use href="#p" x="98" y="140" />
|
||||||
|
<use href="#p" x="112" y="140" />
|
||||||
|
<use href="#p" x="98" y="154" />
|
||||||
|
<use href="#p" x="112" y="154" />
|
||||||
|
<use href="#p" x="98" y="168" />
|
||||||
|
<use href="#p" x="112" y="168" />
|
||||||
|
<use href="#p" x="0" y="168" />
|
||||||
|
<use href="#p" x="14" y="168" />
|
||||||
|
<use href="#p" x="28" y="168" />
|
||||||
|
<use href="#p" x="42" y="168" />
|
||||||
|
<use href="#p" x="56" y="168" />
|
||||||
|
<use href="#p" x="70" y="168" />
|
||||||
|
<use href="#p" x="84" y="168" />
|
||||||
|
<use href="#p" x="98" y="168" />
|
||||||
|
<use href="#p" x="112" y="168" />
|
||||||
|
|
||||||
<!-- 右竖线(占2列) -->
|
<!-- ===== frpc(修正版:f 有完整竖线) ===== -->
|
||||||
<use href="#p" x="84" y="0" />
|
<g transform="translate(49, 20)">
|
||||||
<use href="#p" x="96" y="0" />
|
<!--
|
||||||
<use href="#p" x="84" y="12" />
|
每个字母高度约 28px(4行×7px)
|
||||||
<use href="#p" x="96" y="12" />
|
间距:字母之间空 14px(2行×7px)
|
||||||
<use href="#p" x="84" y="24" />
|
f: y=0-27(竖线4行)
|
||||||
<use href="#p" x="96" y="24" />
|
r: y=41-62
|
||||||
<use href="#p" x="84" y="36" />
|
p: y=76-97
|
||||||
<use href="#p" x="96" y="36" />
|
c: y=111-132
|
||||||
<use href="#p" x="84" y="48" />
|
-->
|
||||||
<use href="#p" x="96" y="48" />
|
|
||||||
<use href="#p" x="84" y="60" />
|
|
||||||
<use href="#p" x="96" y="60" />
|
|
||||||
<use href="#p" x="84" y="72" />
|
|
||||||
<use href="#p" x="96" y="72" />
|
|
||||||
<use href="#p" x="84" y="84" />
|
|
||||||
<use href="#p" x="96" y="84" />
|
|
||||||
<use href="#p" x="84" y="96" />
|
|
||||||
<use href="#p" x="96" y="96" />
|
|
||||||
<use href="#p" x="84" y="108" />
|
|
||||||
<use href="#p" x="96" y="108" />
|
|
||||||
<use href="#p" x="84" y="120" />
|
|
||||||
<use href="#p" x="96" y="120" />
|
|
||||||
<use href="#p" x="84" y="132" />
|
|
||||||
<use href="#p" x="96" y="132" />
|
|
||||||
|
|
||||||
<!-- 底部横线 -->
|
<!-- ===== f:竖线(完整4行)+ 顶横 + 中横 ===== -->
|
||||||
<use href="#p" x="0" y="132" />
|
<!-- 竖线(左侧,从顶到底连续4行) -->
|
||||||
<use href="#p" x="12" y="132" />
|
|
||||||
<use href="#p" x="24" y="132" />
|
|
||||||
<use href="#p" x="36" y="132" />
|
|
||||||
<use href="#p" x="48" y="132" />
|
|
||||||
<use href="#p" x="60" y="132" />
|
|
||||||
<use href="#p" x="72" y="132" />
|
|
||||||
<use href="#p" x="84" y="132" />
|
|
||||||
<use href="#p" x="96" y="132" />
|
|
||||||
|
|
||||||
<!-- ===== U 内部:竖向排列的 frpc(蓝色,5×5 像素) ===== -->
|
|
||||||
<g transform="translate(32, 18)">
|
|
||||||
<!-- f -->
|
|
||||||
<use href="#q" x="0" y="0" />
|
<use href="#q" x="0" y="0" />
|
||||||
<use href="#q" x="0" y="6" />
|
<use href="#q" x="0" y="7" />
|
||||||
<use href="#q" x="0" y="12" />
|
<use href="#q" x="0" y="14" />
|
||||||
<use href="#q" x="0" y="18" />
|
<use href="#q" x="0" y="21" />
|
||||||
<use href="#q" x="6" y="0" />
|
<!-- 顶横(向右延伸) -->
|
||||||
<use href="#q" x="12" y="0" />
|
<use href="#q" x="7" y="0" />
|
||||||
<use href="#q" x="18" y="0" />
|
<use href="#q" x="14" y="0" />
|
||||||
<use href="#q" x="6" y="12" />
|
<!-- 中横(向右延伸) -->
|
||||||
<use href="#q" x="12" y="12" />
|
<use href="#q" x="7" y="14" />
|
||||||
|
<use href="#q" x="14" y="14" />
|
||||||
|
|
||||||
<!-- r -->
|
<!-- ===== r:竖线 + 顶横(一竖一横) ===== -->
|
||||||
<use href="#q" x="0" y="28" />
|
<!-- 竖线(左侧,3行) -->
|
||||||
<use href="#q" x="0" y="34" />
|
<use href="#q" x="0" y="41" />
|
||||||
<use href="#q" x="0" y="40" />
|
<use href="#q" x="0" y="48" />
|
||||||
<use href="#q" x="0" y="46" />
|
<use href="#q" x="0" y="55" />
|
||||||
<use href="#q" x="6" y="28" />
|
<!-- 顶横(向右延伸) -->
|
||||||
<use href="#q" x="12" y="28" />
|
<use href="#q" x="7" y="41" />
|
||||||
<use href="#q" x="18" y="28" />
|
<use href="#q" x="14" y="41" />
|
||||||
<use href="#q" x="6" y="40" />
|
|
||||||
<use href="#q" x="12" y="40" />
|
|
||||||
|
|
||||||
<!-- p -->
|
<!-- ===== p:竖线(向下伸出)+ 右侧圆圈 ===== -->
|
||||||
<use href="#q" x="0" y="56" />
|
<!-- 竖线(左侧,向下伸出5行) -->
|
||||||
<use href="#q" x="0" y="62" />
|
<use href="#q" x="0" y="76" />
|
||||||
<use href="#q" x="0" y="68" />
|
<use href="#q" x="0" y="83" />
|
||||||
<use href="#q" x="0" y="74" />
|
<use href="#q" x="0" y="90" />
|
||||||
<use href="#q" x="0" y="80" />
|
<use href="#q" x="0" y="97" />
|
||||||
<use href="#q" x="6" y="56" />
|
<!-- 圆圈(右侧3×3闭合) -->
|
||||||
<use href="#q" x="12" y="56" />
|
<use href="#q" x="7" y="76" />
|
||||||
<use href="#q" x="18" y="56" />
|
<use href="#q" x="14" y="76" />
|
||||||
<use href="#q" x="18" y="62" />
|
<use href="#q" x="14" y="83" />
|
||||||
<use href="#q" x="18" y="68" />
|
<use href="#q" x="14" y="90" />
|
||||||
<use href="#q" x="6" y="74" />
|
<use href="#q" x="7" y="90" />
|
||||||
<use href="#q" x="12" y="74" />
|
<use href="#q" x="14" y="90" />
|
||||||
<use href="#q" x="18" y="74" />
|
|
||||||
|
|
||||||
<!-- c -->
|
<!-- ===== c:开口朝右的弧形(左侧半圆) ===== -->
|
||||||
<use href="#q" x="6" y="90" />
|
<!-- 左竖 -->
|
||||||
<use href="#q" x="12" y="90" />
|
<use href="#q" x="0" y="111" />
|
||||||
<use href="#q" x="18" y="90" />
|
<use href="#q" x="0" y="118" />
|
||||||
<use href="#q" x="0" y="96" />
|
<use href="#q" x="0" y="125" />
|
||||||
<use href="#q" x="0" y="102" />
|
<!-- 上弧 -->
|
||||||
<use href="#q" x="6" y="108" />
|
<use href="#q" x="7" y="111" />
|
||||||
<use href="#q" x="12" y="108" />
|
<use href="#q" x="14" y="111" />
|
||||||
<use href="#q" x="18" y="108" />
|
<!-- 下弧 -->
|
||||||
|
<use href="#q" x="7" y="125" />
|
||||||
|
<use href="#q" x="14" y="125" />
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== I(中间竖线 2 列宽,高度一致) ===== -->
|
<!-- I -->
|
||||||
<g transform="translate(248, 0)">
|
<g transform="translate(288, 0)">
|
||||||
<!-- 顶部横线(占6列) -->
|
|
||||||
<use href="#p" x="0" y="0" />
|
<use href="#p" x="0" y="0" />
|
||||||
<use href="#p" x="12" y="0" />
|
<use href="#p" x="14" y="0" />
|
||||||
<use href="#p" x="24" y="0" />
|
<use href="#p" x="28" y="0" />
|
||||||
<use href="#p" x="36" y="0" />
|
<use href="#p" x="42" y="0" />
|
||||||
<use href="#p" x="48" y="0" />
|
<use href="#p" x="56" y="0" />
|
||||||
<use href="#p" x="60" y="0" />
|
<use href="#p" x="70" y="0" />
|
||||||
|
<use href="#p" x="28" y="14" />
|
||||||
<!-- 中间竖线(2 列宽) -->
|
<use href="#p" x="42" y="14" />
|
||||||
<use href="#p" x="24" y="12" />
|
<use href="#p" x="28" y="28" />
|
||||||
<use href="#p" x="36" y="12" />
|
<use href="#p" x="42" y="28" />
|
||||||
<use href="#p" x="24" y="24" />
|
<use href="#p" x="28" y="42" />
|
||||||
<use href="#p" x="36" y="24" />
|
<use href="#p" x="42" y="42" />
|
||||||
<use href="#p" x="24" y="36" />
|
<use href="#p" x="28" y="56" />
|
||||||
<use href="#p" x="36" y="36" />
|
<use href="#p" x="42" y="56" />
|
||||||
<use href="#p" x="24" y="48" />
|
<use href="#p" x="28" y="70" />
|
||||||
<use href="#p" x="36" y="48" />
|
<use href="#p" x="42" y="70" />
|
||||||
<use href="#p" x="24" y="60" />
|
<use href="#p" x="28" y="84" />
|
||||||
<use href="#p" x="36" y="60" />
|
<use href="#p" x="42" y="84" />
|
||||||
<use href="#p" x="24" y="72" />
|
<use href="#p" x="28" y="98" />
|
||||||
<use href="#p" x="36" y="72" />
|
<use href="#p" x="42" y="98" />
|
||||||
<use href="#p" x="24" y="84" />
|
<use href="#p" x="28" y="112" />
|
||||||
<use href="#p" x="36" y="84" />
|
<use href="#p" x="42" y="112" />
|
||||||
<use href="#p" x="24" y="96" />
|
<use href="#p" x="28" y="126" />
|
||||||
<use href="#p" x="36" y="96" />
|
<use href="#p" x="42" y="126" />
|
||||||
<use href="#p" x="24" y="108" />
|
<use href="#p" x="28" y="140" />
|
||||||
<use href="#p" x="36" y="108" />
|
<use href="#p" x="42" y="140" />
|
||||||
<use href="#p" x="24" y="120" />
|
<use href="#p" x="28" y="154" />
|
||||||
<use href="#p" x="36" y="120" />
|
<use href="#p" x="42" y="154" />
|
||||||
|
<use href="#p" x="0" y="168" />
|
||||||
<!-- 底部横线(占6列) -->
|
<use href="#p" x="14" y="168" />
|
||||||
<use href="#p" x="0" y="132" />
|
<use href="#p" x="28" y="168" />
|
||||||
<use href="#p" x="12" y="132" />
|
<use href="#p" x="42" y="168" />
|
||||||
<use href="#p" x="24" y="132" />
|
<use href="#p" x="56" y="168" />
|
||||||
<use href="#p" x="36" y="132" />
|
<use href="#p" x="70" y="168" />
|
||||||
<use href="#p" x="48" y="132" />
|
|
||||||
<use href="#p" x="60" y="132" />
|
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ========== 外围装饰元素(像素风,增加层次) ========== -->
|
<!-- ========== 外围装饰 ========== -->
|
||||||
<!-- 左上角小十字星 -->
|
|
||||||
<g transform="translate(40, 40)">
|
<g transform="translate(40, 40)">
|
||||||
<use href="#s" x="0" y="0" />
|
<use href="#s" x="0" y="0" />
|
||||||
<use href="#s" x="0" y="12" />
|
<use href="#s" x="0" y="12" />
|
||||||
@ -259,11 +259,8 @@
|
|||||||
<use href="#s" x="12" y="0" />
|
<use href="#s" x="12" y="0" />
|
||||||
<use href="#s" x="12" y="12" />
|
<use href="#s" x="12" y="12" />
|
||||||
</g>
|
</g>
|
||||||
<!-- 右上角小方块 -->
|
|
||||||
<use href="#d" x="440" y="40" />
|
<use href="#d" x="440" y="40" />
|
||||||
<!-- 左下角小方块 -->
|
|
||||||
<use href="#d" x="40" y="440" />
|
<use href="#d" x="40" y="440" />
|
||||||
<!-- 右下角小十字星 -->
|
|
||||||
<g transform="translate(436, 436)">
|
<g transform="translate(436, 436)">
|
||||||
<use href="#s" x="0" y="0" />
|
<use href="#s" x="0" y="0" />
|
||||||
<use href="#s" x="0" y="12" />
|
<use href="#s" x="0" y="12" />
|
||||||
@ -272,12 +269,13 @@
|
|||||||
<use href="#s" x="12" y="12" />
|
<use href="#s" x="12" y="12" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- 三个随机黑色装饰点(更大一点,12×12) -->
|
<!-- 黑色装饰点 -->
|
||||||
<rect x="90" y="380" width="12" height="12" fill="#000000" />
|
<rect x="90" y="380" width="14" height="14" fill="#000000" />
|
||||||
<rect x="420" y="140" width="12" height="12" fill="#000000" />
|
<rect x="420" y="140" width="14" height="14" fill="#000000" />
|
||||||
<rect x="240" y="60" width="12" height="12" fill="#000000" />
|
<rect x="230" y="50" width="14" height="14" fill="#000000" />
|
||||||
|
|
||||||
<!-- 额外小点缀:蓝色小点,呼应 frpc 颜色 -->
|
<!-- 蓝色小点缀 -->
|
||||||
<rect x="180" y="400" width="6" height="6" fill="#2563eb" />
|
<rect x="180" y="400" width="8" height="8" fill="#2563eb" />
|
||||||
<rect x="370" y="80" width="6" height="6" fill="#2563eb" />
|
<rect x="370" y="80" width="8" height="8" fill="#2563eb" />
|
||||||
|
<rect x="50" y="250" width="8" height="8" fill="#2563eb" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
4
internal/httpapi/dist/index.html
vendored
4
internal/httpapi/dist/index.html
vendored
@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
<title>webui4frpc</title>
|
<title>webui4frpc</title>
|
||||||
<script type="module" crossorigin src="/assets/index-C5WLVFP2.js"></script>
|
<script type="module" crossorigin src="/assets/index-CbqC9m-j.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CL42Ur3_.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-RFHYrCiX.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@ -93,8 +93,8 @@ func (h *Handler) handleUserByName(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
enabled = *req.Enabled
|
enabled = *req.Enabled
|
||||||
}
|
}
|
||||||
// Guard: never disable/demote the last admin.
|
// Guard: never disable/demote the last admin or superadmin.
|
||||||
if u.Role == "admin" && (role != "admin" || !enabled) {
|
if (u.Role == "admin" || u.Role == "superadmin") && (role != u.Role || !enabled) {
|
||||||
n, _ := h.Store.CountAdmins()
|
n, _ := h.Store.CountAdmins()
|
||||||
if n <= 1 {
|
if n <= 1 {
|
||||||
http.Error(w, "cannot demote or disable the last admin", http.StatusConflict)
|
http.Error(w, "cannot demote or disable the last admin", http.StatusConflict)
|
||||||
@ -112,7 +112,7 @@ func (h *Handler) handleUserByName(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "system user is managed by -user/-password flags", http.StatusConflict)
|
http.Error(w, "system user is managed by -user/-password flags", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u.Role == "admin" {
|
if u.Role == "admin" || u.Role == "superadmin" {
|
||||||
n, _ := h.Store.CountAdmins()
|
n, _ := h.Store.CountAdmins()
|
||||||
if n <= 1 {
|
if n <= 1 {
|
||||||
http.Error(w, "cannot delete the last admin", http.StatusConflict)
|
http.Error(w, "cannot delete the last admin", http.StatusConflict)
|
||||||
|
|||||||
@ -31,6 +31,8 @@ type Handler struct {
|
|||||||
BinDir string
|
BinDir string
|
||||||
User string
|
User string
|
||||||
Password string
|
Password string
|
||||||
|
// Sessions issues the UI login cookies (see session.go).
|
||||||
|
Sessions *SessionStore
|
||||||
|
|
||||||
// InstallBinary downloads and activates a frpc binary. Set by the app to
|
// InstallBinary downloads and activates a frpc binary. Set by the app to
|
||||||
// avoid an import cycle with the install package.
|
// avoid an import cycle with the install package.
|
||||||
@ -120,10 +122,15 @@ func NewServeMux(h *Handler) (http.Handler, error) {
|
|||||||
|
|
||||||
// Account & API key management (admin only).
|
// Account & API key management (admin only).
|
||||||
mux.HandleFunc(apiPrefix+"/me", h.auth("read")(h.handleMe))
|
mux.HandleFunc(apiPrefix+"/me", h.auth("read")(h.handleMe))
|
||||||
mux.HandleFunc(apiPrefix+"/users", h.auth("admin")(h.handleUsers))
|
// UI session login/logout. Deliberately NOT behind auth(): login must be
|
||||||
mux.HandleFunc(apiPrefix+"/users/", h.auth("admin")(h.handleUserByName))
|
// reachable without credentials, and logout must work even when the
|
||||||
mux.HandleFunc(apiPrefix+"/apikeys", h.auth("admin")(h.handleApiKeys))
|
// session is already gone.
|
||||||
mux.HandleFunc(apiPrefix+"/apikeys/", h.auth("admin")(h.handleApiKeyByID))
|
mux.HandleFunc(apiPrefix+"/login", h.handleLogin)
|
||||||
|
mux.HandleFunc(apiPrefix+"/logout", h.handleLogout)
|
||||||
|
mux.HandleFunc(apiPrefix+"/users", h.auth("superadmin")(h.handleUsers))
|
||||||
|
mux.HandleFunc(apiPrefix+"/users/", h.auth("superadmin")(h.handleUserByName))
|
||||||
|
mux.HandleFunc(apiPrefix+"/apikeys", h.auth("superadmin")(h.handleApiKeys))
|
||||||
|
mux.HandleFunc(apiPrefix+"/apikeys/", h.auth("superadmin")(h.handleApiKeyByID))
|
||||||
|
|
||||||
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
|
// M6: peer-to-peer binary exchange endpoint (Basic Auth, same creds).
|
||||||
// Not under /api so peers hit it directly; auth still applied. Peers
|
// Not under /api so peers hit it directly; auth still applied. Peers
|
||||||
|
|||||||
156
internal/httpapi/session.go
Normal file
156
internal/httpapi/session.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// UI sessions. Basic Auth credentials are cached by the browser and cannot be
|
||||||
|
// cleared from JS, which made "logout" impossible. To fix that, the SPA signs
|
||||||
|
// in through POST /api/manager/login and receives an HttpOnly session cookie;
|
||||||
|
// the auth middleware now prefers this cookie over the Basic/Bearer headers
|
||||||
|
// (which remain for API clients and inter-node traffic). Logout just clears
|
||||||
|
// the cookie — a real, working sign-out.
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionCookie is the HttpOnly cookie name carrying the UI session token.
|
||||||
|
const sessionCookie = "w4f_session"
|
||||||
|
|
||||||
|
// sessionTTL is how long a session lives without activity; every validated
|
||||||
|
// request slides the expiry forward.
|
||||||
|
const sessionTTL = 24 * time.Hour
|
||||||
|
|
||||||
|
type sessionEntry struct {
|
||||||
|
id identity
|
||||||
|
expiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStore keeps signed-in UI sessions in memory. It is per-node state:
|
||||||
|
// sessions are not replicated across the ring — each webui instance has its
|
||||||
|
// own browser sessions, so nothing needs to cross nodes.
|
||||||
|
type SessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]sessionEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionStore returns an empty session store.
|
||||||
|
func NewSessionStore() *SessionStore {
|
||||||
|
return &SessionStore{sessions: make(map[string]sessionEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// create issues a fresh session for id and sets the session cookie.
|
||||||
|
func (s *SessionStore) create(w http.ResponseWriter, id identity) {
|
||||||
|
var b [32]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
// crypto/rand failure is fatal in practice; refuse to create a session.
|
||||||
|
http.Error(w, "session error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok := hex.EncodeToString(b[:])
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
s.purgeLocked(now)
|
||||||
|
s.sessions[tok] = sessionEntry{id: id, expiry: now.Add(sessionTTL)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: tok,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(sessionTTL.Seconds()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate reads the session cookie and returns the identity if it is live,
|
||||||
|
// sliding the expiry on success.
|
||||||
|
func (s *SessionStore) validate(r *http.Request) (identity, bool) {
|
||||||
|
c, err := r.Cookie(sessionCookie)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return identity{}, false
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
e, ok := s.sessions[c.Value]
|
||||||
|
if !ok || now.After(e.expiry) {
|
||||||
|
delete(s.sessions, c.Value)
|
||||||
|
return identity{}, false
|
||||||
|
}
|
||||||
|
e.expiry = now.Add(sessionTTL)
|
||||||
|
s.sessions[c.Value] = e
|
||||||
|
return e.id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// destroy clears the session and expires the cookie.
|
||||||
|
func (s *SessionStore) destroy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.sessions, c.Value)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeLocked removes expired entries; caller holds the mutex.
|
||||||
|
func (s *SessionStore) purgeLocked(now time.Time) {
|
||||||
|
for k, e := range s.sessions {
|
||||||
|
if now.After(e.expiry) {
|
||||||
|
delete(s.sessions, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogin issues a UI session cookie from username/password. It is
|
||||||
|
// deliberately NOT wrapped in auth(): the whole point is to obtain the first
|
||||||
|
// credential. The same checks as the auth middleware run here (flag fast path
|
||||||
|
// + users table), then a session cookie is set so the SPA stops using Basic.
|
||||||
|
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var id identity
|
||||||
|
if subtle.ConstantTimeCompare([]byte(req.Username), []byte(h.User)) == 1 &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(req.Password), []byte(h.Password)) == 1 {
|
||||||
|
id = identity{Type: "service", Name: h.User, Level: "superadmin"}
|
||||||
|
} else if usr, ok := h.Store.VerifyUserPassword(req.Username, req.Password); ok {
|
||||||
|
id = identity{Type: "user", Name: usr.Username, Level: roleLevel(usr.Role), UserID: usr.ID}
|
||||||
|
_ = h.Store.TouchUserLogin(usr.ID)
|
||||||
|
}
|
||||||
|
if id.Level == "" {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Sessions.create(w, id)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"name": id.Name, "level": id.Level, "type": id.Type})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout clears the UI session cookie.
|
||||||
|
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
methodNotAllowed(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Sessions.destroy(w, r)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
@ -142,14 +142,15 @@ type Forward struct {
|
|||||||
Disabled bool `json:"disabled,omitempty"`
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// User is an authenticated account. Role gates UI/API access (admin = full,
|
// User is an authenticated account. Role gates UI/API access (superadmin =
|
||||||
// viewer = read-only + exports, for auditors). System users are synced from
|
// full + account management, admin = full except account management, viewer =
|
||||||
// the -user/-password flags and are read-only in the account-management UI.
|
// read-only + exports, for auditors). System users are synced from the
|
||||||
|
// -user/-password flags and are read-only in the account-management UI.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
PasswordHash string `json:"-"` // never serialized to clients
|
PasswordHash string `json:"-"` // never serialized to clients
|
||||||
Role string `json:"role"` // "admin" | "viewer"
|
Role string `json:"role"` // "admin" | "viewer" | "superadmin"
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
System bool `json:"system"` // true = flag-synced, UI read-only
|
System bool `json:"system"` // true = flag-synced, UI read-only
|
||||||
CreatedAt int64 `json:"createdAt"`
|
CreatedAt int64 `json:"createdAt"`
|
||||||
@ -229,7 +230,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'admin', -- 'admin' | 'viewer'
|
role TEXT NOT NULL DEFAULT 'admin', -- 'admin' | 'viewer' | 'superadmin'
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
system INTEGER NOT NULL DEFAULT 0, -- 1 = synced from -user/-password flags, UI read-only
|
system INTEGER NOT NULL DEFAULT 0, -- 1 = synced from -user/-password flags, UI read-only
|
||||||
created_at INTEGER NOT NULL DEFAULT 0,
|
created_at INTEGER NOT NULL DEFAULT 0,
|
||||||
@ -773,7 +774,7 @@ func (s *Store) CreateUser(username, plainPassword, role string) (User, error) {
|
|||||||
if username == "" || plainPassword == "" {
|
if username == "" || plainPassword == "" {
|
||||||
return User{}, ErrInvalid
|
return User{}, ErrInvalid
|
||||||
}
|
}
|
||||||
if role != "admin" && role != "viewer" {
|
if role != "admin" && role != "viewer" && role != "superadmin" {
|
||||||
return User{}, ErrInvalid
|
return User{}, ErrInvalid
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
|
||||||
@ -802,7 +803,7 @@ func (s *Store) UpdateUser(id int64, role string, enabled bool, plainPassword st
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
if role != "admin" && role != "viewer" {
|
if role != "admin" && role != "viewer" && role != "superadmin" {
|
||||||
return ErrInvalid
|
return ErrInvalid
|
||||||
}
|
}
|
||||||
if u.System && plainPassword != "" {
|
if u.System && plainPassword != "" {
|
||||||
@ -840,10 +841,11 @@ func (s *Store) DeleteUser(id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountAdmins returns the count of enabled admin users (for the last-admin guard).
|
// CountAdmins returns the count of enabled admin/superadmin users (for the
|
||||||
|
// last-admin guard; both roles can manage others and must never be wiped out).
|
||||||
func (s *Store) CountAdmins() (int, error) {
|
func (s *Store) CountAdmins() (int, error) {
|
||||||
var n int
|
var n int
|
||||||
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role = 'admin' AND enabled = 1").Scan(&n)
|
err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE role IN ('admin','superadmin') AND enabled = 1").Scan(&n)
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,257 +1,257 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" width="500" height="500" shape-rendering="crispEdges">
|
||||||
<defs>
|
<defs>
|
||||||
<!-- 主字母像素块(黑色) -->
|
<rect id="p" width="14" height="14" fill="#000000" />
|
||||||
<rect id="p" width="12" height="12" fill="#000000" />
|
<rect id="q" width="7" height="7" fill="#2563eb" />
|
||||||
<!-- frpc 小像素块(蓝色) -->
|
|
||||||
<rect id="q" width="5" height="5" fill="#2563eb" />
|
|
||||||
<!-- 装饰小方块(浅灰) -->
|
|
||||||
<rect id="d" width="8" height="8" fill="#94a3b8" />
|
<rect id="d" width="8" height="8" fill="#94a3b8" />
|
||||||
<!-- 小十字星装饰 -->
|
|
||||||
<rect id="s" width="6" height="6" fill="#94a3b8" />
|
<rect id="s" width="6" height="6" fill="#94a3b8" />
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- 圆角白色背景(模拟 App 图标外框) -->
|
<!-- 圆角白色背景 -->
|
||||||
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
|
<rect width="500" height="500" rx="40" fill="#ffffff" stroke="#e2e8f0" stroke-width="4" />
|
||||||
|
|
||||||
<!-- ========== 主字母整体居中(横向间距更合理) ========== -->
|
<!-- ========== 主字母 WUI ========== -->
|
||||||
<g transform="translate(70, 160)">
|
<g transform="translate(70, 100)">
|
||||||
<!-- ===== W(基于原始结构,高度约 150px) ===== -->
|
<!-- W -->
|
||||||
<g transform="translate(0, 0)">
|
<g transform="translate(0, 0)">
|
||||||
<!-- 左竖线(占2列) -->
|
|
||||||
<use href="#p" x="0" y="0" />
|
<use href="#p" x="0" y="0" />
|
||||||
<use href="#p" x="12" y="0" />
|
<use href="#p" x="14" y="0" />
|
||||||
<use href="#p" x="0" y="12" />
|
<use href="#p" x="0" y="14" />
|
||||||
<use href="#p" x="12" y="12" />
|
<use href="#p" x="14" y="14" />
|
||||||
<use href="#p" x="0" y="24" />
|
<use href="#p" x="0" y="28" />
|
||||||
<use href="#p" x="12" y="24" />
|
<use href="#p" x="14" y="28" />
|
||||||
<use href="#p" x="0" y="36" />
|
<use href="#p" x="0" y="42" />
|
||||||
<use href="#p" x="12" y="36" />
|
<use href="#p" x="14" y="42" />
|
||||||
<use href="#p" x="0" y="48" />
|
<use href="#p" x="0" y="56" />
|
||||||
<use href="#p" x="12" y="48" />
|
<use href="#p" x="14" y="56" />
|
||||||
<use href="#p" x="0" y="60" />
|
<use href="#p" x="0" y="70" />
|
||||||
<use href="#p" x="12" y="60" />
|
<use href="#p" x="14" y="70" />
|
||||||
<use href="#p" x="0" y="72" />
|
|
||||||
<use href="#p" x="12" y="72" />
|
|
||||||
<use href="#p" x="0" y="84" />
|
<use href="#p" x="0" y="84" />
|
||||||
<use href="#p" x="12" y="84" />
|
<use href="#p" x="14" y="84" />
|
||||||
<use href="#p" x="0" y="96" />
|
<use href="#p" x="0" y="98" />
|
||||||
<use href="#p" x="12" y="96" />
|
<use href="#p" x="14" y="98" />
|
||||||
<use href="#p" x="0" y="108" />
|
<use href="#p" x="0" y="112" />
|
||||||
<use href="#p" x="12" y="108" />
|
<use href="#p" x="14" y="112" />
|
||||||
<use href="#p" x="0" y="120" />
|
<use href="#p" x="0" y="126" />
|
||||||
<use href="#p" x="12" y="120" />
|
<use href="#p" x="14" y="126" />
|
||||||
<use href="#p" x="0" y="132" />
|
<use href="#p" x="0" y="140" />
|
||||||
<use href="#p" x="12" y="132" />
|
<use href="#p" x="14" y="140" />
|
||||||
|
<use href="#p" x="0" y="154" />
|
||||||
<!-- 右竖线(占2列) -->
|
<use href="#p" x="14" y="154" />
|
||||||
<use href="#p" x="84" y="0" />
|
<use href="#p" x="0" y="168" />
|
||||||
<use href="#p" x="96" y="0" />
|
<use href="#p" x="14" y="168" />
|
||||||
<use href="#p" x="84" y="12" />
|
<use href="#p" x="98" y="0" />
|
||||||
<use href="#p" x="96" y="12" />
|
<use href="#p" x="112" y="0" />
|
||||||
<use href="#p" x="84" y="24" />
|
<use href="#p" x="98" y="14" />
|
||||||
<use href="#p" x="96" y="24" />
|
<use href="#p" x="112" y="14" />
|
||||||
<use href="#p" x="84" y="36" />
|
<use href="#p" x="98" y="28" />
|
||||||
<use href="#p" x="96" y="36" />
|
<use href="#p" x="112" y="28" />
|
||||||
<use href="#p" x="84" y="48" />
|
<use href="#p" x="98" y="42" />
|
||||||
<use href="#p" x="96" y="48" />
|
<use href="#p" x="112" y="42" />
|
||||||
<use href="#p" x="84" y="60" />
|
<use href="#p" x="98" y="56" />
|
||||||
<use href="#p" x="96" y="60" />
|
<use href="#p" x="112" y="56" />
|
||||||
<use href="#p" x="84" y="72" />
|
<use href="#p" x="98" y="70" />
|
||||||
<use href="#p" x="96" y="72" />
|
<use href="#p" x="112" y="70" />
|
||||||
|
<use href="#p" x="98" y="84" />
|
||||||
|
<use href="#p" x="112" y="84" />
|
||||||
|
<use href="#p" x="98" y="98" />
|
||||||
|
<use href="#p" x="112" y="98" />
|
||||||
|
<use href="#p" x="98" y="112" />
|
||||||
|
<use href="#p" x="112" y="112" />
|
||||||
|
<use href="#p" x="98" y="126" />
|
||||||
|
<use href="#p" x="112" y="126" />
|
||||||
|
<use href="#p" x="98" y="140" />
|
||||||
|
<use href="#p" x="112" y="140" />
|
||||||
|
<use href="#p" x="98" y="154" />
|
||||||
|
<use href="#p" x="112" y="154" />
|
||||||
|
<use href="#p" x="98" y="168" />
|
||||||
|
<use href="#p" x="112" y="168" />
|
||||||
|
<use href="#p" x="28" y="70" />
|
||||||
|
<use href="#p" x="56" y="70" />
|
||||||
|
<use href="#p" x="84" y="70" />
|
||||||
|
<use href="#p" x="28" y="84" />
|
||||||
|
<use href="#p" x="56" y="84" />
|
||||||
<use href="#p" x="84" y="84" />
|
<use href="#p" x="84" y="84" />
|
||||||
<use href="#p" x="96" y="84" />
|
<use href="#p" x="28" y="98" />
|
||||||
<use href="#p" x="84" y="96" />
|
<use href="#p" x="56" y="98" />
|
||||||
<use href="#p" x="96" y="96" />
|
<use href="#p" x="84" y="98" />
|
||||||
<use href="#p" x="84" y="108" />
|
<use href="#p" x="28" y="112" />
|
||||||
<use href="#p" x="96" y="108" />
|
<use href="#p" x="56" y="112" />
|
||||||
<use href="#p" x="84" y="120" />
|
<use href="#p" x="84" y="112" />
|
||||||
<use href="#p" x="96" y="120" />
|
<use href="#p" x="42" y="126" />
|
||||||
<use href="#p" x="84" y="132" />
|
<use href="#p" x="70" y="126" />
|
||||||
<use href="#p" x="96" y="132" />
|
<use href="#p" x="42" y="140" />
|
||||||
|
<use href="#p" x="70" y="140" />
|
||||||
<!-- 中间 V 形(从 y=60 开始) -->
|
<use href="#p" x="42" y="154" />
|
||||||
<use href="#p" x="24" y="60" />
|
<use href="#p" x="70" y="154" />
|
||||||
<use href="#p" x="48" y="60" />
|
<use href="#p" x="56" y="154" />
|
||||||
<use href="#p" x="72" y="60" />
|
<use href="#p" x="56" y="168" />
|
||||||
<use href="#p" x="24" y="72" />
|
|
||||||
<use href="#p" x="48" y="72" />
|
|
||||||
<use href="#p" x="72" y="72" />
|
|
||||||
<use href="#p" x="24" y="84" />
|
|
||||||
<use href="#p" x="48" y="84" />
|
|
||||||
<use href="#p" x="72" y="84" />
|
|
||||||
<use href="#p" x="24" y="96" />
|
|
||||||
<use href="#p" x="48" y="96" />
|
|
||||||
<use href="#p" x="72" y="96" />
|
|
||||||
<!-- 底部汇合 -->
|
|
||||||
<use href="#p" x="36" y="108" />
|
|
||||||
<use href="#p" x="60" y="108" />
|
|
||||||
<use href="#p" x="36" y="120" />
|
|
||||||
<use href="#p" x="60" y="120" />
|
|
||||||
<use href="#p" x="36" y="132" />
|
|
||||||
<use href="#p" x="60" y="132" />
|
|
||||||
<use href="#p" x="48" y="132" />
|
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== U(高度与 W 一致,内部空间增大) ===== -->
|
<!-- U -->
|
||||||
<g transform="translate(124, 0)">
|
<g transform="translate(144, 0)">
|
||||||
<!-- 左竖线(占2列) -->
|
|
||||||
<use href="#p" x="0" y="0" />
|
<use href="#p" x="0" y="0" />
|
||||||
<use href="#p" x="12" y="0" />
|
<use href="#p" x="14" y="0" />
|
||||||
<use href="#p" x="0" y="12" />
|
<use href="#p" x="0" y="14" />
|
||||||
<use href="#p" x="12" y="12" />
|
<use href="#p" x="14" y="14" />
|
||||||
<use href="#p" x="0" y="24" />
|
<use href="#p" x="0" y="28" />
|
||||||
<use href="#p" x="12" y="24" />
|
<use href="#p" x="14" y="28" />
|
||||||
<use href="#p" x="0" y="36" />
|
<use href="#p" x="0" y="42" />
|
||||||
<use href="#p" x="12" y="36" />
|
<use href="#p" x="14" y="42" />
|
||||||
<use href="#p" x="0" y="48" />
|
<use href="#p" x="0" y="56" />
|
||||||
<use href="#p" x="12" y="48" />
|
<use href="#p" x="14" y="56" />
|
||||||
<use href="#p" x="0" y="60" />
|
<use href="#p" x="0" y="70" />
|
||||||
<use href="#p" x="12" y="60" />
|
<use href="#p" x="14" y="70" />
|
||||||
<use href="#p" x="0" y="72" />
|
|
||||||
<use href="#p" x="12" y="72" />
|
|
||||||
<use href="#p" x="0" y="84" />
|
<use href="#p" x="0" y="84" />
|
||||||
<use href="#p" x="12" y="84" />
|
<use href="#p" x="14" y="84" />
|
||||||
<use href="#p" x="0" y="96" />
|
<use href="#p" x="0" y="98" />
|
||||||
<use href="#p" x="12" y="96" />
|
<use href="#p" x="14" y="98" />
|
||||||
<use href="#p" x="0" y="108" />
|
<use href="#p" x="0" y="112" />
|
||||||
<use href="#p" x="12" y="108" />
|
<use href="#p" x="14" y="112" />
|
||||||
<use href="#p" x="0" y="120" />
|
<use href="#p" x="0" y="126" />
|
||||||
<use href="#p" x="12" y="120" />
|
<use href="#p" x="14" y="126" />
|
||||||
<use href="#p" x="0" y="132" />
|
<use href="#p" x="0" y="140" />
|
||||||
<use href="#p" x="12" y="132" />
|
<use href="#p" x="14" y="140" />
|
||||||
|
<use href="#p" x="0" y="154" />
|
||||||
|
<use href="#p" x="14" y="154" />
|
||||||
|
<use href="#p" x="0" y="168" />
|
||||||
|
<use href="#p" x="14" y="168" />
|
||||||
|
<use href="#p" x="98" y="0" />
|
||||||
|
<use href="#p" x="112" y="0" />
|
||||||
|
<use href="#p" x="98" y="14" />
|
||||||
|
<use href="#p" x="112" y="14" />
|
||||||
|
<use href="#p" x="98" y="28" />
|
||||||
|
<use href="#p" x="112" y="28" />
|
||||||
|
<use href="#p" x="98" y="42" />
|
||||||
|
<use href="#p" x="112" y="42" />
|
||||||
|
<use href="#p" x="98" y="56" />
|
||||||
|
<use href="#p" x="112" y="56" />
|
||||||
|
<use href="#p" x="98" y="70" />
|
||||||
|
<use href="#p" x="112" y="70" />
|
||||||
|
<use href="#p" x="98" y="84" />
|
||||||
|
<use href="#p" x="112" y="84" />
|
||||||
|
<use href="#p" x="98" y="98" />
|
||||||
|
<use href="#p" x="112" y="98" />
|
||||||
|
<use href="#p" x="98" y="112" />
|
||||||
|
<use href="#p" x="112" y="112" />
|
||||||
|
<use href="#p" x="98" y="126" />
|
||||||
|
<use href="#p" x="112" y="126" />
|
||||||
|
<use href="#p" x="98" y="140" />
|
||||||
|
<use href="#p" x="112" y="140" />
|
||||||
|
<use href="#p" x="98" y="154" />
|
||||||
|
<use href="#p" x="112" y="154" />
|
||||||
|
<use href="#p" x="98" y="168" />
|
||||||
|
<use href="#p" x="112" y="168" />
|
||||||
|
<use href="#p" x="0" y="168" />
|
||||||
|
<use href="#p" x="14" y="168" />
|
||||||
|
<use href="#p" x="28" y="168" />
|
||||||
|
<use href="#p" x="42" y="168" />
|
||||||
|
<use href="#p" x="56" y="168" />
|
||||||
|
<use href="#p" x="70" y="168" />
|
||||||
|
<use href="#p" x="84" y="168" />
|
||||||
|
<use href="#p" x="98" y="168" />
|
||||||
|
<use href="#p" x="112" y="168" />
|
||||||
|
|
||||||
<!-- 右竖线(占2列) -->
|
<!-- ===== frpc(修正版:f 有完整竖线) ===== -->
|
||||||
<use href="#p" x="84" y="0" />
|
<g transform="translate(49, 20)">
|
||||||
<use href="#p" x="96" y="0" />
|
<!--
|
||||||
<use href="#p" x="84" y="12" />
|
每个字母高度约 28px(4行×7px)
|
||||||
<use href="#p" x="96" y="12" />
|
间距:字母之间空 14px(2行×7px)
|
||||||
<use href="#p" x="84" y="24" />
|
f: y=0-27(竖线4行)
|
||||||
<use href="#p" x="96" y="24" />
|
r: y=41-62
|
||||||
<use href="#p" x="84" y="36" />
|
p: y=76-97
|
||||||
<use href="#p" x="96" y="36" />
|
c: y=111-132
|
||||||
<use href="#p" x="84" y="48" />
|
-->
|
||||||
<use href="#p" x="96" y="48" />
|
|
||||||
<use href="#p" x="84" y="60" />
|
|
||||||
<use href="#p" x="96" y="60" />
|
|
||||||
<use href="#p" x="84" y="72" />
|
|
||||||
<use href="#p" x="96" y="72" />
|
|
||||||
<use href="#p" x="84" y="84" />
|
|
||||||
<use href="#p" x="96" y="84" />
|
|
||||||
<use href="#p" x="84" y="96" />
|
|
||||||
<use href="#p" x="96" y="96" />
|
|
||||||
<use href="#p" x="84" y="108" />
|
|
||||||
<use href="#p" x="96" y="108" />
|
|
||||||
<use href="#p" x="84" y="120" />
|
|
||||||
<use href="#p" x="96" y="120" />
|
|
||||||
<use href="#p" x="84" y="132" />
|
|
||||||
<use href="#p" x="96" y="132" />
|
|
||||||
|
|
||||||
<!-- 底部横线 -->
|
<!-- ===== f:竖线(完整4行)+ 顶横 + 中横 ===== -->
|
||||||
<use href="#p" x="0" y="132" />
|
<!-- 竖线(左侧,从顶到底连续4行) -->
|
||||||
<use href="#p" x="12" y="132" />
|
|
||||||
<use href="#p" x="24" y="132" />
|
|
||||||
<use href="#p" x="36" y="132" />
|
|
||||||
<use href="#p" x="48" y="132" />
|
|
||||||
<use href="#p" x="60" y="132" />
|
|
||||||
<use href="#p" x="72" y="132" />
|
|
||||||
<use href="#p" x="84" y="132" />
|
|
||||||
<use href="#p" x="96" y="132" />
|
|
||||||
|
|
||||||
<!-- ===== U 内部:竖向排列的 frpc(蓝色,5×5 像素) ===== -->
|
|
||||||
<g transform="translate(32, 18)">
|
|
||||||
<!-- f -->
|
|
||||||
<use href="#q" x="0" y="0" />
|
<use href="#q" x="0" y="0" />
|
||||||
<use href="#q" x="0" y="6" />
|
<use href="#q" x="0" y="7" />
|
||||||
<use href="#q" x="0" y="12" />
|
<use href="#q" x="0" y="14" />
|
||||||
<use href="#q" x="0" y="18" />
|
<use href="#q" x="0" y="21" />
|
||||||
<use href="#q" x="6" y="0" />
|
<!-- 顶横(向右延伸) -->
|
||||||
<use href="#q" x="12" y="0" />
|
<use href="#q" x="7" y="0" />
|
||||||
<use href="#q" x="18" y="0" />
|
<use href="#q" x="14" y="0" />
|
||||||
<use href="#q" x="6" y="12" />
|
<!-- 中横(向右延伸) -->
|
||||||
<use href="#q" x="12" y="12" />
|
<use href="#q" x="7" y="14" />
|
||||||
|
<use href="#q" x="14" y="14" />
|
||||||
|
|
||||||
<!-- r -->
|
<!-- ===== r:竖线 + 顶横(一竖一横) ===== -->
|
||||||
<use href="#q" x="0" y="28" />
|
<!-- 竖线(左侧,3行) -->
|
||||||
<use href="#q" x="0" y="34" />
|
<use href="#q" x="0" y="41" />
|
||||||
<use href="#q" x="0" y="40" />
|
<use href="#q" x="0" y="48" />
|
||||||
<use href="#q" x="0" y="46" />
|
<use href="#q" x="0" y="55" />
|
||||||
<use href="#q" x="6" y="28" />
|
<!-- 顶横(向右延伸) -->
|
||||||
<use href="#q" x="12" y="28" />
|
<use href="#q" x="7" y="41" />
|
||||||
<use href="#q" x="18" y="28" />
|
<use href="#q" x="14" y="41" />
|
||||||
<use href="#q" x="6" y="40" />
|
|
||||||
<use href="#q" x="12" y="40" />
|
|
||||||
|
|
||||||
<!-- p -->
|
<!-- ===== p:竖线(向下伸出)+ 右侧圆圈 ===== -->
|
||||||
<use href="#q" x="0" y="56" />
|
<!-- 竖线(左侧,向下伸出5行) -->
|
||||||
<use href="#q" x="0" y="62" />
|
<use href="#q" x="0" y="76" />
|
||||||
<use href="#q" x="0" y="68" />
|
<use href="#q" x="0" y="83" />
|
||||||
<use href="#q" x="0" y="74" />
|
<use href="#q" x="0" y="90" />
|
||||||
<use href="#q" x="0" y="80" />
|
<use href="#q" x="0" y="97" />
|
||||||
<use href="#q" x="6" y="56" />
|
<!-- 圆圈(右侧3×3闭合) -->
|
||||||
<use href="#q" x="12" y="56" />
|
<use href="#q" x="7" y="76" />
|
||||||
<use href="#q" x="18" y="56" />
|
<use href="#q" x="14" y="76" />
|
||||||
<use href="#q" x="18" y="62" />
|
<use href="#q" x="14" y="83" />
|
||||||
<use href="#q" x="18" y="68" />
|
<use href="#q" x="14" y="90" />
|
||||||
<use href="#q" x="6" y="74" />
|
<use href="#q" x="7" y="90" />
|
||||||
<use href="#q" x="12" y="74" />
|
<use href="#q" x="14" y="90" />
|
||||||
<use href="#q" x="18" y="74" />
|
|
||||||
|
|
||||||
<!-- c -->
|
<!-- ===== c:开口朝右的弧形(左侧半圆) ===== -->
|
||||||
<use href="#q" x="6" y="90" />
|
<!-- 左竖 -->
|
||||||
<use href="#q" x="12" y="90" />
|
<use href="#q" x="0" y="111" />
|
||||||
<use href="#q" x="18" y="90" />
|
<use href="#q" x="0" y="118" />
|
||||||
<use href="#q" x="0" y="96" />
|
<use href="#q" x="0" y="125" />
|
||||||
<use href="#q" x="0" y="102" />
|
<!-- 上弧 -->
|
||||||
<use href="#q" x="6" y="108" />
|
<use href="#q" x="7" y="111" />
|
||||||
<use href="#q" x="12" y="108" />
|
<use href="#q" x="14" y="111" />
|
||||||
<use href="#q" x="18" y="108" />
|
<!-- 下弧 -->
|
||||||
|
<use href="#q" x="7" y="125" />
|
||||||
|
<use href="#q" x="14" y="125" />
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ===== I(中间竖线 2 列宽,高度一致) ===== -->
|
<!-- I -->
|
||||||
<g transform="translate(248, 0)">
|
<g transform="translate(288, 0)">
|
||||||
<!-- 顶部横线(占6列) -->
|
|
||||||
<use href="#p" x="0" y="0" />
|
<use href="#p" x="0" y="0" />
|
||||||
<use href="#p" x="12" y="0" />
|
<use href="#p" x="14" y="0" />
|
||||||
<use href="#p" x="24" y="0" />
|
<use href="#p" x="28" y="0" />
|
||||||
<use href="#p" x="36" y="0" />
|
<use href="#p" x="42" y="0" />
|
||||||
<use href="#p" x="48" y="0" />
|
<use href="#p" x="56" y="0" />
|
||||||
<use href="#p" x="60" y="0" />
|
<use href="#p" x="70" y="0" />
|
||||||
|
<use href="#p" x="28" y="14" />
|
||||||
<!-- 中间竖线(2 列宽) -->
|
<use href="#p" x="42" y="14" />
|
||||||
<use href="#p" x="24" y="12" />
|
<use href="#p" x="28" y="28" />
|
||||||
<use href="#p" x="36" y="12" />
|
<use href="#p" x="42" y="28" />
|
||||||
<use href="#p" x="24" y="24" />
|
<use href="#p" x="28" y="42" />
|
||||||
<use href="#p" x="36" y="24" />
|
<use href="#p" x="42" y="42" />
|
||||||
<use href="#p" x="24" y="36" />
|
<use href="#p" x="28" y="56" />
|
||||||
<use href="#p" x="36" y="36" />
|
<use href="#p" x="42" y="56" />
|
||||||
<use href="#p" x="24" y="48" />
|
<use href="#p" x="28" y="70" />
|
||||||
<use href="#p" x="36" y="48" />
|
<use href="#p" x="42" y="70" />
|
||||||
<use href="#p" x="24" y="60" />
|
<use href="#p" x="28" y="84" />
|
||||||
<use href="#p" x="36" y="60" />
|
<use href="#p" x="42" y="84" />
|
||||||
<use href="#p" x="24" y="72" />
|
<use href="#p" x="28" y="98" />
|
||||||
<use href="#p" x="36" y="72" />
|
<use href="#p" x="42" y="98" />
|
||||||
<use href="#p" x="24" y="84" />
|
<use href="#p" x="28" y="112" />
|
||||||
<use href="#p" x="36" y="84" />
|
<use href="#p" x="42" y="112" />
|
||||||
<use href="#p" x="24" y="96" />
|
<use href="#p" x="28" y="126" />
|
||||||
<use href="#p" x="36" y="96" />
|
<use href="#p" x="42" y="126" />
|
||||||
<use href="#p" x="24" y="108" />
|
<use href="#p" x="28" y="140" />
|
||||||
<use href="#p" x="36" y="108" />
|
<use href="#p" x="42" y="140" />
|
||||||
<use href="#p" x="24" y="120" />
|
<use href="#p" x="28" y="154" />
|
||||||
<use href="#p" x="36" y="120" />
|
<use href="#p" x="42" y="154" />
|
||||||
|
<use href="#p" x="0" y="168" />
|
||||||
<!-- 底部横线(占6列) -->
|
<use href="#p" x="14" y="168" />
|
||||||
<use href="#p" x="0" y="132" />
|
<use href="#p" x="28" y="168" />
|
||||||
<use href="#p" x="12" y="132" />
|
<use href="#p" x="42" y="168" />
|
||||||
<use href="#p" x="24" y="132" />
|
<use href="#p" x="56" y="168" />
|
||||||
<use href="#p" x="36" y="132" />
|
<use href="#p" x="70" y="168" />
|
||||||
<use href="#p" x="48" y="132" />
|
|
||||||
<use href="#p" x="60" y="132" />
|
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- ========== 外围装饰元素(像素风,增加层次) ========== -->
|
<!-- ========== 外围装饰 ========== -->
|
||||||
<!-- 左上角小十字星 -->
|
|
||||||
<g transform="translate(40, 40)">
|
<g transform="translate(40, 40)">
|
||||||
<use href="#s" x="0" y="0" />
|
<use href="#s" x="0" y="0" />
|
||||||
<use href="#s" x="0" y="12" />
|
<use href="#s" x="0" y="12" />
|
||||||
@ -259,11 +259,8 @@
|
|||||||
<use href="#s" x="12" y="0" />
|
<use href="#s" x="12" y="0" />
|
||||||
<use href="#s" x="12" y="12" />
|
<use href="#s" x="12" y="12" />
|
||||||
</g>
|
</g>
|
||||||
<!-- 右上角小方块 -->
|
|
||||||
<use href="#d" x="440" y="40" />
|
<use href="#d" x="440" y="40" />
|
||||||
<!-- 左下角小方块 -->
|
|
||||||
<use href="#d" x="40" y="440" />
|
<use href="#d" x="40" y="440" />
|
||||||
<!-- 右下角小十字星 -->
|
|
||||||
<g transform="translate(436, 436)">
|
<g transform="translate(436, 436)">
|
||||||
<use href="#s" x="0" y="0" />
|
<use href="#s" x="0" y="0" />
|
||||||
<use href="#s" x="0" y="12" />
|
<use href="#s" x="0" y="12" />
|
||||||
@ -272,12 +269,13 @@
|
|||||||
<use href="#s" x="12" y="12" />
|
<use href="#s" x="12" y="12" />
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- 三个随机黑色装饰点(更大一点,12×12) -->
|
<!-- 黑色装饰点 -->
|
||||||
<rect x="90" y="380" width="12" height="12" fill="#000000" />
|
<rect x="90" y="380" width="14" height="14" fill="#000000" />
|
||||||
<rect x="420" y="140" width="12" height="12" fill="#000000" />
|
<rect x="420" y="140" width="14" height="14" fill="#000000" />
|
||||||
<rect x="240" y="60" width="12" height="12" fill="#000000" />
|
<rect x="230" y="50" width="14" height="14" fill="#000000" />
|
||||||
|
|
||||||
<!-- 额外小点缀:蓝色小点,呼应 frpc 颜色 -->
|
<!-- 蓝色小点缀 -->
|
||||||
<rect x="180" y="400" width="6" height="6" fill="#2563eb" />
|
<rect x="180" y="400" width="8" height="8" fill="#2563eb" />
|
||||||
<rect x="370" y="80" width="6" height="6" fill="#2563eb" />
|
<rect x="370" y="80" width="8" height="8" fill="#2563eb" />
|
||||||
|
<rect x="50" y="250" width="8" height="8" fill="#2563eb" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
101
web/src/App.vue
101
web/src/App.vue
@ -10,7 +10,8 @@
|
|||||||
<i class="blob b1"></i><i class="blob b2"></i><i class="blob b3"></i>
|
<i class="blob b1"></i><i class="blob b2"></i><i class="blob b3"></i>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="app-shell">
|
<LoginView v-if="authReady && !isAuthed" />
|
||||||
|
<div v-else-if="authReady" class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sb-brand">
|
<div class="sb-brand">
|
||||||
<img class="sb-logo" src="/favicon.svg" alt="webui4frpc" />
|
<img class="sb-logo" src="/favicon.svg" alt="webui4frpc" />
|
||||||
@ -37,11 +38,11 @@
|
|||||||
<div class="sb-identity" v-if="authReady">
|
<div class="sb-identity" v-if="authReady">
|
||||||
<span class="id-name">{{ authName || '匿名' }}</span>
|
<span class="id-name">{{ authName || '匿名' }}</span>
|
||||||
<span class="id-level" :class="authLevel">{{ levelLabel }}</span>
|
<span class="id-level" :class="authLevel">{{ levelLabel }}</span>
|
||||||
|
<button class="sb-logout" title="退出登录" @click="onLogout">退出</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sb-theme" role="group" aria-label="主题">
|
<div class="sb-theme" role="group" aria-label="主题">
|
||||||
<button class="sw white" :class="{ on: theme === 'white' }" title="白色" @click="setTheme('white')" />
|
<button class="sw white" :class="{ on: theme === 'white' }" title="白色" @click="setTheme('white')" />
|
||||||
<button class="sw blue" :class="{ on: theme === 'blue' }" title="蓝色" @click="setTheme('blue')" />
|
<button class="sw blue" :class="{ on: theme === 'blue' }" title="蓝色" @click="setTheme('blue')" />
|
||||||
<button class="sw pink" :class="{ on: theme === 'pink' }" title="粉色" @click="setTheme('pink')" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -59,31 +60,45 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="boot">加载中…</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import CanvasView from './views/CanvasView.vue'
|
import CanvasView from './views/CanvasView.vue'
|
||||||
import SettingsView from './views/SettingsView.vue'
|
import SettingsView from './views/SettingsView.vue'
|
||||||
import StatusView from './views/StatusView.vue'
|
import StatusView from './views/StatusView.vue'
|
||||||
import ClusterView from './views/ClusterView.vue'
|
import ClusterView from './views/ClusterView.vue'
|
||||||
import UsersView from './views/UsersView.vue'
|
import UsersView from './views/UsersView.vue'
|
||||||
import { authLevel, authName, authReady, fetchMe, isAdmin } from './auth'
|
import LoginView from './views/LoginView.vue'
|
||||||
|
import { authLevel, authName, authReady, canWrite, fetchMe, isAuthed, isSuperAdmin, logout } from './auth'
|
||||||
|
|
||||||
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster' | 'users'
|
type ViewKey = 'canvas' | 'settings' | 'status' | 'cluster' | 'users'
|
||||||
const view = ref<ViewKey>('status')
|
const view = ref<ViewKey>('status')
|
||||||
|
|
||||||
onMounted(() => { fetchMe() })
|
onMounted(() => { fetchMe() })
|
||||||
|
|
||||||
const levelLabel = computed(() =>
|
// logout clears the session cookie and drops back to the login page.
|
||||||
authLevel.value === 'admin' ? '管理员' : authLevel.value === 'write' ? '写' : '只读',
|
async function onLogout() {
|
||||||
)
|
await logout()
|
||||||
|
view.value = 'status'
|
||||||
|
}
|
||||||
|
|
||||||
// ---- theme switcher (white default / blue / pink) ----
|
const levelLabel = computed(() => {
|
||||||
|
switch (authLevel.value) {
|
||||||
|
case 'superadmin': return '超级管理员'
|
||||||
|
case 'admin': return '管理员'
|
||||||
|
case 'write': return '写'
|
||||||
|
default: return '只读'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- theme switcher (white default / blue) ----
|
||||||
// The data-theme attribute is applied pre-mount by main.ts (no flash); here we
|
// The data-theme attribute is applied pre-mount by main.ts (no flash); here we
|
||||||
// only mirror it so the active swatch highlights, and update it on click.
|
// only mirror it so the active swatch highlights, and update it on click.
|
||||||
type Theme = 'white' | 'blue' | 'pink'
|
type Theme = 'white' | 'blue'
|
||||||
const VALID_THEMES: Theme[] = ['white', 'blue', 'pink']
|
const VALID_THEMES: Theme[] = ['white', 'blue']
|
||||||
const theme = ref<Theme>(
|
const theme = ref<Theme>(
|
||||||
(VALID_THEMES as string[]).includes(document.documentElement.getAttribute('data-theme') || '')
|
(VALID_THEMES as string[]).includes(document.documentElement.getAttribute('data-theme') || '')
|
||||||
? (document.documentElement.getAttribute('data-theme') as Theme)
|
? (document.documentElement.getAttribute('data-theme') as Theme)
|
||||||
@ -95,20 +110,32 @@ const setTheme = (t: Theme) => {
|
|||||||
localStorage.setItem('w4f-theme', t)
|
localStorage.setItem('w4f-theme', t)
|
||||||
}
|
}
|
||||||
|
|
||||||
const allNav: { key: ViewKey; label: string; icon: string; adminOnly?: boolean }[] = [
|
const allNav: { key: ViewKey; label: string; icon: string; writeOnly?: boolean; superAdminOnly?: boolean }[] = [
|
||||||
{ key: 'status', label: '状态', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>' },
|
{ key: 'status', label: '状态', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>' },
|
||||||
{ key: 'canvas', label: '连接配置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5 16 16M15.5 7.5 8 16"/></svg>' },
|
{ key: 'canvas', label: '连接配置', writeOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="12" cy="18" r="3"/><path d="M8.5 7.5 16 16M15.5 7.5 8 16"/></svg>' },
|
||||||
{ key: 'settings', label: '设置', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
|
{ key: 'settings', label: '设置', writeOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>' },
|
||||||
{ key: 'cluster', label: '集群', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4m0 0-5 6m5-6 5 6"/></svg>' },
|
{ key: 'cluster', label: '集群', icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><path d="M12 7v4m0 0-5 6m5-6 5 6"/></svg>' },
|
||||||
{ key: 'users', label: '账号与密钥', adminOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
|
{ key: 'users', label: '账号与密钥', superAdminOnly: true, icon: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// nav filters admin-only entries (账号与密钥) until /me resolves to admin.
|
// nav is filtered by role:
|
||||||
// Until authReady, the users entry is hidden so a viewer never sees it flash.
|
// - canvas/settings need write (viewers/auditors never see them),
|
||||||
|
// - users needs superadmin (ordinary admins cannot manage accounts),
|
||||||
|
// - status + cluster are visible to everyone (auditors view + export logs).
|
||||||
const nav = computed(() =>
|
const nav = computed(() =>
|
||||||
allNav.filter((n) => !n.adminOnly || (authReady.value && isAdmin.value)),
|
allNav.filter((n) => {
|
||||||
|
if (n.superAdminOnly) return authReady.value && isSuperAdmin.value
|
||||||
|
if (n.writeOnly) return canWrite.value
|
||||||
|
return true
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// When the account level drops a page (e.g. a viewer logs in while the canvas
|
||||||
|
// was open), fall back to the first visible page so we never render a hidden one.
|
||||||
|
watch(nav, (items) => {
|
||||||
|
if (!items.some((n) => n.key === view.value)) view.value = 'status'
|
||||||
|
})
|
||||||
|
|
||||||
const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?.label ?? '')
|
const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?.label ?? '')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -221,8 +248,8 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
|
|||||||
.sb-identity {
|
.sb-identity {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@ -231,6 +258,7 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
|
|||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
.sb-identity .id-name {
|
.sb-identity .id-name {
|
||||||
|
margin-right: auto;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--w4f-fg);
|
color: var(--w4f-fg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -250,6 +278,10 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
|
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
|
||||||
}
|
}
|
||||||
|
.sb-identity .id-level.superadmin {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--w4f-secondary), var(--w4f-primary));
|
||||||
|
}
|
||||||
.sb-identity .id-level.write {
|
.sb-identity .id-level.write {
|
||||||
color: var(--w4f-warning);
|
color: var(--w4f-warning);
|
||||||
background: var(--w4f-warning-50);
|
background: var(--w4f-warning-50);
|
||||||
@ -257,6 +289,36 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
|
|||||||
.sb-identity .id-level.read {
|
.sb-identity .id-level.read {
|
||||||
color: var(--w4f-muted);
|
color: var(--w4f-muted);
|
||||||
}
|
}
|
||||||
|
.sb-logout {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--w4f-line-strong);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--w4f-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s var(--w4f-ease), border-color 0.15s var(--w4f-ease),
|
||||||
|
background 0.15s var(--w4f-ease);
|
||||||
|
}
|
||||||
|
.sb-logout:hover {
|
||||||
|
color: var(--w4f-danger);
|
||||||
|
border-color: var(--w4f-danger);
|
||||||
|
background: var(--w4f-danger-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- boot splash (shown while /me resolves) ---------- */
|
||||||
|
.boot {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--w4f-muted);
|
||||||
|
font-family: var(--w4f-font);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- theme switcher ---------- */
|
/* ---------- theme switcher ---------- */
|
||||||
.sb-theme { display: flex; gap: 6px; margin-bottom: 12px; }
|
.sb-theme { display: flex; gap: 6px; margin-bottom: 12px; }
|
||||||
@ -270,7 +332,6 @@ const currentLabel = computed(() => nav.value.find((n) => n.key === view.value)?
|
|||||||
/* swatch fills are FIXED (represent each theme's identity), not theme-driven */
|
/* swatch fills are FIXED (represent each theme's identity), not theme-driven */
|
||||||
.sb-theme .sw.white { background: linear-gradient(135deg, #ffffff, #cdd4e0); }
|
.sb-theme .sw.white { background: linear-gradient(135deg, #ffffff, #cdd4e0); }
|
||||||
.sb-theme .sw.blue { background: linear-gradient(135deg, #3b82f6, #06b6d4); }
|
.sb-theme .sw.blue { background: linear-gradient(135deg, #3b82f6, #06b6d4); }
|
||||||
.sb-theme .sw.pink { background: linear-gradient(135deg, #ff7fac, #f33b7c); }
|
|
||||||
|
|
||||||
/* ---------- main ---------- */
|
/* ---------- main ---------- */
|
||||||
.main {
|
.main {
|
||||||
|
|||||||
@ -25,10 +25,16 @@ class HTTPError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X-W4F-UI marks every SPA request. The server treats these as session-cookie
|
||||||
|
// only and ignores Basic/Bearer headers — including any Basic credentials the
|
||||||
|
// browser cached before sessions existed — so logout actually sticks.
|
||||||
|
const UI_HEADER = { "X-W4F-UI": "1" } as const;
|
||||||
|
|
||||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
...options,
|
...options,
|
||||||
|
headers: { ...UI_HEADER, ...(options.headers as Record<string, string> | undefined) },
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new HTTPError(response.status, `HTTP ${response.status}`);
|
throw new HTTPError(response.status, `HTTP ${response.status}`);
|
||||||
@ -167,14 +173,24 @@ export const api = {
|
|||||||
|
|
||||||
// M7 auth/accounts/API keys/canvas export-import/worker logs.
|
// M7 auth/accounts/API keys/canvas export-import/worker logs.
|
||||||
me: () => request<MeResp>("/api/manager/me"),
|
me: () => request<MeResp>("/api/manager/me"),
|
||||||
|
// UI session login/logout. login() sets an HttpOnly session cookie (Set-Cookie
|
||||||
|
// on the 200 response) so the SPA stops using Basic Auth; logout clears it.
|
||||||
|
login: (username: string, password: string) =>
|
||||||
|
request<MeResp>("/api/manager/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
}),
|
||||||
|
logout: () =>
|
||||||
|
request<{ ok: boolean }>("/api/manager/logout", { method: "POST" }),
|
||||||
listUsers: () => request<{ users: User[] }>("/api/manager/users"),
|
listUsers: () => request<{ users: User[] }>("/api/manager/users"),
|
||||||
createUser: (username: string, password: string, role: 'admin' | 'viewer') =>
|
createUser: (username: string, password: string, role: 'admin' | 'viewer' | 'superadmin') =>
|
||||||
request<User>("/api/manager/users", {
|
request<User>("/api/manager/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password, role }),
|
body: JSON.stringify({ username, password, role }),
|
||||||
}),
|
}),
|
||||||
updateUser: (name: string, patch: { password?: string; role?: 'admin' | 'viewer'; enabled?: boolean }) =>
|
updateUser: (name: string, patch: { password?: string; role?: 'admin' | 'viewer' | 'superadmin'; enabled?: boolean }) =>
|
||||||
request<User>(`/api/manager/users/${encodeURIComponent(name)}`, {
|
request<User>(`/api/manager/users/${encodeURIComponent(name)}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
// Lightweight reactive auth principal for the SPA. The browser handles Basic
|
// Lightweight reactive auth principal for the SPA. Since the switch to
|
||||||
// Auth (prompted once when the SPA shell loads behind auth("read")); this just
|
// session-cookie login (POST /api/manager/login), the SPA no longer relies on
|
||||||
// mirrors the resolved identity from GET /me so views can gate their UI.
|
// the browser's Basic Auth: on mount we resolve GET /me — a session cookie
|
||||||
|
// makes it 200 (signed in); otherwise it is 401 and the login page shows.
|
||||||
|
// logout() clears the cookie server-side, so sign-out actually sticks.
|
||||||
//
|
//
|
||||||
// We default to read until /me resolves so a viewer never sees a flash of
|
// We default to read until /me resolves so a viewer never sees a flash of
|
||||||
// admin controls. canWrite/isAdmin are memoized over the reactive level.
|
// admin controls. canWrite/isAdmin are memoized over the reactive level.
|
||||||
@ -11,11 +13,37 @@ import { api } from './api'
|
|||||||
export const authLevel = ref<AccessLevel>('read')
|
export const authLevel = ref<AccessLevel>('read')
|
||||||
export const authName = ref('')
|
export const authName = ref('')
|
||||||
export const authReady = ref(false)
|
export const authReady = ref(false)
|
||||||
|
export const isAuthed = ref(false)
|
||||||
|
|
||||||
const RANK: Record<AccessLevel, number> = { read: 1, write: 2, admin: 3 }
|
const RANK: Record<AccessLevel, number> = { read: 1, write: 2, admin: 3, superadmin: 4 }
|
||||||
|
|
||||||
export const canWrite = computed(() => RANK[authLevel.value] >= RANK.write)
|
export const canWrite = computed(() => RANK[authLevel.value] >= RANK.write)
|
||||||
|
// admin: full write access but cannot manage accounts (that needs superadmin).
|
||||||
export const isAdmin = computed(() => authLevel.value === 'admin')
|
export const isAdmin = computed(() => authLevel.value === 'admin')
|
||||||
|
// superadmin: the only tier that can manage accounts (users/apikeys routes).
|
||||||
|
export const isSuperAdmin = computed(() => authLevel.value === 'superadmin')
|
||||||
|
|
||||||
|
// login signs in via the session-cookie endpoint and mirrors the resolved
|
||||||
|
// principal. Call from the login page; on success App.vue drops into the shell.
|
||||||
|
export async function login(username: string, password: string): Promise<void> {
|
||||||
|
const me = await api.login(username, password)
|
||||||
|
authLevel.value = me.level
|
||||||
|
authName.value = me.name
|
||||||
|
isAuthed.value = true
|
||||||
|
authReady.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// logout clears the UI session and returns to the login page.
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await api.logout()
|
||||||
|
} catch {
|
||||||
|
// session cookie may already be gone; still reset local state
|
||||||
|
}
|
||||||
|
authLevel.value = 'read'
|
||||||
|
authName.value = ''
|
||||||
|
isAuthed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// fetchMe resolves the current principal. Called once on app mount; safe to
|
// fetchMe resolves the current principal. Called once on app mount; safe to
|
||||||
// call again after account changes that could affect the live session.
|
// call again after account changes that could affect the live session.
|
||||||
@ -24,10 +52,12 @@ export async function fetchMe(): Promise<void> {
|
|||||||
const me = await api.me()
|
const me = await api.me()
|
||||||
authLevel.value = me.level
|
authLevel.value = me.level
|
||||||
authName.value = me.name
|
authName.value = me.name
|
||||||
|
isAuthed.value = true
|
||||||
} catch {
|
} catch {
|
||||||
// 401 (no/failed auth) — stay read-only; the browser will have prompted.
|
// 401 (no/failed auth) — show the login page.
|
||||||
authLevel.value = 'read'
|
authLevel.value = 'read'
|
||||||
authName.value = ''
|
authName.value = ''
|
||||||
|
isAuthed.value = false
|
||||||
} finally {
|
} finally {
|
||||||
authReady.value = true
|
authReady.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
<!-- source handle (right) connects to remote -->
|
<!-- source handle (right) connects to remote -->
|
||||||
<Handle type="source" :position="Position.Right" />
|
<Handle type="source" :position="Position.Right" />
|
||||||
|
|
||||||
|
<fieldset class="node-fields" :disabled="!canWrite">
|
||||||
<div class="node-head">
|
<div class="node-head">
|
||||||
<span class="node-icon">⬢</span>
|
<span class="node-icon">⬢</span>
|
||||||
<input v-model="nameField" class="name-input" />
|
<input v-model="nameField" class="name-input" />
|
||||||
@ -29,14 +30,15 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="adv-check">
|
<label class="adv-check">
|
||||||
<input v-model="localOnlyField" type="checkbox" /> 仅本机转发
|
<input v-model="localOnlyField" type="checkbox" /> 仅本机转发
|
||||||
<span class="hint">(勾选=直接在本机拉起,不进集群;不勾选=127.0.0.1 替换为本机实际地址交给集群)</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="adv">
|
<div class="adv">
|
||||||
<button class="adv-toggle" @click.stop="showAdv = !showAdv">
|
<button class="adv-toggle" @click.stop="showAdv = !showAdv">
|
||||||
{{ showAdv ? '▾ 收起高级' : '▸ 高级' }}
|
{{ showAdv ? '▾ 收起高级' : '▸ 高级' }}
|
||||||
</button>
|
</button>
|
||||||
|
<fieldset class="node-fields" :disabled="!canWrite">
|
||||||
<div v-if="showAdv" class="adv-body">
|
<div v-if="showAdv" class="adv-body">
|
||||||
<label class="adv-check">
|
<label class="adv-check">
|
||||||
<input v-model="useEncryptionField" type="checkbox" /> 加密
|
<input v-model="useEncryptionField" type="checkbox" /> 加密
|
||||||
@ -78,6 +80,7 @@
|
|||||||
<label>失败次数 <input v-model.number="healthCheckMaxFailedField" type="number" min="0" placeholder="默认1" /></label>
|
<label>失败次数 <input v-model.number="healthCheckMaxFailedField" type="number" min="0" placeholder="默认1" /></label>
|
||||||
<label>间隔(秒) <input v-model.number="healthCheckIntervalField" type="number" min="0" placeholder="默认10" /></label>
|
<label>间隔(秒) <input v-model.number="healthCheckIntervalField" type="number" min="0" placeholder="默认10" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -85,6 +88,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Handle, Position } from '@vue-flow/core'
|
import { Handle, Position } from '@vue-flow/core'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { canWrite } from '../auth'
|
||||||
import type { CanvasLocal } from '../types'
|
import type { CanvasLocal } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -194,6 +198,16 @@ const httpHeadersField = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
// Read-only wrapper: fieldset disabled kills every form control inside
|
||||||
|
// (inputs/selects/checkboxes + the delete button) with one switch. The
|
||||||
|
// advanced-panel toggle sits outside so viewers can still inspect settings.
|
||||||
|
.node-fields {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.local-node {
|
.local-node {
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
border-radius: 14px 14px 14px 4px;
|
border-radius: 14px 14px 14px 4px;
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
<div
|
<div
|
||||||
ref="labelEl"
|
ref="labelEl"
|
||||||
class="port-label"
|
class="port-label"
|
||||||
:class="{ dragging }"
|
:class="{ dragging, readonly: !canWrite }"
|
||||||
:style="labelStyle"
|
:style="labelStyle"
|
||||||
@pointerdown.stop.prevent="onPointerDown"
|
@pointerdown.stop.prevent="onPointerDown"
|
||||||
@pointermove="onPointerMove"
|
@pointermove="onPointerMove"
|
||||||
@ -25,14 +25,14 @@
|
|||||||
class="port-toggle"
|
class="port-toggle"
|
||||||
:class="{ off: isDisabled }"
|
:class="{ off: isDisabled }"
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
@click.stop="emit('toggle-disabled', { edgeId: props.id })"
|
@click.stop="canWrite && emit('toggle-disabled', { edgeId: props.id })"
|
||||||
>{{ isDisabled ? '禁' : '通' }}</span>
|
>{{ isDisabled ? '禁' : '通' }}</span>
|
||||||
<span class="port-num">{{ displayPort }}</span>
|
<span class="port-num">{{ displayPort }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="groupName"
|
v-if="groupName"
|
||||||
class="port-group"
|
class="port-group"
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
@click.stop="emit('edit-group', { edgeId: props.id })"
|
@click.stop="canWrite && emit('edit-group', { edgeId: props.id })"
|
||||||
>{{ groupName }}</span>
|
>{{ groupName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</EdgeLabelRenderer>
|
</EdgeLabelRenderer>
|
||||||
@ -43,6 +43,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { EdgeLabelRenderer } from '@vue-flow/core'
|
import { EdgeLabelRenderer } from '@vue-flow/core'
|
||||||
import type { EdgeProps } from '@vue-flow/core'
|
import type { EdgeProps } from '@vue-flow/core'
|
||||||
|
import { canWrite } from '../auth'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<EdgeProps & { conflicted?: boolean }>(),
|
defineProps<EdgeProps & { conflicted?: boolean }>(),
|
||||||
@ -84,6 +85,8 @@ let dragging = false
|
|||||||
const onPointerDown = (e: PointerEvent) => {
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
// Never let this press reach the Vue Flow pane (which would pan the canvas).
|
// Never let this press reach the Vue Flow pane (which would pan the canvas).
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
// Read-only accounts cannot drag/edit the label or the underlying link.
|
||||||
|
if (!canWrite.value) return
|
||||||
dragging = true
|
dragging = true
|
||||||
moved = false
|
moved = false
|
||||||
startX = e.clientX
|
startX = e.clientX
|
||||||
@ -351,6 +354,10 @@ function midpointOf(path: string): { x: number; y: number } {
|
|||||||
box-shadow: $shadow-md;
|
box-shadow: $shadow-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.readonly {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.port-toggle {
|
.port-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
<!-- target handle (left) receives connections from locals -->
|
<!-- target handle (left) receives connections from locals -->
|
||||||
<Handle type="target" :position="Position.Left" />
|
<Handle type="target" :position="Position.Left" />
|
||||||
|
|
||||||
|
<fieldset class="node-fields" :disabled="!canWrite">
|
||||||
<div class="node-head">
|
<div class="node-head">
|
||||||
<span class="node-icon">⬤</span>
|
<span class="node-icon">⬤</span>
|
||||||
<input v-model="nameField" class="name-input" />
|
<input v-model="nameField" class="name-input" />
|
||||||
@ -25,11 +26,13 @@
|
|||||||
<label>令牌 <input v-model="tokenField" /></label>
|
<label>令牌 <input v-model="tokenField" /></label>
|
||||||
<label>URL <input v-model="urlField" /></label>
|
<label>URL <input v-model="urlField" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<div class="adv">
|
<div class="adv">
|
||||||
<button class="adv-toggle" @click.stop="showAdv = !showAdv">
|
<button class="adv-toggle" @click.stop="showAdv = !showAdv">
|
||||||
{{ showAdv ? '▾ 收起高级' : '▸ 高级' }}
|
{{ showAdv ? '▾ 收起高级' : '▸ 高级' }}
|
||||||
</button>
|
</button>
|
||||||
|
<fieldset class="node-fields" :disabled="!canWrite">
|
||||||
<div v-if="showAdv" class="adv-body">
|
<div v-if="showAdv" class="adv-body">
|
||||||
<label
|
<label
|
||||||
>传输协议
|
>传输协议
|
||||||
@ -57,6 +60,7 @@
|
|||||||
<label>用户 <input v-model="adminUserField" placeholder="admin" /></label>
|
<label>用户 <input v-model="adminUserField" placeholder="admin" /></label>
|
||||||
<label>密码 <input v-model="adminPasswordField" type="password" placeholder="可选" /></label>
|
<label>密码 <input v-model="adminPasswordField" type="password" placeholder="可选" /></label>
|
||||||
</div>
|
</div>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -64,6 +68,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Handle, Position } from '@vue-flow/core'
|
import { Handle, Position } from '@vue-flow/core'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { canWrite } from '../auth'
|
||||||
import type { CanvasRemote } from '../types'
|
import type { CanvasRemote } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -104,6 +109,16 @@ const showAdv = ref(false)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
// Read-only wrapper: fieldset disabled kills every form control inside
|
||||||
|
// (inputs/selects/checkboxes + the delete button) with one switch. The
|
||||||
|
// advanced-panel toggle sits outside so viewers can still inspect settings.
|
||||||
|
.node-fields {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.remote-node {
|
.remote-node {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
border-radius: 14px 14px 4px 14px;
|
border-radius: 14px 14px 4px 14px;
|
||||||
@ -211,53 +226,78 @@ const showAdv = ref(false)
|
|||||||
flex: 0 0 64px;
|
flex: 0 0 64px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.adv {
|
// NOTE: .adv is a SIBLING of .fields (both children of .remote-node), not a
|
||||||
|
// descendant — it MUST NOT be nested under .fields in SCSS or the selector
|
||||||
|
// `.fields .adv .adv-body` never matches the DOM and the whole advanced body
|
||||||
|
// renders unstyled. Mirrors the fixed LocalNode.vue.
|
||||||
|
.adv {
|
||||||
|
margin-top: 6px;
|
||||||
|
|
||||||
|
.adv-toggle {
|
||||||
|
border: 1px dashed var(--w4f-warn-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--w4f-warn-text);
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-body {
|
||||||
|
// 2-column responsive grid: short fields share a row, section headers
|
||||||
|
// (adv-sep) span the full width — same compact layout as LocalNode's
|
||||||
|
// advanced section.
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
.adv-toggle {
|
padding: 8px;
|
||||||
border: 1px dashed var(--w4f-warn-border);
|
background: var(--w4f-warn-100);
|
||||||
background: transparent;
|
border-radius: 8px;
|
||||||
color: var(--w4f-warn-text);
|
|
||||||
font-size: 11px;
|
label {
|
||||||
border-radius: 6px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.adv-body {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 2px;
|
||||||
margin-top: 6px;
|
font-size: 11px;
|
||||||
padding: 6px;
|
color: var(--w4f-warn-text);
|
||||||
background: var(--w4f-warn-100);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
label {
|
input,
|
||||||
display: flex;
|
select {
|
||||||
align-items: center;
|
flex: 1;
|
||||||
gap: 4px;
|
min-width: 0;
|
||||||
font-size: 11px;
|
border: 1px solid var(--w4f-warn-border);
|
||||||
color: var(--w4f-warn-text);
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
input,
|
font-size: 12px;
|
||||||
select {
|
background: var(--w4f-card-solid);
|
||||||
flex: 1;
|
color: var(--w4f-fg);
|
||||||
min-width: 0;
|
&:focus {
|
||||||
border: 1px solid var(--w4f-warn-border);
|
outline: none;
|
||||||
border-radius: 6px;
|
border-color: var(--w4f-warning);
|
||||||
padding: 2px 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
background: var(--w4f-card-solid);
|
|
||||||
color: var(--w4f-fg);
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--w4f-warning);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.adv-check {
|
}
|
||||||
font-size: 11px;
|
|
||||||
}
|
// checkbox labels keep the control inline with their text (single row)
|
||||||
|
.adv-check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// section divider spans the full grid width
|
||||||
|
.adv-sep {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: block;
|
||||||
|
margin: 4px 0 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--w4f-warn-text);
|
||||||
|
border-top: 1px dashed var(--w4f-warn-border);
|
||||||
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,13 +8,14 @@ import './styles/theme.css'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
// Apply the saved theme BEFORE mount so the first paint already uses the
|
// Apply the saved theme BEFORE mount so the first paint already uses the
|
||||||
// correct palette (no white→pink/blue flash). :root is the white default, so
|
// correct palette (no white→blue flash). :root is the white default, so only
|
||||||
// only blue/pink need an explicit data-theme attribute.
|
// blue needs an explicit data-theme attribute. A leftover 'pink' (or any other
|
||||||
|
// value) from an older build falls back to the white default.
|
||||||
(function applyThemeEarly() {
|
(function applyThemeEarly() {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('w4f-theme')
|
const saved = localStorage.getItem('w4f-theme')
|
||||||
if (saved === 'blue' || saved === 'pink') {
|
if (saved === 'blue') {
|
||||||
document.documentElement.setAttribute('data-theme', saved)
|
document.documentElement.setAttribute('data-theme', 'blue')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* localStorage unavailable (private mode) — stay on the white default */
|
/* localStorage unavailable (private mode) — stay on the white default */
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
/*
|
/*
|
||||||
* webui4frpc global theme — glassmorphism with a switchable accent.
|
* webui4frpc global theme — glassmorphism with a switchable accent.
|
||||||
*
|
*
|
||||||
* Three themes share the same surface treatment (translucent glass cards,
|
* Two themes share the same surface treatment (translucent glass cards,
|
||||||
* animated gradient-mesh background, KPI cards, gradient buttons, pill tags,
|
* animated gradient-mesh background, KPI cards, gradient buttons, pill tags,
|
||||||
* slim scrollbars); only the accent hue changes:
|
* slim scrollbars); only the accent hue changes:
|
||||||
* :root = white (neutral slate) — the DEFAULT
|
* :root = white (neutral slate) — the DEFAULT
|
||||||
* [data-theme="blue"] = blue / cyan
|
* [data-theme="blue"] = blue / cyan
|
||||||
* [data-theme="pink"] = sakura / frost (the original ModelRouter palette)
|
|
||||||
* The active theme is applied by setting data-theme on <html> (see App.vue +
|
* The active theme is applied by setting data-theme on <html> (see App.vue +
|
||||||
* main.ts; persisted in localStorage). Element Plus variables are overridden
|
* main.ts; persisted in localStorage). Element Plus variables are overridden
|
||||||
* per theme so EP components inherit the active accent live.
|
* per theme so EP components inherit the active accent live.
|
||||||
@ -183,71 +182,7 @@
|
|||||||
--el-color-info: #64748b;
|
--el-color-info: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- pink theme (sakura × frost — the original ModelRouter palette) ---- */
|
/* ---- blue theme (ocean × frost) ---- */
|
||||||
[data-theme='pink'] {
|
|
||||||
--w4f-primary: #FF7FAC;
|
|
||||||
--w4f-primary-h: #F33B7C;
|
|
||||||
--w4f-primary-50: #FFF0F5;
|
|
||||||
--w4f-primary-100: #FFE4E9;
|
|
||||||
--w4f-primary-200: #FFCDD9;
|
|
||||||
--w4f-primary-300: #FF9EB5;
|
|
||||||
--w4f-secondary: #88C0D0;
|
|
||||||
--w4f-secondary-h: #4C8DAE;
|
|
||||||
--w4f-secondary-50: #F0F9FC;
|
|
||||||
--w4f-secondary-200: #AEE1F2;
|
|
||||||
--w4f-danger: #DB3694;
|
|
||||||
--w4f-danger-50: #FEEAF6;
|
|
||||||
--w4f-warning: #b7791f;
|
|
||||||
--w4f-warning-50: #fdf3e3;
|
|
||||||
--w4f-info: #3f6ef5;
|
|
||||||
--w4f-ok: #17a964;
|
|
||||||
|
|
||||||
--w4f-bg-1: #eef2ff;
|
|
||||||
--w4f-bg-2: #ffffff;
|
|
||||||
--w4f-bg-3: #ffe9f0;
|
|
||||||
--w4f-bg-soft: #f8f6fb;
|
|
||||||
--w4f-bg-muted: #f4f1f7;
|
|
||||||
--w4f-bg-hover: #f5eef3;
|
|
||||||
--w4f-bg-active: #ffe4e9;
|
|
||||||
--w4f-fg: #3b3350;
|
|
||||||
--w4f-fg-2: #5a5470;
|
|
||||||
--w4f-muted: #7c7a95;
|
|
||||||
--w4f-faint: #9c98b0;
|
|
||||||
--w4f-card: rgba(255, 255, 255, 0.60);
|
|
||||||
--w4f-card-solid: #ffffff;
|
|
||||||
--w4f-card-2: rgba(255, 255, 255, 0.42);
|
|
||||||
--w4f-line: rgba(255, 127, 172, 0.18);
|
|
||||||
--w4f-line-2: rgba(255, 127, 172, 0.12);
|
|
||||||
--w4f-line-3: rgba(255, 127, 172, 0.06);
|
|
||||||
--w4f-line-strong: rgba(120, 90, 150, 0.22);
|
|
||||||
|
|
||||||
--w4f-blob1: rgba(255, 127, 172, 0.45);
|
|
||||||
--w4f-blob2: rgba(136, 192, 208, 0.42);
|
|
||||||
--w4f-blob3: rgba(244, 114, 182, 0.30);
|
|
||||||
|
|
||||||
--w4f-sh-sm: 0 1px 2px rgba(70, 50, 110, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
|
||||||
--w4f-sh-md: 0 6px 20px rgba(120, 90, 160, 0.13);
|
|
||||||
--w4f-sh-lg: 0 18px 46px rgba(120, 90, 160, 0.22);
|
|
||||||
|
|
||||||
--w4f-glow: rgba(255, 127, 172, 0.35);
|
|
||||||
--w4f-danger-glow: rgba(219, 54, 148, 0.30);
|
|
||||||
--w4f-glow-soft: rgba(255, 127, 172, 0.18);
|
|
||||||
--w4f-thumb: rgba(255, 182, 193, 0.45);
|
|
||||||
--w4f-thumb-hover: rgba(255, 127, 172, 0.7);
|
|
||||||
--w4f-selection: #ffcdba;
|
|
||||||
|
|
||||||
--el-color-primary: #FF7FAC;
|
|
||||||
--el-color-primary-light-3: #FF9EB5;
|
|
||||||
--el-color-primary-light-5: #ffb3c4;
|
|
||||||
--el-color-primary-light-7: #ffcdd9;
|
|
||||||
--el-color-primary-light-8: #ffe4e9;
|
|
||||||
--el-color-primary-light-9: #fff0f5;
|
|
||||||
--el-color-primary-dark-2: #f33b7c;
|
|
||||||
--el-color-success: #17a964;
|
|
||||||
--el-color-warning: #b7791f;
|
|
||||||
--el-color-danger: #db3694;
|
|
||||||
--el-color-info: #7c7a95;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
// These are thin passthroughs to the runtime CSS custom-property tokens
|
// These are thin passthroughs to the runtime CSS custom-property tokens
|
||||||
// defined in theme.css (--w4f-*). They are auto-injected into every
|
// defined in theme.css (--w4f-*). They are auto-injected into every
|
||||||
// <style lang="scss"> block via vite.config.ts additionalData, so every view
|
// <style lang="scss"> block via vite.config.ts additionalData, so every view
|
||||||
// follows the active theme (white default / blue / pink) without per-view
|
// follows the active theme (white default / blue) without per-view
|
||||||
// rewrites. There is NO SCSS color math on these (no lighten/darken/mix), so
|
// rewrites. There is NO SCSS color math on these (no lighten/darken/mix), so
|
||||||
// a var() reference is safe at compile time and resolves to the live theme
|
// a var() reference is safe at compile time and resolves to the live theme
|
||||||
// color in the browser.
|
// color in the browser.
|
||||||
|
|||||||
@ -261,7 +261,7 @@ export interface RingSnapshot {
|
|||||||
|
|
||||||
// ---- Auth / accounts / API keys (M7) ----
|
// ---- Auth / accounts / API keys (M7) ----
|
||||||
|
|
||||||
export type AccessLevel = 'read' | 'write' | 'admin';
|
export type AccessLevel = 'read' | 'write' | 'admin' | 'superadmin';
|
||||||
|
|
||||||
// MeResp is the authenticated principal (GET /me). The frontend uses level to
|
// MeResp is the authenticated principal (GET /me). The frontend uses level to
|
||||||
// gate the UI: viewer (= auditors) sees read + export controls only.
|
// gate the UI: viewer (= auditors) sees read + export controls only.
|
||||||
@ -275,7 +275,7 @@ export interface MeResp {
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
role: 'admin' | 'viewer';
|
role: 'admin' | 'viewer' | 'superadmin';
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
system?: boolean; // flag-synced built-in account (UI read-only)
|
system?: boolean; // flag-synced built-in account (UI read-only)
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|||||||
@ -1,27 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="canvas-editor">
|
<div class="canvas-editor">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="toolbar">
|
<div class="toolbar w4f-card">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<span class="toolbar-title">连接配置</span>
|
<span class="toolbar-title">连接配置</span>
|
||||||
<button class="btn local-add" @click="addLocal">+ 本地转发项</button>
|
<button v-if="canWrite" class="btn local-add" @click="addLocal">+ 本地转发项</button>
|
||||||
<button class="btn remote-add" @click="addRemote">+ 远程节点</button>
|
<button v-if="canWrite" class="btn remote-add" @click="addRemote">+ 远程节点</button>
|
||||||
<button class="btn warn" @click="autoLayout">自动排列</button>
|
<button v-if="canWrite" class="btn warn" @click="autoLayout">自动排列</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<span class="save-hint">{{ dirty ? '● 未保存' : '已保存' }}</span>
|
<span class="save-hint">{{ dirty ? '● 未保存' : '已保存' }}</span>
|
||||||
<button class="btn ghost" @click="exportCanvas">导出</button>
|
<button class="btn ghost" @click="exportCanvas">导出</button>
|
||||||
<button class="btn ghost" :disabled="!canWrite" @click="pickImport" title="导入转发表(覆盖当前)">
|
<button v-if="canWrite" class="btn ghost" @click="pickImport" title="导入转发表(覆盖当前)">
|
||||||
导入
|
导入
|
||||||
</button>
|
</button>
|
||||||
<input ref="importInput" type="file" accept=".json,application/json" class="hidden-input" @change="onImportFile" />
|
<input ref="importInput" type="file" accept=".json,application/json" class="hidden-input" @change="onImportFile" />
|
||||||
<button class="btn save" :disabled="saving" @click="save">
|
<button v-if="canWrite" class="btn save" :disabled="saving" @click="save">
|
||||||
保存配置
|
保存配置
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flow-wrap">
|
<div class="flow-wrap w4f-card">
|
||||||
<VueFlow
|
<VueFlow
|
||||||
v-model:nodes="nodes"
|
v-model:nodes="nodes"
|
||||||
v-model:edges="edges"
|
v-model:edges="edges"
|
||||||
@ -30,6 +30,8 @@
|
|||||||
:max-zoom="2"
|
:max-zoom="2"
|
||||||
fit-view-on-init
|
fit-view-on-init
|
||||||
:delete-key-code="null"
|
:delete-key-code="null"
|
||||||
|
:nodes-draggable="canWrite"
|
||||||
|
:nodes-connectable="canWrite"
|
||||||
:edges-updatable="false"
|
:edges-updatable="false"
|
||||||
:edges-reconnectable="false"
|
:edges-reconnectable="false"
|
||||||
class="flow-canvas"
|
class="flow-canvas"
|
||||||
@ -91,6 +93,7 @@
|
|||||||
max="65535"
|
max="65535"
|
||||||
placeholder="默认 = 本地端口"
|
placeholder="默认 = 本地端口"
|
||||||
class="port-input"
|
class="port-input"
|
||||||
|
:disabled="!canWrite"
|
||||||
@keyup.enter="confirmPort"
|
@keyup.enter="confirmPort"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -99,12 +102,13 @@
|
|||||||
<el-input
|
<el-input
|
||||||
v-model="groupInput"
|
v-model="groupInput"
|
||||||
placeholder="可选,用于转发页一键启停整组"
|
placeholder="可选,用于转发页一键启停整组"
|
||||||
|
:disabled="!canWrite"
|
||||||
@keyup.enter="confirmPort"
|
@keyup.enter="confirmPort"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button class="btn" @click="portDlg.visible = false">取消</button>
|
<button class="btn" @click="portDlg.visible = false">取消</button>
|
||||||
<button class="btn save" @click="confirmPort">确定</button>
|
<button class="btn save" :disabled="!canWrite" @click="confirmPort">确定</button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
@ -381,6 +385,7 @@ const edgeId = (source: string, target: string, remotePort: number) =>
|
|||||||
`${source}-->${target}@${remotePort}`
|
`${source}-->${target}@${remotePort}`
|
||||||
|
|
||||||
const onConnect = (conn: Connection) => {
|
const onConnect = (conn: Connection) => {
|
||||||
|
if (!canWrite.value) return
|
||||||
const localName = conn.source.startsWith('local::')
|
const localName = conn.source.startsWith('local::')
|
||||||
? conn.source.slice(7)
|
? conn.source.slice(7)
|
||||||
: ''
|
: ''
|
||||||
@ -564,6 +569,7 @@ const onRemoteData = (data: CanvasRemote) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onEdgeClick = ({ edge }: { edge: Edge }) => {
|
const onEdgeClick = ({ edge }: { edge: Edge }) => {
|
||||||
|
if (!canWrite.value) return
|
||||||
editEdge(edge)
|
editEdge(edge)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -827,7 +833,7 @@ onBeforeUnmount(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: $color-bg-secondary;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@ -835,9 +841,9 @@ onBeforeUnmount(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 16px;
|
gap: 10px;
|
||||||
background: $color-bg-primary;
|
flex-wrap: wrap;
|
||||||
border-bottom: 1px solid $color-border-light;
|
padding: 12px 16px;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -935,12 +941,13 @@ onBeforeUnmount(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-canvas {
|
.flow-canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--w4f-bg-muted);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -62,13 +62,13 @@
|
|||||||
<!-- Action toolbar -->
|
<!-- Action toolbar -->
|
||||||
<div class="toolbar w4f-card">
|
<div class="toolbar w4f-card">
|
||||||
<template v-if="!isMember">
|
<template v-if="!isMember">
|
||||||
<button class="w4f-btn" :disabled="busy" @click="onCreate">⊕ 创建集群</button>
|
<button v-if="canWrite" class="w4f-btn" :disabled="busy" @click="onCreate">⊕ 创建集群</button>
|
||||||
<button class="w4f-btn ghost" :disabled="busy" @click="joinDialog.visible = true">↪ 加入集群…</button>
|
<button v-if="canWrite" class="w4f-btn ghost" :disabled="busy" @click="joinDialog.visible = true">↪ 加入集群…</button>
|
||||||
<span class="act-hint" v-if="isStandalone && isLeader">本节点为初始 leader,已就绪;可直接创建,或输入对端地址加入既有集群</span>
|
<span class="act-hint" v-if="isStandalone && isLeader">本节点为初始 leader,已就绪;可直接创建,或输入对端地址加入既有集群</span>
|
||||||
<span class="act-hint" v-else-if="!selfInRing">本节点已脱离集群,可创建新集群或加入既有集群</span>
|
<span class="act-hint" v-else-if="!selfInRing">本节点已脱离集群,可创建新集群或加入既有集群</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button class="w4f-btn danger" :disabled="busy" @click="onLeave">⏏ 退出集群</button>
|
<button v-if="canWrite" class="w4f-btn danger" :disabled="busy" @click="onLeave">⏏ 退出集群</button>
|
||||||
<span class="act-hint">退出后本节点仅保留 localOnly 转发;可重新创建/加入</span>
|
<span class="act-hint">退出后本节点仅保留 localOnly 转发;可重新创建/加入</span>
|
||||||
</template>
|
</template>
|
||||||
<button class="w4f-btn ghost small" style="margin-left: auto" :disabled="busy" @click="load">⟳ 刷新</button>
|
<button class="w4f-btn ghost small" style="margin-left: auto" :disabled="busy" @click="load">⟳ 刷新</button>
|
||||||
@ -92,7 +92,7 @@
|
|||||||
<div class="w4f-bar"><i :style="{ width: Math.min(100, n.load.memPct + n.load.netPct) + '%' }" /></div>
|
<div class="w4f-bar"><i :style="{ width: Math.min(100, n.load.memPct + n.load.netPct) + '%' }" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rn-addr">{{ n.addr }}</div>
|
<div class="rn-addr">{{ n.addr }}</div>
|
||||||
<button v-if="n.id !== ring.selfId && n.alive" class="w4f-btn danger small rn-remove" :disabled="busy" @click="onRemoveNode(n.id)">移除节点</button>
|
<button v-if="canWrite && n.id !== ring.selfId && n.alive" class="w4f-btn danger small rn-remove" :disabled="busy" @click="onRemoveNode(n.id)">移除节点</button>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="i < ring.nodes.length - 1" class="ring-arrow">→</span>
|
<span v-if="i < ring.nodes.length - 1" class="ring-arrow">→</span>
|
||||||
</template>
|
</template>
|
||||||
@ -176,16 +176,16 @@
|
|||||||
<el-dialog v-model="joinDialog.visible" title="加入集群" width="420px">
|
<el-dialog v-model="joinDialog.visible" title="加入集群" width="420px">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>对端地址</label>
|
<label>对端地址</label>
|
||||||
<el-input v-model="joinDialog.addr" placeholder="对端 IP:port(如 192.168.1.10:7500)" @keyup.enter="onJoin" />
|
<el-input v-model="joinDialog.addr" :disabled="!canWrite" placeholder="对端 IP:port(如 192.168.1.10:7500)" @keyup.enter="onJoin" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>加入密钥</label>
|
<label>加入密钥</label>
|
||||||
<el-input v-model="joinDialog.key" placeholder="对端节点的加入密钥(nodeKey)" @keyup.enter="onJoin" />
|
<el-input v-model="joinDialog.key" :disabled="!canWrite" placeholder="对端节点的加入密钥(nodeKey)" @keyup.enter="onJoin" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-hint">填入对端节点可被本机路由到达的真实 IP:port(或域名:port),本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。需提供对端节点的加入密钥以通过验证。</div>
|
<div class="form-hint">填入对端节点可被本机路由到达的真实 IP:port(或域名:port),本节点将向对端请求加入并采纳其环状态,令牌到达即并入环。需提供对端节点的加入密钥以通过验证。</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button class="w4f-btn ghost small" @click="joinDialog.visible = false">取消</button>
|
<button class="w4f-btn ghost small" @click="joinDialog.visible = false">取消</button>
|
||||||
<button class="w4f-btn small" :disabled="busy || !joinDialog.addr.trim() || !joinDialog.key.trim()" @click="onJoin">加入</button>
|
<button class="w4f-btn small" :disabled="busy || !canWrite || !joinDialog.addr.trim() || !joinDialog.key.trim()" @click="onJoin">加入</button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
@ -195,6 +195,7 @@
|
|||||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { canWrite } from '../auth'
|
||||||
import type { RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
|
import type { RingSnapshot, RingTaskInfo, RingLogEntry } from '../types'
|
||||||
|
|
||||||
const ring = ref<RingSnapshot | null>(null)
|
const ring = ref<RingSnapshot | null>(null)
|
||||||
@ -494,7 +495,7 @@ onBeforeUnmount(() => {
|
|||||||
.ring-node { position: relative; border: 1px solid var(--w4f-line); border-radius: $radius-md; padding: 12px 14px; min-width: 168px;
|
.ring-node { position: relative; border: 1px solid var(--w4f-line); border-radius: $radius-md; padding: 12px 14px; min-width: 168px;
|
||||||
background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm);
|
background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm);
|
||||||
&.leader { border-color: var(--w4f-secondary); background: var(--w4f-secondary-50); }
|
&.leader { border-color: var(--w4f-secondary); background: var(--w4f-secondary-50); }
|
||||||
&.self { outline: 2px solid rgba(255, 127, 172, 0.45); box-shadow: 0 0 0 4px rgba(255, 127, 172, 0.14); border-color: var(--w4f-primary); }
|
&.self { outline: 2px solid var(--w4f-primary); box-shadow: 0 0 0 4px var(--w4f-glow-soft); border-color: var(--w4f-primary); }
|
||||||
&.dead { border-color: var(--w4f-danger); background: var(--w4f-danger-50); opacity: 0.72; }
|
&.dead { border-color: var(--w4f-danger); background: var(--w4f-danger-50); opacity: 0.72; }
|
||||||
}
|
}
|
||||||
.rn-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
.rn-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
@ -510,8 +511,8 @@ onBeforeUnmount(() => {
|
|||||||
.task-list, .topo-list, .log-list { display: flex; flex-direction: column; gap: 6px; }
|
.task-list, .topo-list, .log-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.log-list { max-height: 340px; overflow-y: auto; padding-right: 2px; }
|
.log-list { max-height: 340px; overflow-y: auto; padding-right: 2px; }
|
||||||
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 8px 11px; font-size: 13px; background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm); }
|
.task-row, .topo-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--w4f-line); border-radius: $radius-xs; padding: 8px 11px; font-size: 13px; background: var(--w4f-card-2); box-shadow: var(--w4f-sh-sm); }
|
||||||
.task-row.pending { background: var(--w4f-primary-50); border-color: rgba(255, 127, 172, 0.25); }
|
.task-row.pending { background: var(--w4f-primary-50); border-color: var(--w4f-primary-200); }
|
||||||
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: rgba(219, 54, 148, 0.25); }
|
.task-row.revoke, .task-row.remove { background: var(--w4f-danger-50); border-color: var(--w4f-danger); }
|
||||||
.tk-id, .tp-id { font-weight: 700; color: $color-text-secondary; font-size: 12px; font-family: var(--w4f-mono); }
|
.tk-id, .tp-id { font-weight: 700; color: $color-text-secondary; font-size: 12px; font-family: var(--w4f-mono); }
|
||||||
.tk-name, .tp-name { font-weight: 600; }
|
.tk-name, .tp-name { font-weight: 600; }
|
||||||
.tk-arrow { color: var(--w4f-primary); font-weight: 700; }
|
.tk-arrow { color: var(--w4f-primary); font-weight: 700; }
|
||||||
|
|||||||
155
web/src/views/LoginView.vue
Normal file
155
web/src/views/LoginView.vue
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
<!--
|
||||||
|
LoginView — session-cookie sign-in page. Shown by App.vue when /me is 401
|
||||||
|
(no session cookie). Submitting POST /api/manager/login sets the HttpOnly
|
||||||
|
session cookie; on success auth.login() flips isAuthed and App drops into
|
||||||
|
the shell. There is no browser Basic-Auth prompt anymore (the 401 response
|
||||||
|
omits WWW-Authenticate), so credentials are never cached by the browser and
|
||||||
|
logout works.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="login-wrap">
|
||||||
|
<form class="login-card" @submit.prevent="submit">
|
||||||
|
<div class="lc-brand">
|
||||||
|
<img class="lc-logo" src="/favicon.svg" alt="webui4frpc" />
|
||||||
|
<b>webui4frpc</b>
|
||||||
|
<small>令牌环 · 转发编排</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="lc-field">
|
||||||
|
<span>用户名</span>
|
||||||
|
<input v-model="username" autocomplete="username" placeholder="用户名" required />
|
||||||
|
</label>
|
||||||
|
<label class="lc-field">
|
||||||
|
<span>密码</span>
|
||||||
|
<input v-model="password" type="password" autocomplete="current-password" placeholder="••••••••" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-if="error" class="lc-error">{{ error }}</p>
|
||||||
|
|
||||||
|
<button class="lc-btn" type="submit" :disabled="busy">
|
||||||
|
{{ busy ? '登录中…' : '登 录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { login } from '../auth'
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await login(username.value, password.value)
|
||||||
|
} catch {
|
||||||
|
error.value = '登录失败:用户名或密码错误'
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-wrap {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-family: var(--w4f-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 32px 28px 28px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--w4f-card);
|
||||||
|
border: 1px solid var(--w4f-line);
|
||||||
|
box-shadow: var(--w4f-sh-lg);
|
||||||
|
backdrop-filter: blur(calc(var(--w4f-glass) * 0.8)) saturate(1.3);
|
||||||
|
-webkit-backdrop-filter: blur(calc(var(--w4f-glass) * 0.8)) saturate(1.3);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.login-card { backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.lc-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
box-shadow: 0 8px 22px var(--w4f-glow);
|
||||||
|
}
|
||||||
|
.lc-brand b { font-size: 19px; letter-spacing: 0.3px; }
|
||||||
|
.lc-brand small { font-size: 12px; color: var(--w4f-muted); }
|
||||||
|
|
||||||
|
.lc-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--w4f-fg-2);
|
||||||
|
}
|
||||||
|
.lc-field input {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--w4f-line-strong);
|
||||||
|
background: var(--w4f-card-solid);
|
||||||
|
color: var(--w4f-fg);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s var(--w4f-ease), box-shadow 0.15s var(--w4f-ease);
|
||||||
|
}
|
||||||
|
.lc-field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--w4f-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--w4f-glow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--w4f-danger);
|
||||||
|
background: var(--w4f-danger-50);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-btn {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 11px 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, var(--w4f-primary), var(--w4f-secondary));
|
||||||
|
box-shadow: 0 6px 18px var(--w4f-glow);
|
||||||
|
transition: transform 0.15s var(--w4f-ease-spring), box-shadow 0.15s var(--w4f-ease-spring);
|
||||||
|
}
|
||||||
|
.lc-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 24px var(--w4f-glow);
|
||||||
|
}
|
||||||
|
.lc-btn:disabled { opacity: 0.65; cursor: default; }
|
||||||
|
</style>
|
||||||
@ -23,10 +23,12 @@
|
|||||||
clearable
|
clearable
|
||||||
size="large"
|
size="large"
|
||||||
class="binary-input"
|
class="binary-input"
|
||||||
|
:disabled="!canWrite"
|
||||||
@blur="applyBinaryPath"
|
@blur="applyBinaryPath"
|
||||||
@keyup.enter="applyBinaryPath"
|
@keyup.enter="applyBinaryPath"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
v-if="canWrite"
|
||||||
class="btn install-btn"
|
class="btn install-btn"
|
||||||
:disabled="installing"
|
:disabled="installing"
|
||||||
@click="installBinary"
|
@click="installBinary"
|
||||||
@ -57,6 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-switch
|
<el-switch
|
||||||
v-model="settings.autoStartProfiles"
|
v-model="settings.autoStartProfiles"
|
||||||
|
:disabled="!canWrite"
|
||||||
@change="saveSettings"
|
@change="saveSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -65,7 +68,7 @@
|
|||||||
<span class="setting-label">异常退出自动重启</span>
|
<span class="setting-label">异常退出自动重启</span>
|
||||||
<span class="setting-hint">worker 崩溃后带退避重启</span>
|
<span class="setting-hint">worker 崩溃后带退避重启</span>
|
||||||
</div>
|
</div>
|
||||||
<el-switch v-model="settings.restartOnExit" @change="saveSettings" />
|
<el-switch v-model="settings.restartOnExit" :disabled="!canWrite" @change="saveSettings" />
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-text">
|
<div class="setting-text">
|
||||||
@ -76,6 +79,7 @@
|
|||||||
v-model="settings.restartIntervalSeconds"
|
v-model="settings.restartIntervalSeconds"
|
||||||
:min="1"
|
:min="1"
|
||||||
:max="3600"
|
:max="3600"
|
||||||
|
:disabled="!canWrite"
|
||||||
@change="saveSettings"
|
@change="saveSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -89,6 +93,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Loading } from '@element-plus/icons-vue'
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { canWrite } from '../auth'
|
||||||
import type {
|
import type {
|
||||||
BinaryStatus,
|
BinaryStatus,
|
||||||
Settings as ManagerSettings,
|
Settings as ManagerSettings,
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tb-right">
|
<div class="tb-right">
|
||||||
<button class="w4f-btn ghost small" @click="loadStatus">⟳ 刷新</button>
|
<button class="w4f-btn ghost small" @click="loadStatus">⟳ 刷新</button>
|
||||||
<button class="w4f-btn small" @click="openAdd">+ 添加远程节点</button>
|
<button v-if="canWrite" class="w4f-btn small" @click="openAdd">+ 添加远程节点</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -43,9 +43,9 @@
|
|||||||
<span class="k-ic">→</span>{{ g.label }}
|
<span class="k-ic">→</span>{{ g.label }}
|
||||||
<span class="grp-count">{{ g.items.length }} 条</span>
|
<span class="grp-count">{{ g.items.length }} 条</span>
|
||||||
<span class="grp-actions">
|
<span class="grp-actions">
|
||||||
<button class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
|
<button v-if="canWrite" class="w4f-btn small" @click="groupStart(g.key)">一键启动整组</button>
|
||||||
<button class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
|
<button v-if="canWrite" class="w4f-btn ghost small" @click="groupStop(g.key)">一键停止整组</button>
|
||||||
<button v-if="g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>
|
<button v-if="canWrite && g.key" class="w4f-btn ghost small danger" @click="deleteGroup(g.key)">删除分组</button>
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
@ -70,13 +70,14 @@
|
|||||||
<span v-if="f.localProto" class="w4f-tag w4f-tag-primary">{{ f.localProto }}</span>
|
<span v-if="f.localProto" class="w4f-tag w4f-tag-primary">{{ f.localProto }}</span>
|
||||||
<span
|
<span
|
||||||
class="w4f-tag w4f-tag-info group-chip"
|
class="w4f-tag w4f-tag-info group-chip"
|
||||||
@click="editGroup(f)"
|
:class="{ readonly: !canWrite }"
|
||||||
:title="'点击修改分组(当前:' + (f.group || '未分组') + ')'"
|
@click="canWrite && editGroup(f)"
|
||||||
|
:title="canWrite ? '点击修改分组(当前:' + (f.group || '未分组') + ')' : '当前分组:' + (f.group || '未分组')"
|
||||||
>{{ f.group || '未分组' }}</span>
|
>{{ f.group || '未分组' }}</span>
|
||||||
<span v-if="f.kind === 'remote'" class="fwd-owner" :title="f.ownerId || ''">{{ ownerLabel(f) }}</span>
|
<span v-if="f.kind === 'remote'" class="fwd-owner" :title="f.ownerId || ''">{{ ownerLabel(f) }}</span>
|
||||||
<span class="fwd-actions">
|
<span class="fwd-actions">
|
||||||
<button v-if="!f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
|
<button v-if="canWrite && !f.active" class="w4f-btn small" @click="startForward(f)">启动转发</button>
|
||||||
<button v-else class="w4f-btn ghost small" @click="stopForward(f)">停止转发</button>
|
<button v-else-if="canWrite" class="w4f-btn ghost small" @click="stopForward(f)">停止转发</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -101,8 +102,8 @@
|
|||||||
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
|
<span class="w4f-dot" :class="connDotClass(p.connState)" />{{ connStateLabel(p.connState) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="nc-actions">
|
<span class="nc-actions">
|
||||||
<button class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
|
<button v-if="canWrite" class="w4f-btn ghost small" @click="openEdit(p)">编辑</button>
|
||||||
<button class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
|
<button v-if="canWrite" class="w4f-btn danger small" @click="removeRemote(p.name)">删除</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nc-meta">
|
<div class="nc-meta">
|
||||||
@ -172,31 +173,31 @@
|
|||||||
<el-dialog v-model="dialog.visible" :title="dialog.isNew ? '添加远程节点' : '编辑远程节点'" width="440px">
|
<el-dialog v-model="dialog.visible" :title="dialog.isNew ? '添加远程节点' : '编辑远程节点'" width="440px">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>名称</label>
|
<label>名称</label>
|
||||||
<el-input v-model="dialog.remote.name" :disabled="!dialog.isNew" placeholder="如 aliyun-hz" />
|
<el-input v-model="dialog.remote.name" :disabled="!canWrite || !dialog.isNew" placeholder="如 aliyun-hz" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>服务器 IP</label>
|
<label>服务器 IP</label>
|
||||||
<el-input v-model="dialog.remote.ip" placeholder="frps 地址" />
|
<el-input v-model="dialog.remote.ip" :disabled="!canWrite" placeholder="frps 地址" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>端口</label>
|
<label>端口</label>
|
||||||
<el-input-number v-model="dialog.remote.port" :min="1" :max="65535" />
|
<el-input-number v-model="dialog.remote.port" :disabled="!canWrite" :min="1" :max="65535" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Token</label>
|
<label>Token</label>
|
||||||
<el-input v-model="dialog.remote.token" placeholder="可选" />
|
<el-input v-model="dialog.remote.token" :disabled="!canWrite" placeholder="可选" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>URL</label>
|
<label>URL</label>
|
||||||
<el-input v-model="dialog.remote.url" placeholder="可选" />
|
<el-input v-model="dialog.remote.url" :disabled="!canWrite" placeholder="可选" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>启用</label>
|
<label>启用</label>
|
||||||
<el-switch v-model="dialog.remote.enabled" />
|
<el-switch v-model="dialog.remote.enabled" :disabled="!canWrite" />
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button class="w4f-btn ghost small" @click="dialog.visible = false">取消</button>
|
<button class="w4f-btn ghost small" @click="dialog.visible = false">取消</button>
|
||||||
<button class="w4f-btn small" @click="saveRemote">保存</button>
|
<button class="w4f-btn small" :disabled="!canWrite" @click="saveRemote">保存</button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
@ -206,6 +207,7 @@
|
|||||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { canWrite } from '../auth'
|
||||||
import type { ForwardStatus, Remote, StatusResp } from '../types'
|
import type { ForwardStatus, Remote, StatusResp } from '../types'
|
||||||
|
|
||||||
const status = ref<StatusResp | null>(null)
|
const status = ref<StatusResp | null>(null)
|
||||||
@ -616,7 +618,7 @@ onBeforeUnmount(() => {
|
|||||||
.fwd-actions { display: flex; gap: 6px; }
|
.fwd-actions { display: flex; gap: 6px; }
|
||||||
.grp-count { font-size: 12px; color: $color-text-muted; font-weight: 600; }
|
.grp-count { font-size: 12px; color: $color-text-muted; font-weight: 600; }
|
||||||
.grp-actions { margin-left: auto; display: flex; gap: 6px; }
|
.grp-actions { margin-left: auto; display: flex; gap: 6px; }
|
||||||
.group-chip { cursor: pointer; transition: background $transition-fast; &:hover { background: color-mix(in srgb, var(--w4f-info) 30%, transparent); } }
|
.group-chip { cursor: pointer; transition: background $transition-fast; &:hover { background: color-mix(in srgb, var(--w4f-info) 30%, transparent); } &.readonly { cursor: default; &:hover { background: none; } } }
|
||||||
|
|
||||||
/* local service card */
|
/* local service card */
|
||||||
.local-card { padding: 14px 16px; }
|
.local-card { padding: 14px 16px; }
|
||||||
|
|||||||
@ -14,10 +14,10 @@
|
|||||||
</header>
|
</header>
|
||||||
<el-table :data="users" size="small" stripe empty-text="暂无账号">
|
<el-table :data="users" size="small" stripe empty-text="暂无账号">
|
||||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||||
<el-table-column label="角色" width="110">
|
<el-table-column label="角色" width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag size="small" :type="row.role === 'admin' ? 'danger' : 'info'">
|
<el-tag size="small" :type="row.role === 'superadmin' ? 'danger' : row.role === 'admin' ? 'warning' : 'info'">
|
||||||
{{ row.role === 'admin' ? '管理员' : '审计(viewer)' }}
|
{{ row.role === 'superadmin' ? '超级管理员' : row.role === 'admin' ? '管理员' : '审计(viewer)' }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -97,6 +97,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="角色">
|
<el-form-item label="角色">
|
||||||
<el-select v-model="userForm.role" :disabled="editing && editing?.system">
|
<el-select v-model="userForm.role" :disabled="editing && editing?.system">
|
||||||
|
<el-option label="超级管理员 (superadmin)" value="superadmin" />
|
||||||
<el-option label="管理员 (admin)" value="admin" />
|
<el-option label="管理员 (admin)" value="admin" />
|
||||||
<el-option label="审计 (viewer)" value="viewer" />
|
<el-option label="审计 (viewer)" value="viewer" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@ -180,7 +181,7 @@ const scopeType = (s: string) => (s === 'admin' ? 'danger' : s === 'write' ? 'wa
|
|||||||
const userDialog = ref(false)
|
const userDialog = ref(false)
|
||||||
const editing = ref<User | null>(null)
|
const editing = ref<User | null>(null)
|
||||||
const userSaving = ref(false)
|
const userSaving = ref(false)
|
||||||
const userForm = ref({ username: '', password: '', role: 'viewer' as 'admin' | 'viewer', enabled: true })
|
const userForm = ref({ username: '', password: '', role: 'viewer' as 'admin' | 'viewer' | 'superadmin', enabled: true })
|
||||||
|
|
||||||
const openUserCreate = () => {
|
const openUserCreate = () => {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
@ -197,7 +198,7 @@ const submitUser = async () => {
|
|||||||
userSaving.value = true
|
userSaving.value = true
|
||||||
try {
|
try {
|
||||||
if (editing.value) {
|
if (editing.value) {
|
||||||
const patch: { password?: string; role: 'admin' | 'viewer'; enabled: boolean } = {
|
const patch: { password?: string; role: 'admin' | 'viewer' | 'superadmin'; enabled: boolean } = {
|
||||||
role: userForm.value.role,
|
role: userForm.value.role,
|
||||||
enabled: userForm.value.enabled,
|
enabled: userForm.value.enabled,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user