Compare commits

...

63 Commits

Author SHA1 Message Date
7c38a99973 fix(ci): исправить путь container registry
All checks were successful
Build & Push Docker Image / build (push) Successful in 2m15s
- исправлен путь образа на gromov/casdoor
2026-04-11 19:42:25 +03:00
7c26dbb7d0 fix(ci): убрать тест версии из Dockerfile
Some checks failed
Build & Push Docker Image / build (push) Failing after 6m15s
- удалён TestGetVersionInfo, падает без .git в контейнере
2026-04-11 19:32:22 +03:00
61bc75b12e chore(ci): добавить ручной запуск workflow
Some checks failed
Build & Push Docker Image / build (push) Failing after 2m5s
- добавлен workflow_dispatch для ручного запуска сборки
2026-04-11 19:26:23 +03:00
18a8694d28 chore(ci): добавить Gitea Actions для сборки Docker-образа
- добавлен workflow сборки и пуша в container registry
- сборка при пуше в ветку custom
- target STANDARD (Alpine)
2026-04-11 19:19:01 +03:00
8478543c6b feat(i18n): добавить русский язык и конфигурацию разработки
- добавлен русский перевод интерфейса (web/src/locales/ru)
- восстановлен русский перевод бэкенда из Crowdin (i18n/locales/ru)
- добавлен ru в список языков организации
- добавлен Русский в селект языков
- добавлена конфигурация для локальной разработки (PostgreSQL, порт 5434)
- добавлен docker-compose.dev.yml
2026-04-11 19:11:15 +03:00
Yang Luo
25d8595e66 fix: improve top-left logo position 2026-04-11 22:33:20 +08:00
Yang Luo
3aafa91937 fix: improve hosting badge position and UI 2026-04-11 22:23:46 +08:00
Yang Luo
0077839549 fix: hide global scrollbar 2026-04-11 22:07:54 +08:00
Yang Luo
e1ee2ddee8 fix: add margin to 3 store pages 2026-04-11 22:02:05 +08:00
Yang Luo
b93be2d3e2 fix: add top breadcrumb bar 2026-04-11 21:53:57 +08:00
Yang Luo
77b56a2e40 fix: increase left sidebar width 2026-04-11 21:48:13 +08:00
Yang Luo
c0591f316e fix: increase org-select's width 2026-04-11 21:43:56 +08:00
Yang Luo
6749d46561 fix: improve top-left logo position 2026-04-11 21:42:21 +08:00
Yang Luo
a4a50f182b fix: hide left sidebar's scrollbar 2026-04-11 21:21:11 +08:00
Yang Luo
221d10a172 fix: fix Can't resolve 'rc-util/es/isEqual' bug 2026-04-11 20:46:34 +08:00
Yang Luo
5c051ba03d feat: improve table column width
BREAKING CHANGE: major release
2026-04-11 19:08:54 +08:00
Yang Luo
c16f4d2fb5 fix: improve xxx list page table's column row height 2026-04-11 19:01:28 +08:00
Yang Luo
fe185f880c fix: improve i18n keys 2026-04-11 19:00:45 +08:00
Yang Luo
b3bed1992b fix: improve "Loading" position 2026-04-11 18:54:05 +08:00
Yang Luo
be38d178fd fix: increase org-select's width 2026-04-11 18:50:19 +08:00
Yang Luo
3eb164e149 fix: add left margin to top-right user avatar 2026-04-11 18:46:47 +08:00
Yang Luo
6c3cd8a74b fix: set Sidebar menu: selected item - darker background 2026-04-11 18:43:10 +08:00
Yang Luo
c5ab4eec59 fix: fix top-left logo missing bug 2026-04-11 18:41:20 +08:00
Yang Luo
e8170884d7 fix: improve record and session list page UI 2026-04-11 18:40:22 +08:00
Yang Luo
729b21e8ae fix: use Apple Inter font 2026-04-11 18:32:36 +08:00
Yang Luo
bed67a1ff2 fix: improve top-left menu text 2026-04-11 18:21:29 +08:00
Yang Luo
df5f5def31 fix: improve list page's table title bar height 2026-04-11 18:10:29 +08:00
Yang Luo
76c56e9b2d fix: fix top-left menu highlight 2026-04-11 18:09:27 +08:00
Yang Luo
f46e229d5b fix: improve top-left logo 2026-04-11 17:50:25 +08:00
Yang Luo
112be9714b fix: reduce content area margin 2026-04-11 17:47:46 +08:00
Yang Luo
9d85362a24 fix: reduce top bar height 2026-04-11 17:43:03 +08:00
Yang Luo
37e2f13d99 feat: change to left sidebar 2026-04-11 17:32:13 +08:00
Yang Luo
f35398ea5c fix: use outlined icons in top navbar 2026-04-11 17:19:09 +08:00
Yang Luo
5a5470d5a3 fix: use shadcn theme by default 2026-04-11 17:15:35 +08:00
Yang Luo
948fc017e1 fix: improve i18n data 2026-04-11 17:01:26 +08:00
Yang Luo
c63184fc67 feat: upgrade to Antd 6.3.5 2026-04-11 16:53:23 +08:00
Yang Luo
f5f4032b3b fix: fix code format 2026-04-11 11:02:59 +08:00
Yang Luo
7006041fa9 fix: remove OpenClaw session heartbeat logs 2026-04-11 10:54:59 +08:00
Yang Luo
d7bc2bf052 feat: add support for OAuth 2.0 DPoP (Demonstrating Proof of Possession) 2026-04-11 10:45:33 +08:00
Yang Luo
29eeb03f85 fix: refactor out token_oauth_util.go 2026-04-11 10:19:04 +08:00
Yang Luo
14b4b557f9 feat: support user's accessKey in auto signin filter 2026-04-11 01:07:36 +08:00
Yang Luo
49d35ac161 feat: add Provider.CustomLogoutUrl field 2026-04-11 01:01:58 +08:00
Yang Luo
5ed9158368 fix: improve GetSortedUsers code 2026-04-11 00:58:40 +08:00
Yang Luo
2bb728ad7d feat: fix wrong verb sent in oauth state (signup instead of signin) 2026-04-11 00:42:57 +08:00
nkanf-dev
f4665df477 fix: fix checking shared-app login permission in user organization (#5381) 2026-04-11 00:09:59 +08:00
Paperlz
12bbecb69d feat: build OpenClaw session graphs from DB entries (#5382) 2026-04-11 00:02:04 +08:00
Yang Luo
a5079cd0c5 feat: fix UpdatePolicy/UpdatePolicies APIs for empty field update 2026-04-09 01:00:02 +08:00
Yang Luo
e361044f86 fix: add RequestBodyFilter to fix POST body JSON error without Content-Type issue 2026-04-09 00:35:05 +08:00
Yang Luo
91cdf56636 feat: Enable ABAC support in /api/enforce and /api/batch-enforce 2026-04-09 00:32:48 +08:00
Yang Luo
10daed237e feat: improve objOwner check 2026-04-08 23:17:54 +08:00
cooronx
315a6bb040 feat: deduplicate permission RBAC by building grouping policies in run time (#5374) 2026-04-08 23:01:00 +08:00
Yang Luo
cef6b85389 feat: failed HTTP Basic Authentication won't trigger error now 2026-04-08 22:59:52 +08:00
Yang Luo
14a802f2c5 fix: fix add-new logic for provider 2026-04-08 22:18:44 +08:00
Yang Luo
40d1f63cd6 fix: don't collect OpenClaw metrics entries for now 2026-04-08 19:23:14 +08:00
Yang Luo
85c91c50d3 fix: add provider.State to log providers 2026-04-08 09:52:45 +08:00
Yang Luo
0e5f810f2f feat: fix "sqlite3" driverName to "sqlite" 2026-04-08 09:27:16 +08:00
asuka
e9c2ec0d6c fix: fix authz permission for well-known OAuth endpoints (#5372) 2026-04-07 22:38:12 +08:00
Paperlz
2a8ac578da feat: add local OpenClaw transcript sync for session logs (#5370) 2026-04-07 22:34:55 +08:00
DacongDA
31ce1512df feat: apply loginPage captcha rule check to SendCodeInput.js (#5369) 2026-04-06 17:54:27 +08:00
Yang Luo
bac824cb4f feat: improve filter check 2026-04-06 12:26:29 +08:00
DacongDA
1637ca1dfb feat: support GET request in MCP SSE (#5363) 2026-04-06 10:35:49 +08:00
Paperlz
c7ad2052c9 feat: add provider-based entry viewers for SELinux logs (#5364) 2026-04-06 00:59:32 +08:00
Yang Luo
117bf608ea fix: include xs namespace in C14N10 exclusive canonicalization prefix list 2026-04-05 21:09:54 +08:00
93 changed files with 7976 additions and 1962 deletions

View File

@@ -0,0 +1,31 @@
name: Build & Push Docker Image
on:
push:
branches:
- custom
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: gromlab.ru
username: ${{ secrets.CR_USER }}
password: ${{ secrets.CR_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
target: STANDARD
push: true
tags: |
gromlab.ru/gromov/casdoor:latest
gromlab.ru/gromov/casdoor:${{ github.sha }}

View File

@@ -19,7 +19,6 @@ RUN go mod download
# Copy source files
COPY . .
RUN go test -v -run TestGetVersionInfo ./util/system_test.go ./util/system.go ./util/variable.go
RUN ./build.sh
FROM alpine:latest AS STANDARD

View File

@@ -92,9 +92,13 @@ p, *, *, POST, /api/v1/logs, *, *
p, *, *, POST, /api/reset-email-or-phone, *, *
p, *, *, POST, /api/upload-resource, *, *
p, *, *, GET, /.well-known/openid-configuration, *, *
p, *, *, GET, /.well-known/oauth-authorization-server, *, *
p, *, *, GET, /.well-known/oauth-protected-resource, *, *
p, *, *, GET, /.well-known/webfinger, *, *
p, *, *, *, /.well-known/jwks, *, *
p, *, *, GET, /.well-known/:application/openid-configuration, *, *
p, *, *, GET, /.well-known/:application/oauth-authorization-server, *, *
p, *, *, GET, /.well-known/:application/oauth-protected-resource, *, *
p, *, *, GET, /.well-known/:application/webfinger, *, *
p, *, *, *, /.well-known/:application/jwks, *, *
p, *, *, GET, /api/get-saml-login, *, *
@@ -175,7 +179,7 @@ func IsAllowed(subOwner string, subName string, method string, urlPath string, o
return true
}
if user.IsAdmin && (subOwner == objOwner || (objOwner == "admin")) {
if user.IsAdmin && subOwner == objOwner {
return true
}
}

View File

@@ -1,43 +1,37 @@
appname = casdoor
httpport = 8000
runmode = dev
copyrequestbody = true
driverName = mysql
dataSourceName = root:123456@tcp(localhost:3306)/
dbName = casdoor
tableNamePrefix =
showSql = false
redisEndpoint =
defaultStorageProvider =
isCloudIntranet = false
authState = "casdoor"
socks5Proxy = "127.0.0.1:10808"
verificationCodeTimeout = 10
initScore = 0
logPostOnly = true
isUsernameLowered = false
origin =
originFrontend =
staticBaseUrl = "https://cdn.casbin.org"
isDemoMode = false
batchSize = 100
showGithubCorner = false
forceLanguage = ""
defaultLanguage = "en"
aiAssistantUrl = "https://ai.casbin.com"
defaultApplication = "app-built-in"
maxItemsForFlatMenu = 7
enableErrorMask = false
enableGzip = true
inactiveTimeoutMinutes =
ldapServerPort = 389
ldapsCertId = ""
ldapsServerPort = 636
radiusServerPort = 1812
radiusDefaultOrganization = "built-in"
radiusSecret = "secret"
quota = {"organization": -1, "user": -1, "application": -1, "provider": -1}
logConfig = {"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
initDataNewOnly = false
initDataFile = "./init_data.json"
frontendBaseDir = "../cc_0"
appname = casdoor
httpport = 8000
runmode = dev
copyrequestbody = true
driverName = postgres
dataSourceName = "user=casdoor password=casdoor_dev host=localhost port=5434 sslmode=disable dbname=casdoor"
dbName = casdoor
tableNamePrefix =
showSql = false
redisEndpoint =
defaultStorageProvider =
isCloudIntranet = false
authState = "casdoor"
socks5Proxy = ""
verificationCodeTimeout = 10
initScore = 0
logPostOnly = true
isUsernameLowered = false
origin = "http://localhost:8000"
originFrontend = "http://localhost:7001"
staticBaseUrl = "https://cdn.casbin.org"
isDemoMode = false
batchSize = 100
showGithubCorner = false
forceLanguage = ""
defaultLanguage = "ru"
enableErrorMask = false
enableGzip = true
ldapServerPort = 389
ldapsServerPort = 636
radiusServerPort = 1812
radiusDefaultOrganization = "built-in"
radiusSecret = "secret"
quota = {"organization": -1, "user": -1, "application": -1, "provider": -1}
logConfig = {"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
initDataNewOnly = false
initDataFile = "./init_data.json"

43
conf/app.conf.orig Normal file
View File

@@ -0,0 +1,43 @@
appname = casdoor
httpport = 8000
runmode = dev
copyrequestbody = true
driverName = mysql
dataSourceName = root:123456@tcp(localhost:3306)/
dbName = casdoor
tableNamePrefix =
showSql = false
redisEndpoint =
defaultStorageProvider =
isCloudIntranet = false
authState = "casdoor"
socks5Proxy = "127.0.0.1:10808"
verificationCodeTimeout = 10
initScore = 0
logPostOnly = true
isUsernameLowered = false
origin =
originFrontend =
staticBaseUrl = "https://cdn.casbin.org"
isDemoMode = false
batchSize = 100
showGithubCorner = false
forceLanguage = ""
defaultLanguage = "en"
aiAssistantUrl = "https://ai.casbin.com"
defaultApplication = "app-built-in"
maxItemsForFlatMenu = 7
enableErrorMask = false
enableGzip = true
inactiveTimeoutMinutes =
ldapServerPort = 389
ldapsCertId = ""
ldapsServerPort = 636
radiusServerPort = 1812
radiusDefaultOrganization = "built-in"
radiusSecret = "secret"
quota = {"organization": -1, "user": -1, "application": -1, "provider": -1}
logConfig = {"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
initDataNewOnly = false
initDataFile = "./init_data.json"
frontendBaseDir = "../cc_0"

37
conf/app.dev.conf Normal file
View File

@@ -0,0 +1,37 @@
appname = casdoor
httpport = 8000
runmode = dev
copyrequestbody = true
driverName = postgres
dataSourceName = "user=casdoor password=casdoor_dev host=localhost port=5434 sslmode=disable dbname=casdoor"
dbName = casdoor
tableNamePrefix =
showSql = false
redisEndpoint =
defaultStorageProvider =
isCloudIntranet = false
authState = "casdoor"
socks5Proxy = ""
verificationCodeTimeout = 10
initScore = 0
logPostOnly = true
isUsernameLowered = false
origin = "http://localhost:8000"
originFrontend = "http://localhost:7001"
staticBaseUrl = "https://cdn.casbin.org"
isDemoMode = false
batchSize = 100
showGithubCorner = false
forceLanguage = ""
defaultLanguage = "ru"
enableErrorMask = false
enableGzip = true
ldapServerPort = 389
ldapsServerPort = 636
radiusServerPort = 1812
radiusDefaultOrganization = "built-in"
radiusSecret = "secret"
quota = {"organization": -1, "user": -1, "application": -1, "provider": -1}
logConfig = {"adapter":"file", "filename": "logs/casdoor.log", "maxdays":99999, "perm":"0770"}
initDataNewOnly = false
initDataFile = "./init_data.json"

View File

@@ -374,6 +374,10 @@ func (c *ApiController) Logout() {
return
}
// Retrieve application and token before clearing the session
application := c.GetSessionApplication()
sessionToken := c.GetSessionToken()
c.ClearUserSession()
c.ClearTokenSession()
@@ -382,7 +386,9 @@ func (c *ApiController) Logout() {
return
}
application := c.GetSessionApplication()
// Propagate logout to external Custom OAuth2 providers
object.InvokeCustomProviderLogout(application, sessionToken)
if application == nil || application.Name == "app-built-in" || application.HomepageUrl == "" {
c.ResponseOk(user)
return
@@ -427,6 +433,9 @@ func (c *ApiController) Logout() {
return
}
// Propagate logout to external Custom OAuth2 providers
object.InvokeCustomProviderLogout(application, accessToken)
if redirectUri == "" {
c.ResponseOk()
return
@@ -469,6 +478,10 @@ func (c *ApiController) SsoLogout() {
logoutAll := c.Ctx.Input.Query("logoutAll")
logoutAllSessions := logoutAll == "" || logoutAll == "true" || logoutAll == "1"
// Retrieve application and token before clearing the session
ssoApplication := c.GetSessionApplication()
ssoSessionToken := c.GetSessionToken()
c.ClearUserSession()
c.ClearTokenSession()
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
@@ -548,6 +561,9 @@ func (c *ApiController) SsoLogout() {
}
}
// Propagate logout to external Custom OAuth2 providers
object.InvokeCustomProviderLogout(ssoApplication, ssoSessionToken)
c.ResponseOk()
}

View File

@@ -57,7 +57,9 @@ func (c *ApiController) Enforce() {
return
}
var request []string
// Accept both plain string arrays (["alice","data1","read"]) and mixed arrays
// with JSON objects ([{"DivisionGuid":"x"}, "resource", "read"]) for ABAC support.
var request []interface{}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &request)
if err != nil {
c.ResponseError(err.Error())
@@ -74,8 +76,8 @@ func (c *ApiController) Enforce() {
res := []bool{}
keyRes := []string{}
// type transformation
interfaceRequest := util.StringToInterfaceArray(request)
// Convert elements: JSON-object strings and maps become anonymous structs for ABAC.
interfaceRequest := util.InterfaceToEnforceArray(request)
enforceResult, err := enforcer.Enforce(interfaceRequest...)
if err != nil {
@@ -197,7 +199,8 @@ func (c *ApiController) BatchEnforce() {
return
}
var requests [][]string
// Accept both string arrays and mixed arrays with JSON objects for ABAC support.
var requests [][]interface{}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &requests)
if err != nil {
c.ResponseError(err.Error())
@@ -214,8 +217,8 @@ func (c *ApiController) BatchEnforce() {
res := [][]bool{}
keyRes := []string{}
// type transformation
interfaceRequests := util.StringToInterfaceArray2d(requests)
// Convert elements: JSON-object strings and maps become anonymous structs for ABAC.
interfaceRequests := util.InterfaceToEnforceArray2d(requests)
enforceResult, err := enforcer.BatchEnforce(interfaceRequests)
if err != nil {

View File

@@ -88,6 +88,25 @@ func (c *ApiController) GetEntry() {
c.ResponseOk(entry)
}
// GetOpenClawSessionGraph
// @Title GetOpenClawSessionGraph
// @Tag Entry API
// @Description get OpenClaw session graph
// @Param id query string true "The id ( owner/name ) of the entry"
// @Success 200 {object} object.OpenClawSessionGraph The Response object
// @router /get-openclaw-session-graph [get]
func (c *ApiController) GetOpenClawSessionGraph() {
id := c.Ctx.Input.Query("id")
graph, err := object.GetOpenClawSessionGraph(id)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(graph)
}
// UpdateEntry
// @Title UpdateEntry
// @Tag Entry API

View File

@@ -32,7 +32,7 @@ import (
// @Param owner path string true "The owner name of the server"
// @Param name path string true "The name of the server"
// @Success 200 {object} mcp.McpResponse The Response object
// @router /server/:owner/:name [post]
// @router /server/:owner/:name [get,post]
func (c *ApiController) ProxyServer() {
owner := c.Ctx.Input.Param(":owner")
name := c.Ctx.Input.Param(":name")

View File

@@ -250,6 +250,9 @@ func (c *ApiController) GetOAuthToken() {
}
}
// Extract DPoP proof header (RFC 9449). Empty string when DPoP is not used.
dpopProof := c.Ctx.Request.Header.Get("DPoP")
host := c.Ctx.Request.Host
if deviceCode != "" {
deviceAuthCache, ok := object.DeviceAuthMap.Load(deviceCode)
@@ -291,7 +294,7 @@ func (c *ApiController) GetOAuthToken() {
username = deviceAuthCacheCast.UserName
}
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource)
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource, dpopProof)
if err != nil {
c.ResponseError(err.Error())
return
@@ -340,7 +343,8 @@ func (c *ApiController) RefreshToken() {
return
}
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
dpopProof := c.Ctx.Request.Header.Get("DPoP")
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host, dpopProof)
if err != nil {
c.ResponseError(err.Error())
return
@@ -556,6 +560,11 @@ func (c *ApiController) IntrospectToken() {
introspectionResponse.TokenType = token.TokenType
introspectionResponse.ClientId = application.ClientId
// Expose DPoP key binding in the introspection response (RFC 9449 §8).
if token.DPoPJkt != "" {
introspectionResponse.Cnf = &object.DPoPConfirmation{JKT: token.DPoPJkt}
}
}
c.Data["json"] = introspectionResponse

View File

@@ -252,6 +252,10 @@ func (c *ApiController) SendVerificationCode() {
return
}
if vform.CaptchaToken != "" {
enableCaptcha = true
}
// Only verify CAPTCHA if it should be enabled
if enableCaptcha {
captchaProvider, err := object.GetCaptchaProviderByApplication(vform.ApplicationId, "false", c.GetAcceptLanguage())

20
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
ports:
- "5434:5432"
environment:
POSTGRES_USER: casdoor
POSTGRES_PASSWORD: casdoor_dev
POSTGRES_DB: casdoor
volumes:
- casdoor_pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U casdoor"]
interval: 5s
timeout: 5s
retries: 5
volumes:
casdoor_pg_data:

230
i18n/locales/ru/data.json Normal file
View File

@@ -0,0 +1,230 @@
{
"account": {
"Failed to add user": "Не удалось добавить пользователя",
"Get init score failed, error: %w": "Не удалось получить исходный балл, ошибка: %w",
"The application does not allow to sign up new account": "Приложение не позволяет зарегистрироваться новому аккаунту"
},
"auth": {
"Challenge method should be S256": "Метод проверки должен быть S256",
"DeviceCode Invalid": "Неверный код устройства",
"Failed to create user, user information is invalid: %s": "Не удалось создать пользователя, информация о пользователе недействительна: %s",
"Failed to login in: %s": "Не удалось войти в систему: %s",
"Invalid token": "Недействительный токен",
"State expected: %s, but got: %s": "Ожидался статус: %s, но получен: %s",
"The account for provider: %s and username: %s (%s) does not exist and is not allowed to sign up as new account via %s, please use another way to sign up": "Аккаунт провайдера: %s и имя пользователя: %s (%s) не существует и не может быть зарегистрирован через %s, пожалуйста, используйте другой способ регистрации",
"The account for provider: %s and username: %s (%s) does not exist and is not allowed to sign up as new account, please contact your IT support": "Аккаунт для провайдера: %s и имя пользователя: %s (%s) не существует и не может быть зарегистрирован как новый аккаунт. Пожалуйста, обратитесь в службу поддержки IT",
"The account for provider: %s and username: %s (%s) is already linked to another account: %s (%s)": "Аккаунт поставщика: %s и имя пользователя: %s (%s) уже связаны с другим аккаунтом: %s (%s)",
"The application: %s does not exist": "Приложение: %s не существует",
"The application: %s has disabled users to signin": "Приложение: %s отключило вход пользователей",
"The group: %s does not exist": "Группа: %s не существует",
"The login method: login with LDAP is not enabled for the application": "Метод входа через LDAP отключен для этого приложения",
"The login method: login with SMS is not enabled for the application": "Метод входа через SMS отключен для этого приложения",
"The login method: login with email is not enabled for the application": "Метод входа через электронную почту отключен для этого приложения",
"The login method: login with face is not enabled for the application": "Метод входа через распознавание лица отключен для этого приложения",
"The login method: login with password is not enabled for the application": "Метод входа: вход с паролем не включен для приложения",
"The order: %s does not exist": "The order: %s does not exist",
"The organization: %s does not exist": "Организация: %s не существует",
"The organization: %s has disabled users to signin": "Организация: %s отключила вход пользователей",
"The plan: %s does not exist": "План: %s не существует",
"The pricing: %s does not exist": "Тариф: %s не существует",
"The pricing: %s does not have plan: %s": "Тариф: %s не имеет план: %s",
"The provider: %s does not exist": "Провайдер: %s не существует",
"The provider: %s is not enabled for the application": "Провайдер: %s не включен для приложения",
"Unauthorized operation": "Несанкционированная операция",
"Unknown authentication type (not password or provider), form = %s": "Неизвестный тип аутентификации (не пароль и не провайдер), форма = %s",
"User's tag: %s is not listed in the application's tags": "Тег пользователя: %s отсутствует в списке тегов приложения",
"UserCode Expired": "Срок действия кода пользователя истек",
"UserCode Invalid": "Неверный код пользователя",
"paid-user %s does not have active or pending subscription and the application: %s does not have default pricing": "Платный пользователь %s не имеет активной или ожидающей подписки, а приложение %s не имеет цены по умолчанию",
"the application for user %s is not found": "Приложение для пользователя %s не найдено",
"the organization: %s is not found": "Организация: %s не найдена"
},
"cas": {
"Service %s and %s do not match": "Сервисы %s и %s не совпадают"
},
"check": {
"%s does not meet the CIDR format requirements: %s": "%s не соответствует требованиям формата CIDR: %s",
"Affiliation cannot be blank": "Принадлежность не может быть пустым значением",
"CIDR for IP: %s should not be empty": "CIDR для IP: %s не должен быть пустым",
"Default code does not match the code's matching rules": "Код по умолчанию не соответствует правилам соответствия кода",
"DisplayName cannot be blank": "Имя отображения не может быть пустым",
"DisplayName is not valid real name": "DisplayName не является действительным именем",
"Email already exists": "Электронная почта уже существует",
"Email cannot be empty": "Электронная почта не может быть пустой",
"Email is invalid": "Адрес электронной почты недействительный",
"Empty username.": "Пустое имя пользователя.",
"Face data does not exist, cannot log in": "Данные лица отсутствуют, вход невозможен",
"Face data mismatch": "Несоответствие данных лица",
"Failed to parse client IP: %s": "Не удалось разобрать IP клиента: %s",
"FirstName cannot be blank": "Имя не может быть пустым",
"Guest users must upgrade their account by setting a username and password before they can sign in directly": "Guest users must upgrade their account by setting a username and password before they can sign in directly",
"Invitation code cannot be blank": "Код приглашения не может быть пустым",
"Invitation code exhausted": "Код приглашения исчерпан",
"Invitation code is invalid": "Код приглашения недействителен",
"Invitation code suspended": "Код приглашения приостановлен",
"LastName cannot be blank": "Фамилия не может быть пустой",
"Multiple accounts with same uid, please check your ldap server": "Множественные учетные записи с тем же UID. Пожалуйста, проверьте свой сервер LDAP",
"Organization does not exist": "Организация не существует",
"Password cannot be empty": "Пароль не может быть пустым",
"Phone already exists": "Телефон уже существует",
"Phone cannot be empty": "Телефон не может быть пустым",
"Phone number is invalid": "Номер телефона является недействительным",
"Please register using the email corresponding to the invitation code": "Пожалуйста, зарегистрируйтесь, используя электронную почту, соответствующую коду приглашения",
"Please register using the phone corresponding to the invitation code": "Пожалуйста, зарегистрируйтесь, используя номер телефона, соответствующий коду приглашения",
"Please register using the username corresponding to the invitation code": "Пожалуйста, зарегистрируйтесь, используя имя пользователя, соответствующее коду приглашения",
"Session outdated, please login again": "Сессия устарела, пожалуйста, войдите снова",
"The invitation code has already been used": "Код приглашения уже использован",
"The password must contain at least one special character": "Пароль должен содержать хотя бы один специальный символ",
"The password must contain at least one uppercase letter, one lowercase letter and one digit": "Пароль должен содержать хотя бы одну заглавную букву, одну строчную букву и одну цифру",
"The password must have at least 6 characters": "Пароль должен содержать не менее 6 символов",
"The password must have at least 8 characters": "Пароль должен содержать не менее 8 символов",
"The password must not contain any repeated characters": "Пароль не должен содержать повторяющихся символов",
"The user has been deleted and cannot be used to sign in, please contact the administrator": "Пользователь был удален и не может быть использован для входа, пожалуйста, свяжитесь с администратором",
"The user is forbidden to sign in, please contact the administrator": "Пользователю запрещен вход, пожалуйста, обратитесь к администратору",
"The user: %s doesn't exist in LDAP server": "Пользователь: %s не существует на сервере LDAP",
"The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline.": "Имя пользователя может состоять только из буквенно-цифровых символов, нижних подчеркиваний или дефисов, не может содержать последовательные дефисы или подчеркивания, а также не может начинаться или заканчиваться на дефис или подчеркивание.",
"The value \"%s\" for account field \"%s\" doesn't match the account item regex": "The value \"%s\" for account field \"%s\" doesn't match the account item regex",
"The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"": "The value \"%s\" for signup field \"%s\" doesn't match the signup item regex of the application \"%s\"",
"Username already exists": "Имя пользователя уже существует",
"Username cannot be an email address": "Имя пользователя не может быть адресом электронной почты",
"Username cannot contain white spaces": "Имя пользователя не может содержать пробелы",
"Username cannot start with a digit": "Имя пользователя не может начинаться с цифры",
"Username is too long (maximum is 255 characters).": "Имя пользователя слишком длинное (максимальная длина - 255 символов).",
"Username must have at least 2 characters": "Имя пользователя должно содержать не менее 2 символов",
"Username supports email format. Also The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline. Also pay attention to the email format.": "Имя пользователя поддерживает формат электронной почты. Также имя пользователя может содержать только буквенно-цифровые символы, подчеркивания или дефисы, не может иметь последовательных дефисов или подчеркиваний и не может начинаться или заканчиваться дефисом или подчеркиванием. Также обратите внимание на формат электронной почты.",
"You have entered the wrong password or code too many times, please wait for %d minutes and try again": "Вы ввели неправильный пароль или код слишком много раз, пожалуйста, подождите %d минут и попробуйте снова",
"Your IP address: %s has been banned according to the configuration of: ": "Ваш IP-адрес: %s заблокирован согласно конфигурации: ",
"Your password has expired. Please reset your password by clicking \"Forgot password\"": "Срок действия вашего пароля истек. Пожалуйста, сбросьте пароль, нажав \"Забыли пароль\"",
"Your region is not allow to signup by phone": "Ваш регион не разрешает регистрацию по телефону",
"password or code is incorrect": "пароль или код неверны",
"password or code is incorrect, you have %s remaining chances": "Неправильный пароль или код, у вас осталось %s попыток",
"unsupported password type: %s": "неподдерживаемый тип пароля: %s"
},
"enforcer": {
"the adapter: %s is not found": "адаптер: %s не найден"
},
"general": {
"Failed to import groups": "Не удалось импортировать группы",
"Failed to import users": "Не удалось импортировать пользователей",
"Insufficient balance: new balance %v would be below credit limit %v": "Insufficient balance: new balance %v would be below credit limit %v",
"Insufficient balance: new organization balance %v would be below credit limit %v": "Insufficient balance: new organization balance %v would be below credit limit %v",
"Missing parameter": "Отсутствующий параметр",
"Only admin user can specify user": "Только администратор может указать пользователя",
"Please login first": "Пожалуйста, сначала войдите в систему",
"The LDAP: %s does not exist": "Группа LDAP: %s не существует",
"The organization: %s should have one application at least": "Организация: %s должна иметь хотя бы одно приложение",
"The syncer: %s does not exist": "The syncer: %s does not exist",
"The user: %s doesn't exist": "Пользователь %s не существует",
"The user: %s is not found": "The user: %s is not found",
"User is required for User category transaction": "User is required for User category transaction",
"Wrong userId": "Неверный идентификатор пользователя",
"don't support captchaProvider: ": "неподдерживаемый captchaProvider: ",
"this operation is not allowed in demo mode": "эта операция недоступна в демонстрационном режиме",
"this operation requires administrator to perform": "эта операция требует прав администратора"
},
"invitation": {
"Invitation %s does not exist": "Приглашение %s не существует"
},
"ldap": {
"Ldap server exist": "LDAP-сервер существует"
},
"link": {
"Please link first": "Пожалуйста, сначала установите ссылку",
"This application has no providers": "Это приложение не имеет провайдеров",
"This application has no providers of type": "Это приложение не имеет провайдеров данного типа",
"This provider can't be unlinked": "Этот провайдер не может быть отсоединен",
"You are not the global admin, you can't unlink other users": "Вы не являетесь глобальным администратором, вы не можете отсоединять других пользователей",
"You can't unlink yourself, you are not a member of any application": "Вы не можете отвязаться, так как вы не являетесь участником никакого приложения"
},
"organization": {
"Only admin can modify the %s.": "Только администратор может изменять %s.",
"The %s is immutable.": "%s неизменяемый.",
"Unknown modify rule %s.": "Неизвестное изменение правила %s.",
"adding a new user to the 'built-in' organization is currently disabled. Please note: all users in the 'built-in' organization are global administrators in Casdoor. Refer to the docs: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. If you still wish to create a user for the 'built-in' organization, go to the organization's settings page and enable the 'Has privilege consent' option.": "Добавление нового пользователя в организацию «built-in» (встроенная) в настоящее время отключено. Обратите внимание: все пользователи в организации «built-in» являются глобальными администраторами в Casdoor. См. документацию: https://casdoor.org/docs/basic/core-concepts#how-does-casdoor-manage-itself. Если вы все еще хотите создать пользователя для организации «built-in», перейдите на страницу настроек организации и включите опцию «Имеет согласие на привилегии»."
},
"permission": {
"The permission: \"%s\" doesn't exist": "The permission: \"%s\" doesn't exist"
},
"product": {
"Product list cannot be empty": "Product list cannot be empty"
},
"provider": {
"Failed to initialize ID Verification provider": "Failed to initialize ID Verification provider",
"Invalid application id": "Неверный идентификатор приложения",
"No ID Verification provider configured": "No ID Verification provider configured",
"Provider is not an ID Verification provider": "Provider is not an ID Verification provider",
"the provider: %s does not exist": "Провайдер: %s не существует"
},
"resource": {
"User is nil for tag: avatar": "Пользователь равен нулю для тега: аватар",
"Username or fullFilePath is empty: username = %s, fullFilePath = %s": "Имя пользователя или полный путь к файлу пусты: имя_пользователя = %s, полный_путь_к_файлу = %s"
},
"saml": {
"Application %s not found": "Приложение %s не найдено"
},
"saml_sp": {
"provider %s's category is not SAML": "Категория провайдера %s не является SAML"
},
"service": {
"Empty parameters for emailForm: %v": "Пустые параметры для emailForm: %v",
"Invalid Email receivers: %s": "Некорректные получатели электронной почты: %s",
"Invalid phone receivers: %s": "Некорректные получатели телефонных звонков: %s"
},
"session": {
"session id %s is the current session and cannot be deleted": "session id %s is the current session and cannot be deleted"
},
"storage": {
"The objectKey: %s is not allowed": "Объект «objectKey: %s» не разрешен",
"The provider type: %s is not supported": "Тип провайдера: %s не поддерживается"
},
"subscription": {
"Error": "Ошибка"
},
"ticket": {
"Ticket not found": "Ticket not found"
},
"token": {
"Grant_type: %s is not supported in this application": "Тип предоставления: %s не поддерживается в данном приложении",
"Invalid application or wrong clientSecret": "Недействительное приложение или неправильный clientSecret",
"Invalid client_id": "Недействительный идентификатор клиента",
"Redirect URI: %s doesn't exist in the allowed Redirect URI list": "URI перенаправления: %s не существует в списке разрешенных URI перенаправления",
"Token not found, invalid accessToken": "Токен не найден, недействительный accessToken"
},
"user": {
"Display name cannot be empty": "Отображаемое имя не может быть пустым",
"ID card information and real name are required": "ID card information and real name are required",
"Identity verification failed": "Identity verification failed",
"MFA email is enabled but email is empty": "MFA по электронной почте включен, но электронная почта не указана",
"MFA phone is enabled but phone number is empty": "MFA по телефону включен, но номер телефона не указан",
"New password cannot contain blank space.": "Новый пароль не может содержать пробелы.",
"No application found for user": "No application found for user",
"The new password must be different from your current password": "Новый пароль должен отличаться от текущего пароля",
"User is already verified": "Пользователь уже подтвержден",
"the user's owner and name should not be empty": "владелец и имя пользователя не должны быть пустыми"
},
"util": {
"No application is found for userId: %s": "Не найдено заявки для пользователя с идентификатором: %s",
"No provider for category: %s is found for application: %s": "Нет провайдера для категории: %s для приложения: %s",
"The provider: %s is not found": "Поставщик: %s не найден"
},
"verification": {
"Invalid captcha provider.": "Недействительный поставщик CAPTCHA.",
"Phone number is invalid in your region %s": "Номер телефона недействителен в вашем регионе %s",
"The forgot password feature is disabled": "The forgot password feature is disabled",
"The verification code has already been used!": "Код подтверждения уже использован!",
"The verification code has not been sent yet!": "Код подтверждения еще не был отправлен!",
"Turing test failed.": "Тест Тьюринга не удался.",
"Unable to get the email modify rule.": "Невозможно получить правило изменения электронной почты.",
"Unable to get the phone modify rule.": "Невозможно получить правило изменения телефона.",
"Unknown type": "Неизвестный тип",
"Wrong verification code!": "Неправильный код подтверждения!",
"You should verify your code in %d min!": "Вы должны проверить свой код через %d минут!",
"please add a SMS provider to the \"Providers\" list for the application: %s": "Пожалуйста, добавьте SMS-провайдера в список \"Провайдеры\" для приложения: %s",
"please add an Email provider to the \"Providers\" list for the application: %s": "пожалуйста, добавьте Email-провайдера в список \\\"Провайдеры\\\" для приложения: %s",
"the user does not exist, please sign up first": "Пользователь не существует, пожалуйста, сначала зарегистрируйтесь"
},
"webauthn": {
"Found no credentials for this user": "Учетные данные для этого пользователя не найдены",
"Please call WebAuthnSigninBegin first": "Пожалуйста, сначала вызовите WebAuthnSigninBegin"
}
}

View File

@@ -54,6 +54,7 @@
"pt",
"tr",
"pl",
"ru",
"uk"
],
"masterPassword": "",

View File

@@ -90,6 +90,7 @@ func main() {
web.SetStaticPath("/swagger", "swagger")
web.SetStaticPath("/files", "files")
// https://studygolang.com/articles/2303
web.InsertFilter("*", web.BeforeStatic, routers.RequestBodyFilter)
web.InsertFilter("*", web.BeforeRouter, routers.StaticFilter)
web.InsertFilter("*", web.BeforeRouter, routers.AutoSigninFilter)
web.InsertFilter("*", web.BeforeRouter, routers.CorsFilter)

View File

@@ -43,11 +43,21 @@ func GetServerTools(owner, name, url, token string) ([]*mcpsdk.Tool, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*10)
defer cancel()
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: util.GetId(owner, name), Version: "1.0.0"}, nil)
if token != "" {
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url, HTTPClient: httpClient}, nil)
if strings.HasSuffix(url, "sse") {
if token != "" {
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url, HTTPClient: httpClient}, nil)
} else {
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url}, nil)
}
} else {
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url}, nil)
if token != "" {
httpClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}))
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url, HTTPClient: httpClient}, nil)
} else {
session, err = client.Connect(ctx, &mcpsdk.StreamableClientTransport{Endpoint: url}, nil)
}
}
if err != nil {

View File

@@ -161,6 +161,9 @@ func (adapter *Adapter) InitAdapter() error {
}
} else {
driverName = adapter.DatabaseType
if driverName == "sqlite3" {
driverName = "sqlite"
}
switch driverName {
case "mssql":
dataSourceName = fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s", adapter.User,
@@ -174,7 +177,7 @@ func (adapter *Adapter) InitAdapter() error {
case "CockroachDB":
dataSourceName = fmt.Sprintf("user=%s password=%s host=%s port=%d sslmode=disable dbname=%s serial_normalization=virtual_sequence",
adapter.User, adapter.Password, adapter.Host, adapter.Port, adapter.Database)
case "sqlite3":
case "sqlite":
dataSourceName = fmt.Sprintf("file:%s", adapter.Host)
default:
return fmt.Errorf("unsupported database type: %s", adapter.DatabaseType)

View File

@@ -64,6 +64,53 @@ func (a *SafeAdapter) RemovePolicies(sec string, ptype string, rules [][]string)
return err
}
func (a *SafeAdapter) UpdatePolicy(sec string, ptype string, oldRule []string, newRule []string) error {
oldLine := a.buildCasbinRule(ptype, oldRule)
newLine := a.buildCasbinRule(ptype, newRule)
session := a.engine.NewSession()
defer session.Close()
if a.tableName != "" {
session = session.Table(a.tableName)
}
_, err := session.
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?",
oldLine.Ptype, oldLine.V0, oldLine.V1, oldLine.V2, oldLine.V3, oldLine.V4, oldLine.V5).
MustCols("ptype", "v0", "v1", "v2", "v3", "v4", "v5").
Update(newLine)
return err
}
func (a *SafeAdapter) UpdatePolicies(sec string, ptype string, oldRules [][]string, newRules [][]string) error {
_, err := a.engine.Transaction(func(tx *xorm.Session) (interface{}, error) {
for i, oldRule := range oldRules {
oldLine := a.buildCasbinRule(ptype, oldRule)
newLine := a.buildCasbinRule(ptype, newRules[i])
var session *xorm.Session
if a.tableName != "" {
session = tx.Table(a.tableName)
} else {
session = tx
}
_, err := session.
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ? AND v3 = ? AND v4 = ? AND v5 = ?",
oldLine.Ptype, oldLine.V0, oldLine.V1, oldLine.V2, oldLine.V3, oldLine.V4, oldLine.V5).
MustCols("ptype", "v0", "v1", "v2", "v3", "v4", "v5").
Update(newLine)
if err != nil {
return nil, err
}
}
return nil, nil
})
return err
}
func (a *SafeAdapter) buildCasbinRule(ptype string, rule []string) *xormadapter.CasbinRule {
line := xormadapter.CasbinRule{Ptype: ptype}

View File

@@ -587,7 +587,12 @@ func CheckLoginPermission(userId string, application *Application) (bool, error)
return true, nil
}
permissions, err := GetPermissions(application.Organization)
permissionOrganization := application.Organization
if application.IsShared {
permissionOrganization = owner
}
permissions, err := GetPermissions(permissionOrganization)
if err != nil {
return false, err
}

View File

@@ -318,10 +318,12 @@ func GetGroupUserCount(groupId string, field, value string) (int64, error) {
return int64(len(names)), nil
} else {
tableNamePrefix := conf.GetConfigString("tableNamePrefix")
return ormer.Engine.Table(tableNamePrefix+"user").
Where("owner = ?", owner).In("name", names).
And(fmt.Sprintf("user.%s like ?", util.CamelToSnakeCase(field)), "%"+value+"%").
Count()
session := ormer.Engine.Table(tableNamePrefix+"user").
Where("owner = ?", owner).In("name", names)
if util.FilterField(field) {
session = session.And(fmt.Sprintf("user.%s like ?", util.CamelToSnakeCase(field)), "%"+value+"%")
}
return session.Count()
}
}
@@ -345,7 +347,7 @@ func GetPaginationGroupUsers(groupId string, offset, limit int, field, value, so
session.Limit(limit, offset)
}
if field != "" && value != "" {
if field != "" && value != "" && util.FilterField(field) {
session = session.And(fmt.Sprintf("%s.%s like ?", prefixedUserTable, util.CamelToSnakeCase(field)), "%"+value+"%")
}

View File

@@ -134,7 +134,7 @@ func initBuiltInOrganization() bool {
DefaultAvatar: fmt.Sprintf("%s/img/casbin.svg", conf.GetConfigString("staticBaseUrl")),
UserTypes: []string{},
Tags: []string{},
Languages: []string{"en", "es", "fr", "de", "ja", "zh", "vi", "pt", "tr", "pl", "uk"},
Languages: []string{"en", "es", "fr", "de", "ja", "zh", "vi", "pt", "tr", "pl", "ru", "uk"},
InitScore: 2000,
AccountItems: getBuiltInAccountItems(),
EnableSoftDeletion: false,

View File

@@ -39,30 +39,37 @@ func InitLogProviders() {
if p.Category != "Log" {
continue
}
if p.State == "Disabled" {
continue
}
switch p.Type {
case "System Log", "SELinux Log":
startLogCollector(p)
case "Agent":
if p.SubType == "OpenClaw" {
startOpenClawProvider(p)
startOpenClawTranscriptSync(p)
}
}
}
}
func stopCollector(id string) {
runningCollectorsMu.Lock()
defer runningCollectorsMu.Unlock()
if existing, ok := runningCollectors[id]; ok {
_ = existing.Stop()
delete(runningCollectors, id)
}
}
// startLogCollector starts a pull-based log collector (System Log / SELinux Log)
// for the given provider. If a collector for the same provider is already
// running it is stopped first.
func startLogCollector(provider *Provider) {
runningCollectorsMu.Lock()
defer runningCollectorsMu.Unlock()
id := provider.GetId()
if existing, ok := runningCollectors[id]; ok {
_ = existing.Stop()
delete(runningCollectors, id)
}
stopCollector(id)
lp, err := log.GetLogProvider(provider.Type, provider.Host, provider.Port, provider.Title)
if err != nil {
@@ -93,21 +100,16 @@ func startLogCollector(provider *Provider) {
return
}
runningCollectorsMu.Lock()
defer runningCollectorsMu.Unlock()
runningCollectors[id] = lp
}
// startOpenClawProvider registers an OpenClaw provider in runningCollectors so
// that incoming OTLP requests can be routed to it by IP.
func startOpenClawProvider(provider *Provider) {
runningCollectorsMu.Lock()
defer runningCollectorsMu.Unlock()
id := provider.GetId()
if existing, ok := runningCollectors[id]; ok {
_ = existing.Stop()
delete(runningCollectors, id)
}
stopCollector(id)
lp, err := GetLogProviderFromProvider(provider)
if err != nil {
@@ -115,15 +117,53 @@ func startOpenClawProvider(provider *Provider) {
return
}
runningCollectorsMu.Lock()
defer runningCollectorsMu.Unlock()
runningCollectors[id] = lp
}
func refreshLogProviderRuntime(oldID string, provider *Provider) {
if provider == nil {
if oldID != "" {
stopLogProviderRuntime(oldID)
}
return
}
if oldID != "" {
stopLogProviderRuntime(oldID)
}
if provider.Category != "Log" {
return
}
if provider.State == "Disabled" {
return
}
switch provider.Type {
case "System Log", "SELinux Log":
startLogCollector(provider)
case "Agent":
if provider.SubType == "OpenClaw" {
startOpenClawProvider(provider)
startOpenClawTranscriptSync(provider)
}
}
}
func stopLogProviderRuntime(providerID string) {
if providerID == "" {
return
}
stopCollector(providerID)
stopOpenClawTranscriptSync(providerID)
}
// GetOpenClawProviderByIP returns the running OpenClawProvider whose Host field
// matches clientIP, or whose Host is empty (meaning any IP is allowed).
// Returns nil if no matching provider is registered.
func GetOpenClawProviderByIP(clientIP string) (*log.OpenClawProvider, error) {
providers := []*Provider{}
err := ormer.Engine.Where("category = ? AND type = ? AND sub_type = ?", "Log", "Agent", "OpenClaw").Find(&providers)
err := ormer.Engine.Where("category = ? AND type = ? AND sub_type = ? AND (state = ? OR state = ?)", "Log", "Agent", "OpenClaw", "Enabled", "").Find(&providers)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,804 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/casdoor/casdoor/util"
)
type OpenClawSessionGraph struct {
Nodes []*OpenClawSessionGraphNode `json:"nodes"`
Edges []*OpenClawSessionGraphEdge `json:"edges"`
Stats OpenClawSessionGraphStats `json:"stats"`
}
type OpenClawSessionGraphNode struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
OriginalParentID string `json:"originalParentId,omitempty"`
EntryID string `json:"entryId,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
Kind string `json:"kind"`
Timestamp string `json:"timestamp"`
Summary string `json:"summary"`
Tool string `json:"tool,omitempty"`
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
OK *bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
Text string `json:"text,omitempty"`
IsAnchor bool `json:"isAnchor"`
}
type OpenClawSessionGraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
}
type OpenClawSessionGraphStats struct {
TotalNodes int `json:"totalNodes"`
TaskCount int `json:"taskCount"`
ToolCallCount int `json:"toolCallCount"`
ToolResultCount int `json:"toolResultCount"`
FinalCount int `json:"finalCount"`
FailedCount int `json:"failedCount"`
}
type openClawSessionGraphBuilder struct {
graph *OpenClawSessionGraph
nodes map[string]*OpenClawSessionGraphNode
}
type openClawSessionGraphRecord struct {
Entry *Entry
Payload openClawBehaviorPayload
}
type openClawAssistantStepGroup struct {
ParentID string
Timestamp string
ToolNames []string
Text string
}
func GetOpenClawSessionGraph(id string) (*OpenClawSessionGraph, error) {
entry, err := GetEntry(id)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
if strings.TrimSpace(entry.Type) != "session" {
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
}
provider, err := GetProvider(util.GetId(entry.Owner, entry.Provider))
if err != nil {
return nil, err
}
if provider != nil && !isOpenClawLogProvider(provider) {
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
}
anchorPayload, err := parseOpenClawSessionGraphPayload(entry)
if err != nil {
return nil, fmt.Errorf("failed to parse anchor entry %s: %w", entry.Name, err)
}
records, err := collectOpenClawSessionGraphRecords(entry, anchorPayload)
if err != nil {
return nil, fmt.Errorf("failed to load OpenClaw session entries from database: %w", err)
}
return buildOpenClawSessionGraphFromEntries(anchorPayload, entry.Name, records), nil
}
func parseOpenClawSessionGraphPayload(entry *Entry) (openClawBehaviorPayload, error) {
if entry == nil {
return openClawBehaviorPayload{}, fmt.Errorf("entry is nil")
}
message := strings.TrimSpace(entry.Message)
if message == "" {
return openClawBehaviorPayload{}, fmt.Errorf("message is empty")
}
var payload openClawBehaviorPayload
if err := json.Unmarshal([]byte(message), &payload); err != nil {
return openClawBehaviorPayload{}, err
}
payload.SessionID = strings.TrimSpace(payload.SessionID)
payload.EntryID = strings.TrimSpace(payload.EntryID)
payload.ToolCallID = strings.TrimSpace(payload.ToolCallID)
payload.ParentID = strings.TrimSpace(payload.ParentID)
payload.Kind = strings.TrimSpace(payload.Kind)
payload.Summary = strings.TrimSpace(payload.Summary)
payload.Tool = strings.TrimSpace(payload.Tool)
payload.Query = strings.TrimSpace(payload.Query)
payload.URL = strings.TrimSpace(payload.URL)
payload.Path = strings.TrimSpace(payload.Path)
payload.Error = strings.TrimSpace(payload.Error)
payload.AssistantText = strings.TrimSpace(payload.AssistantText)
payload.Text = strings.TrimSpace(payload.Text)
payload.Timestamp = strings.TrimSpace(firstNonEmpty(payload.Timestamp, entry.CreatedTime))
if payload.SessionID == "" {
return openClawBehaviorPayload{}, fmt.Errorf("sessionId is empty")
}
if payload.EntryID == "" {
return openClawBehaviorPayload{}, fmt.Errorf("entryId is empty")
}
if payload.Kind == "" {
return openClawBehaviorPayload{}, fmt.Errorf("kind is empty")
}
return payload, nil
}
func collectOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload) ([]openClawSessionGraphRecord, error) {
if anchorEntry == nil {
return nil, fmt.Errorf("anchor entry is nil")
}
entries := []*Entry{}
query := ormer.Engine.Where("owner = ? and type = ?", anchorEntry.Owner, "session")
if providerName := strings.TrimSpace(anchorEntry.Provider); providerName != "" {
query = query.And("provider = ?", providerName)
}
if err := query.
Asc("created_time").
Asc("name").
Find(&entries); err != nil {
return nil, err
}
return filterOpenClawSessionGraphRecords(anchorEntry, anchorPayload, entries), nil
}
func filterOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload, entries []*Entry) []openClawSessionGraphRecord {
targetSessionID := strings.TrimSpace(anchorPayload.SessionID)
records := make([]openClawSessionGraphRecord, 0, len(entries)+1)
hasAnchor := false
for _, candidate := range entries {
if candidate == nil {
continue
}
payload, err := parseOpenClawSessionGraphPayload(candidate)
if err != nil {
continue
}
if payload.SessionID != targetSessionID {
continue
}
records = append(records, openClawSessionGraphRecord{
Entry: candidate,
Payload: payload,
})
if candidate.Owner == anchorEntry.Owner && candidate.Name == anchorEntry.Name {
hasAnchor = true
}
}
if !hasAnchor && anchorEntry != nil {
records = append(records, openClawSessionGraphRecord{
Entry: anchorEntry,
Payload: anchorPayload,
})
}
sort.SliceStable(records, func(i, j int) bool {
leftPayload := records[i].Payload
rightPayload := records[j].Payload
leftTimestamp := strings.TrimSpace(firstNonEmpty(leftPayload.Timestamp, records[i].Entry.CreatedTime))
rightTimestamp := strings.TrimSpace(firstNonEmpty(rightPayload.Timestamp, records[j].Entry.CreatedTime))
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
return timestampOrder < 0
}
return records[i].Entry.Name < records[j].Entry.Name
})
return records
}
func buildOpenClawSessionGraphFromEntries(anchorPayload openClawBehaviorPayload, anchorEntryName string, records []openClawSessionGraphRecord) *OpenClawSessionGraph {
builder := newOpenClawSessionGraphBuilder()
nodeIDsByEntryName := map[string][]string{}
assistantGroups := map[string]*openClawAssistantStepGroup{}
toolCallNodesByAssistant := map[string][]*OpenClawSessionGraphNode{}
toolCallNodeIDByToolCallID := map[string]string{}
allToolCallNodes := []*OpenClawSessionGraphNode{}
toolResultRecords := []openClawSessionGraphRecord{}
for _, record := range records {
payload := record.Payload
switch payload.Kind {
case "task":
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: payload.ParentID,
EntryID: payload.EntryID,
Kind: "task",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
case "tool_call":
nodeID := buildStoredToolCallNodeID(record.Entry, payload)
builder.addNode(&OpenClawSessionGraphNode{
ID: nodeID,
ParentID: payload.EntryID,
EntryID: payload.EntryID,
ToolCallID: payload.ToolCallID,
Kind: "tool_call",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Tool: payload.Tool,
Query: payload.Query,
URL: payload.URL,
Path: payload.Path,
Text: payload.Text,
})
storedNode := builder.nodes[nodeID]
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, nodeID)
if storedNode != nil {
toolCallNodesByAssistant[payload.EntryID] = append(toolCallNodesByAssistant[payload.EntryID], storedNode)
allToolCallNodes = append(allToolCallNodes, storedNode)
}
if payload.ToolCallID != "" && toolCallNodeIDByToolCallID[payload.ToolCallID] == "" {
toolCallNodeIDByToolCallID[payload.ToolCallID] = nodeID
}
group := assistantGroups[payload.EntryID]
if group == nil {
group = &openClawAssistantStepGroup{
ParentID: payload.ParentID,
Timestamp: payload.Timestamp,
}
assistantGroups[payload.EntryID] = group
}
group.ParentID = firstNonEmpty(group.ParentID, payload.ParentID)
group.Timestamp = chooseEarlierTimestamp(group.Timestamp, payload.Timestamp)
group.ToolNames = append(group.ToolNames, payload.Tool)
group.Text = firstNonEmpty(group.Text, payload.AssistantText)
case "tool_result":
toolResultRecords = append(toolResultRecords, record)
case "final":
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: payload.ParentID,
EntryID: payload.EntryID,
Kind: "final",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
}
}
assistantIDs := make([]string, 0, len(assistantGroups))
for entryID := range assistantGroups {
assistantIDs = append(assistantIDs, entryID)
}
sort.Strings(assistantIDs)
for _, assistantID := range assistantIDs {
group := assistantGroups[assistantID]
builder.addNode(&OpenClawSessionGraphNode{
ID: assistantID,
ParentID: strings.TrimSpace(group.ParentID),
EntryID: assistantID,
Kind: "assistant_step",
Timestamp: strings.TrimSpace(group.Timestamp),
Summary: buildAssistantStepSummary(group.ToolNames),
Text: strings.TrimSpace(group.Text),
})
}
for _, record := range toolResultRecords {
payload := record.Payload
parentID := strings.TrimSpace(payload.ParentID)
originalParentID := ""
if payload.ToolCallID != "" {
if matchedNodeID := strings.TrimSpace(toolCallNodeIDByToolCallID[payload.ToolCallID]); matchedNodeID != "" {
originalParentID = parentID
parentID = matchedNodeID
}
}
if parentID == strings.TrimSpace(payload.ParentID) {
if matchedNodeID := matchToolResultToolCallNodeID(payload, toolCallNodesByAssistant[payload.ParentID], allToolCallNodes); matchedNodeID != "" && matchedNodeID != parentID {
originalParentID = parentID
parentID = matchedNodeID
}
}
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: parentID,
OriginalParentID: originalParentID,
EntryID: payload.EntryID,
ToolCallID: payload.ToolCallID,
Kind: "tool_result",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Tool: payload.Tool,
Query: payload.Query,
URL: payload.URL,
Path: payload.Path,
OK: cloneBoolPointer(payload.OK),
Error: payload.Error,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
}
markStoredGraphAnchor(builder, anchorPayload, anchorEntryName, nodeIDsByEntryName)
return builder.finalize()
}
func appendGraphNodeEntryName(index map[string][]string, entry *Entry, nodeID string) {
if index == nil || entry == nil {
return
}
entryName := strings.TrimSpace(entry.Name)
nodeID = strings.TrimSpace(nodeID)
if entryName == "" || nodeID == "" {
return
}
for _, existingNodeID := range index[entryName] {
if existingNodeID == nodeID {
return
}
}
index[entryName] = append(index[entryName], nodeID)
}
func matchToolResultToolCallNodeID(payload openClawBehaviorPayload, assistantToolCalls []*OpenClawSessionGraphNode, allToolCalls []*OpenClawSessionGraphNode) string {
if matchedNodeID := chooseMatchingToolCallNodeID(payload, assistantToolCalls); matchedNodeID != "" {
return matchedNodeID
}
if len(assistantToolCalls) != len(allToolCalls) {
return chooseMatchingToolCallNodeID(payload, allToolCalls)
}
return ""
}
func chooseMatchingToolCallNodeID(payload openClawBehaviorPayload, candidates []*OpenClawSessionGraphNode) string {
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
seenNodeIDs := map[string]struct{}{}
for _, candidate := range candidates {
if candidate == nil || candidate.Kind != "tool_call" {
continue
}
if _, ok := seenNodeIDs[candidate.ID]; ok {
continue
}
seenNodeIDs[candidate.ID] = struct{}{}
filtered = append(filtered, candidate)
}
if len(filtered) == 0 {
return ""
}
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Query, func(node *OpenClawSessionGraphNode) string { return node.Query })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.URL, func(node *OpenClawSessionGraphNode) string { return node.URL })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Path, func(node *OpenClawSessionGraphNode) string { return node.Path })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Tool, func(node *OpenClawSessionGraphNode) string { return node.Tool })
if len(filtered) == 1 {
return filtered[0].ID
}
return ""
}
func refineToolCallCandidates(candidates []*OpenClawSessionGraphNode, expected string, selector func(node *OpenClawSessionGraphNode) string) []*OpenClawSessionGraphNode {
expected = strings.TrimSpace(expected)
if expected == "" {
return candidates
}
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
for _, candidate := range candidates {
if strings.TrimSpace(selector(candidate)) == expected {
filtered = append(filtered, candidate)
}
}
if len(filtered) == 0 {
return candidates
}
return filtered
}
func markStoredGraphAnchor(builder *openClawSessionGraphBuilder, anchorPayload openClawBehaviorPayload, anchorEntryName string, nodeIDsByEntryName map[string][]string) {
anchorNodeID := ""
if nodeIDs := nodeIDsByEntryName[strings.TrimSpace(anchorEntryName)]; len(nodeIDs) == 1 {
anchorNodeID = nodeIDs[0]
}
if anchorNodeID == "" {
switch anchorPayload.Kind {
case "tool_call":
candidates := []string{}
for _, node := range builder.nodes {
if !toolCallPayloadMatchesNode(anchorPayload, node) {
continue
}
candidates = append(candidates, node.ID)
}
switch len(candidates) {
case 1:
anchorNodeID = candidates[0]
default:
anchorNodeID = anchorPayload.EntryID
}
default:
if node := builder.nodes[anchorPayload.EntryID]; node != nil && node.Kind == anchorPayload.Kind {
anchorNodeID = node.ID
}
}
}
if anchorNode := builder.nodes[anchorNodeID]; anchorNode != nil {
anchorNode.IsAnchor = true
}
}
func buildStoredToolCallNodeID(entry *Entry, payload openClawBehaviorPayload) string {
if payload.ToolCallID != "" {
return fmt.Sprintf("tool_call:%s", payload.ToolCallID)
}
if entry != nil && strings.TrimSpace(entry.Name) != "" {
return fmt.Sprintf("tool_call_row:%s", strings.TrimSpace(entry.Name))
}
return fmt.Sprintf("tool_call:%s", strings.TrimSpace(payload.EntryID))
}
func newOpenClawSessionGraphBuilder() *openClawSessionGraphBuilder {
return &openClawSessionGraphBuilder{
graph: &OpenClawSessionGraph{
Nodes: []*OpenClawSessionGraphNode{},
Edges: []*OpenClawSessionGraphEdge{},
},
nodes: map[string]*OpenClawSessionGraphNode{},
}
}
func (b *openClawSessionGraphBuilder) addNode(node *OpenClawSessionGraphNode) {
if b == nil || node == nil {
return
}
node.ID = strings.TrimSpace(node.ID)
if node.ID == "" {
return
}
if existing := b.nodes[node.ID]; existing != nil {
mergeOpenClawGraphNode(existing, node)
return
}
cloned := *node
cloned.ParentID = strings.TrimSpace(cloned.ParentID)
cloned.OriginalParentID = strings.TrimSpace(cloned.OriginalParentID)
cloned.EntryID = strings.TrimSpace(cloned.EntryID)
cloned.ToolCallID = strings.TrimSpace(cloned.ToolCallID)
cloned.Kind = strings.TrimSpace(cloned.Kind)
cloned.Timestamp = strings.TrimSpace(cloned.Timestamp)
cloned.Summary = strings.TrimSpace(cloned.Summary)
cloned.Tool = strings.TrimSpace(cloned.Tool)
cloned.Query = strings.TrimSpace(cloned.Query)
cloned.URL = strings.TrimSpace(cloned.URL)
cloned.Path = strings.TrimSpace(cloned.Path)
cloned.Error = strings.TrimSpace(cloned.Error)
cloned.Text = strings.TrimSpace(cloned.Text)
cloned.OK = cloneBoolPointer(cloned.OK)
b.nodes[cloned.ID] = &cloned
}
func (b *openClawSessionGraphBuilder) finalize() *OpenClawSessionGraph {
if b == nil || b.graph == nil {
return nil
}
nodeIDs := make([]string, 0, len(b.nodes))
for id := range b.nodes {
nodeIDs = append(nodeIDs, id)
}
sort.Slice(nodeIDs, func(i, j int) bool {
left := b.nodes[nodeIDs[i]]
right := b.nodes[nodeIDs[j]]
return compareGraphNodes(left, right) < 0
})
b.graph.Nodes = make([]*OpenClawSessionGraphNode, 0, len(nodeIDs))
b.graph.Stats = OpenClawSessionGraphStats{}
for _, id := range nodeIDs {
node := b.nodes[id]
b.graph.Nodes = append(b.graph.Nodes, node)
updateOpenClawSessionGraphStats(&b.graph.Stats, node)
}
edgeKeys := map[string]struct{}{}
b.graph.Edges = []*OpenClawSessionGraphEdge{}
for _, node := range b.graph.Nodes {
if node.ParentID == "" || b.nodes[node.ParentID] == nil {
continue
}
key := fmt.Sprintf("%s->%s", node.ParentID, node.ID)
if _, ok := edgeKeys[key]; ok {
continue
}
edgeKeys[key] = struct{}{}
b.graph.Edges = append(b.graph.Edges, &OpenClawSessionGraphEdge{
Source: node.ParentID,
Target: node.ID,
})
}
sort.Slice(b.graph.Edges, func(i, j int) bool {
left := b.graph.Edges[i]
right := b.graph.Edges[j]
if left.Source != right.Source {
return left.Source < right.Source
}
return left.Target < right.Target
})
return b.graph
}
func mergeOpenClawGraphNode(current, next *OpenClawSessionGraphNode) {
if current == nil || next == nil {
return
}
current.ParentID = firstNonEmpty(current.ParentID, next.ParentID)
current.OriginalParentID = firstNonEmpty(current.OriginalParentID, next.OriginalParentID)
current.EntryID = firstNonEmpty(current.EntryID, next.EntryID)
current.ToolCallID = firstNonEmpty(current.ToolCallID, next.ToolCallID)
current.Kind = firstNonEmpty(current.Kind, next.Kind)
current.Timestamp = chooseEarlierTimestamp(current.Timestamp, next.Timestamp)
current.Summary = firstNonEmpty(current.Summary, next.Summary)
current.Tool = firstNonEmpty(current.Tool, next.Tool)
current.Query = firstNonEmpty(current.Query, next.Query)
current.URL = firstNonEmpty(current.URL, next.URL)
current.Path = firstNonEmpty(current.Path, next.Path)
current.Error = firstNonEmpty(current.Error, next.Error)
current.Text = firstNonEmpty(current.Text, next.Text)
current.OK = mergeBoolPointers(current.OK, next.OK)
current.IsAnchor = current.IsAnchor || next.IsAnchor
}
func updateOpenClawSessionGraphStats(stats *OpenClawSessionGraphStats, node *OpenClawSessionGraphNode) {
if stats == nil || node == nil {
return
}
stats.TotalNodes++
switch node.Kind {
case "task":
stats.TaskCount++
case "tool_call":
stats.ToolCallCount++
case "tool_result":
stats.ToolResultCount++
if node.OK != nil && !*node.OK {
stats.FailedCount++
}
case "final":
stats.FinalCount++
}
}
func buildAssistantStepSummary(toolNames []string) string {
deduped := []string{}
seen := map[string]struct{}{}
for _, toolName := range toolNames {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
continue
}
if _, ok := seen[toolName]; ok {
continue
}
seen[toolName] = struct{}{}
deduped = append(deduped, toolName)
}
if len(toolNames) == 0 {
return "assistant step"
}
if len(deduped) == 0 {
return fmt.Sprintf("%d tool calls", len(toolNames))
}
if len(deduped) <= 3 {
return fmt.Sprintf("%d tool calls: %s", len(toolNames), strings.Join(deduped, ", "))
}
return fmt.Sprintf("%d tool calls: %s, ...", len(toolNames), strings.Join(deduped[:3], ", "))
}
func toolCallPayloadMatchesNode(payload openClawBehaviorPayload, node *OpenClawSessionGraphNode) bool {
if node == nil || node.Kind != "tool_call" {
return false
}
if payload.ToolCallID != "" {
return strings.TrimSpace(node.ToolCallID) == strings.TrimSpace(payload.ToolCallID)
}
if strings.TrimSpace(node.EntryID) != strings.TrimSpace(payload.EntryID) {
return false
}
fields := []struct {
payload string
node string
}{
{payload.Tool, node.Tool},
{payload.Query, node.Query},
{payload.URL, node.URL},
{payload.Path, node.Path},
{payload.Text, node.Text},
}
matchedField := false
for _, field := range fields {
left := strings.TrimSpace(field.payload)
if left == "" {
continue
}
matchedField = true
if left != strings.TrimSpace(field.node) {
return false
}
}
return matchedField
}
func compareGraphNodes(left, right *OpenClawSessionGraphNode) int {
leftTimestamp := ""
rightTimestamp := ""
leftID := ""
rightID := ""
if left != nil {
leftTimestamp = left.Timestamp
leftID = left.ID
}
if right != nil {
rightTimestamp = right.Timestamp
rightID = right.ID
}
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
return timestampOrder
}
if leftID < rightID {
return -1
}
if leftID > rightID {
return 1
}
return 0
}
func chooseEarlierTimestamp(current, next string) string {
current = strings.TrimSpace(current)
next = strings.TrimSpace(next)
if current == "" {
return next
}
if next == "" {
return current
}
if compareOpenClawGraphTimestamps(next, current) < 0 {
return next
}
return current
}
func compareOpenClawGraphTimestamps(left, right string) int {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
leftUnixNano, leftOK := parseOpenClawGraphTimestamp(left)
rightUnixNano, rightOK := parseOpenClawGraphTimestamp(right)
if leftOK && rightOK {
if leftUnixNano < rightUnixNano {
return -1
}
if leftUnixNano > rightUnixNano {
return 1
}
return 0
}
if left < right {
return -1
}
if left > right {
return 1
}
return 0
}
func parseOpenClawGraphTimestamp(timestamp string) (_ int64, ok bool) {
timestamp = strings.TrimSpace(timestamp)
if timestamp == "" {
return 0, false
}
defer func() {
if recover() != nil {
ok = false
}
}()
return util.String2Time(timestamp).UnixNano(), true
}
func mergeBoolPointers(current, next *bool) *bool {
if next == nil {
return current
}
if current == nil {
return cloneBoolPointer(next)
}
value := *current && *next
return &value
}
func cloneBoolPointer(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}

View File

@@ -0,0 +1,733 @@
package object
import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/util"
)
const openClawTranscriptSyncInterval = 10 * time.Second
var (
openClawTranscriptWorkers = map[string]*openClawTranscriptSyncWorker{}
openClawTranscriptWorkersMu sync.Mutex
writeSuccessPathPattern = regexp.MustCompile(`(?i)successfully wrote \d+ bytes to (.+)$`)
)
type openClawTranscriptSyncWorker struct {
provider *Provider
stopCh chan struct{}
doneCh chan struct{}
fileStates map[string]openClawTranscriptFileState
}
type openClawTranscriptFileState struct {
ModTimeUnixNano int64
Size int64
}
type openClawTranscriptEntry struct {
Type string `json:"type"`
ID string `json:"id"`
ParentID string `json:"parentId"`
Timestamp string `json:"timestamp"`
Message *openClawMessage `json:"message"`
Details map[string]interface{} `json:"details"`
}
type openClawMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
StopReason string `json:"stopReason"`
ToolCallID string `json:"toolCallId"`
ToolName string `json:"toolName"`
IsError bool `json:"isError"`
Timestamp int64 `json:"timestamp"`
}
type openClawContentItem struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Text string `json:"text"`
Arguments map[string]interface{} `json:"arguments"`
}
type openClawBehaviorPayload struct {
Summary string `json:"summary"`
Kind string `json:"kind"`
SessionID string `json:"sessionId"`
EntryID string `json:"entryId"`
ToolCallID string `json:"toolCallId,omitempty"`
ParentID string `json:"parentId,omitempty"`
Timestamp string `json:"timestamp"`
Tool string `json:"tool,omitempty"`
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
OK *bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
AssistantText string `json:"assistantText,omitempty"`
Text string `json:"text,omitempty"`
}
type openClawToolContext struct {
Tool string
Query string
URL string
Path string
Command string
}
func startOpenClawTranscriptSync(provider *Provider) {
if provider == nil || provider.Category != "Log" || provider.Type != "Agent" || provider.SubType != "OpenClaw" {
return
}
id := provider.GetId()
stopOpenClawTranscriptSync(id)
worker := &openClawTranscriptSyncWorker{
provider: provider,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
fileStates: map[string]openClawTranscriptFileState{},
}
openClawTranscriptWorkersMu.Lock()
openClawTranscriptWorkers[id] = worker
openClawTranscriptWorkersMu.Unlock()
go worker.run()
}
func stopOpenClawTranscriptSync(providerID string) {
openClawTranscriptWorkersMu.Lock()
worker, ok := openClawTranscriptWorkers[providerID]
if ok {
delete(openClawTranscriptWorkers, providerID)
}
openClawTranscriptWorkersMu.Unlock()
if !ok {
return
}
close(worker.stopCh)
<-worker.doneCh
}
func (w *openClawTranscriptSyncWorker) run() {
defer close(w.doneCh)
w.syncOnce()
ticker := time.NewTicker(openClawTranscriptSyncInterval)
defer ticker.Stop()
for {
select {
case <-w.stopCh:
return
case <-ticker.C:
w.syncOnce()
}
}
}
func (w *openClawTranscriptSyncWorker) syncOnce() {
if w.isStopping() {
return
}
if err := w.scanTranscriptDir(); err != nil {
fmt.Printf("OpenClaw transcript sync failed for provider %s: %v\n", w.provider.Name, err)
}
}
func (w *openClawTranscriptSyncWorker) isStopping() bool {
select {
case <-w.stopCh:
return true
default:
return false
}
}
func (w *openClawTranscriptSyncWorker) scanTranscriptDir() error {
rootDir, err := resolveOpenClawTranscriptDir(w.provider)
if err != nil {
return err
}
entries, err := os.ReadDir(rootDir)
if err != nil {
return err
}
seenPaths := map[string]struct{}{}
for _, entry := range entries {
if w.isStopping() {
return nil
}
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, ".reset.") || name == "sessions.json" {
continue
}
path := filepath.Join(rootDir, name)
seenPaths[path] = struct{}{}
info, err := entry.Info()
if err != nil {
return err
}
nextState := openClawTranscriptFileState{
ModTimeUnixNano: info.ModTime().UnixNano(),
Size: info.Size(),
}
if w.shouldSkipTranscriptFile(path, nextState) {
continue
}
if err := w.scanTranscriptFile(path); err != nil {
return err
}
w.fileStates[path] = nextState
}
for path := range w.fileStates {
if w.isStopping() {
return nil
}
if _, ok := seenPaths[path]; !ok {
delete(w.fileStates, path)
}
}
return nil
}
func (w *openClawTranscriptSyncWorker) shouldSkipTranscriptFile(path string, nextState openClawTranscriptFileState) bool {
currentState, ok := w.fileStates[path]
if !ok {
return false
}
return currentState.ModTimeUnixNano == nextState.ModTimeUnixNano && currentState.Size == nextState.Size
}
func resolveOpenClawTranscriptDir(provider *Provider) (string, error) {
if provider == nil {
return "", fmt.Errorf("provider is nil")
}
if endpoint := strings.TrimSpace(provider.Endpoint); endpoint != "" {
return expandOpenClawPath(endpoint)
}
stateDir, err := resolveOpenClawStateDir()
if err != nil {
return "", err
}
agentID := strings.TrimSpace(provider.Title)
if agentID == "" {
agentID = "main"
}
return filepath.Join(stateDir, "agents", agentID, "sessions"), nil
}
func fillOpenClawProviderDefaults(provider *Provider) error {
if !isOpenClawLogProvider(provider) {
return nil
}
if strings.TrimSpace(provider.Title) == "" {
provider.Title = "main"
}
if strings.TrimSpace(provider.Endpoint) != "" {
resolved, err := expandOpenClawPath(provider.Endpoint)
if err != nil {
return err
}
provider.Endpoint = resolved
return nil
}
transcriptDir, err := resolveOpenClawTranscriptDir(provider)
if err != nil {
return err
}
provider.Endpoint = transcriptDir
return nil
}
func isOpenClawLogProvider(provider *Provider) bool {
return provider != nil && provider.Category == "Log" && provider.Type == "Agent" && provider.SubType == "OpenClaw"
}
func resolveOpenClawStateDir() (string, error) {
if override := strings.TrimSpace(os.Getenv("OPENCLAW_STATE_DIR")); override != "" {
return expandOpenClawPath(override)
}
homeDir, err := resolveOpenClawHomeDir()
if err != nil {
return "", err
}
if profile := strings.TrimSpace(os.Getenv("OPENCLAW_PROFILE")); profile != "" && !strings.EqualFold(profile, "default") {
return filepath.Join(homeDir, ".openclaw-"+profile), nil
}
return filepath.Join(homeDir, ".openclaw"), nil
}
func resolveOpenClawHomeDir() (string, error) {
if explicitHome := strings.TrimSpace(os.Getenv("OPENCLAW_HOME")); explicitHome != "" {
return expandOpenClawPath(explicitHome)
}
return resolveSystemHomeDir()
}
func resolveSystemHomeDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Clean(homeDir), nil
}
func expandOpenClawPath(input string) (string, error) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return "", nil
}
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~\\") {
homeDir, err := resolveSystemHomeDir()
if err != nil {
return "", err
}
suffix := strings.TrimPrefix(strings.TrimPrefix(trimmed, "~"), string(filepath.Separator))
suffix = strings.TrimPrefix(strings.TrimPrefix(suffix, "/"), "\\")
if suffix == "" {
return filepath.Clean(homeDir), nil
}
return filepath.Clean(filepath.Join(homeDir, suffix)), nil
}
if runtime.GOOS == "windows" && len(trimmed) >= 2 && trimmed[0] == '%' {
if index := strings.Index(trimmed[1:], "%"); index >= 0 {
end := index + 1
envKey := trimmed[1:end]
if envValue := strings.TrimSpace(os.Getenv(envKey)); envValue != "" {
replaced := envValue + trimmed[end+1:]
return filepath.Clean(replaced), nil
}
}
}
return filepath.Clean(trimmed), nil
}
func (w *openClawTranscriptSyncWorker) scanTranscriptFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
sessionID := strings.TrimSuffix(filepath.Base(path), ".jsonl")
toolContexts := map[string]openClawToolContext{}
reader := bufio.NewReader(file)
for {
if w.isStopping() {
return nil
}
lineBytes, readErr := reader.ReadBytes('\n')
if readErr != nil && readErr != io.EOF {
return readErr
}
line := strings.TrimSpace(string(lineBytes))
if line == "" {
if readErr == io.EOF {
break
}
continue
}
var entry openClawTranscriptEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
if readErr == io.EOF {
break
}
continue
}
results := buildOpenClawTranscriptEntries(w.provider, sessionID, entry, toolContexts)
for _, result := range results {
if result == nil {
continue
}
if err := addOpenClawTranscriptEntry(result); err != nil {
return err
}
}
if readErr == io.EOF {
break
}
}
return nil
}
func buildOpenClawTranscriptEntries(provider *Provider, sessionID string, entry openClawTranscriptEntry, toolContexts map[string]openClawToolContext) []*Entry {
if entry.Type != "message" || entry.Message == nil {
return nil
}
message := entry.Message
switch message.Role {
case "user":
text := normalizeUserText(extractMessageText(message.Content))
if text == "" {
return nil
}
if isHeartbeatText(text) {
return nil
}
return []*Entry{newOpenClawTranscriptEntry(provider, sessionID, "task", entry.ID, openClawBehaviorPayload{
Summary: truncateText(fmt.Sprintf("task: %s", text), 100),
Kind: "task",
SessionID: sessionID,
EntryID: entry.ID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Text: truncateText(text, 2000),
})}
case "assistant":
items := parseContentItems(message.Content)
assistantText := truncateText(extractMessageText(message.Content), 2000)
toolEntries := []*Entry{}
storedAssistantText := false
for _, item := range items {
if item.Type != "toolCall" {
continue
}
context := extractOpenClawToolContext(item)
toolContexts[item.ID] = context
payload := openClawBehaviorPayload{
Summary: truncateText(buildToolCallSummary(context), 100),
Kind: "tool_call",
SessionID: sessionID,
EntryID: entry.ID,
ToolCallID: item.ID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Tool: context.Tool,
Query: context.Query,
URL: context.URL,
Path: context.Path,
Text: truncateText(context.Command, 500),
}
if !storedAssistantText {
// Avoid duplicating the same assistant text on every tool-call row.
payload.AssistantText = assistantText
storedAssistantText = true
}
identity := fmt.Sprintf("%s/%s", entry.ID, item.ID)
toolEntries = append(toolEntries, newOpenClawTranscriptEntry(provider, sessionID, "tool_call", identity, payload))
}
if len(toolEntries) > 0 {
return toolEntries
}
if message.StopReason != "stop" {
return nil
}
text := extractMessageText(message.Content)
if text == "" {
return nil
}
return []*Entry{newOpenClawTranscriptEntry(provider, sessionID, "final", entry.ID, openClawBehaviorPayload{
Summary: truncateText(fmt.Sprintf("final: %s", text), 100),
Kind: "final",
SessionID: sessionID,
EntryID: entry.ID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Text: truncateText(text, 2000),
})}
case "toolResult":
payload, ok := buildToolResultPayload(sessionID, entry, toolContexts[message.ToolCallID])
if !ok {
return nil
}
return []*Entry{newOpenClawTranscriptEntry(provider, sessionID, "tool_result", entry.ID, payload)}
default:
return nil
}
}
func buildToolResultPayload(sessionID string, entry openClawTranscriptEntry, toolContext openClawToolContext) (openClawBehaviorPayload, bool) {
message := entry.Message
if message == nil {
return openClawBehaviorPayload{}, false
}
okValue, errorText := resolveToolResultStatus(entry)
text := summarizeToolResultText(extractMessageText(message.Content), okValue)
toolName := firstNonEmpty(toolContext.Tool, message.ToolName)
if toolName == "" && text == "" && errorText == "" {
return openClawBehaviorPayload{}, false
}
return openClawBehaviorPayload{
Summary: truncateText(buildToolResultSummary(toolName, toolContext, okValue, errorText, text), 100),
Kind: "tool_result",
SessionID: sessionID,
EntryID: entry.ID,
ToolCallID: message.ToolCallID,
ParentID: entry.ParentID,
Timestamp: normalizeOpenClawTimestamp(entry.Timestamp, message.Timestamp),
Tool: toolName,
Query: toolContext.Query,
URL: toolContext.URL,
Path: firstNonEmpty(toolContext.Path, extractWriteSuccessPath(text)),
OK: &okValue,
Error: truncateText(errorText, 500),
Text: truncateText(text, 2000),
}, true
}
func newOpenClawTranscriptEntry(provider *Provider, sessionID string, entryKind string, identity string, payload openClawBehaviorPayload) *Entry {
body, _ := json.Marshal(payload)
nameSource := fmt.Sprintf("%s|%s|%s", provider.Name, sessionID, identity)
createdTime := payload.Timestamp
if strings.TrimSpace(createdTime) == "" {
createdTime = util.GetCurrentTime()
}
return &Entry{
Owner: CasdoorOrganization,
Name: fmt.Sprintf("oc_%s", util.GetMd5Hash(nameSource)),
CreatedTime: createdTime,
UpdatedTime: createdTime,
DisplayName: truncateText(payload.Summary, 100),
Provider: provider.Name,
Type: "session",
Message: string(body),
}
}
func addOpenClawTranscriptEntry(entry *Entry) error {
if entry == nil {
return nil
}
_, err := AddEntry(entry)
if err == nil || isDuplicateEntryError(err) {
return nil
}
return err
}
func isDuplicateEntryError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "duplicate") || strings.Contains(msg, "unique constraint") || strings.Contains(msg, "already exists")
}
func normalizeOpenClawTimestamp(raw string, fallbackMillis int64) string {
if trimmed := strings.TrimSpace(raw); trimmed != "" {
if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
return trimmed
}
}
if fallbackMillis > 0 {
return time.UnixMilli(fallbackMillis).UTC().Format(time.RFC3339)
}
return util.GetCurrentTime()
}
func parseContentItems(raw json.RawMessage) []openClawContentItem {
if len(raw) == 0 {
return nil
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
if strings.TrimSpace(text) == "" {
return nil
}
return []openClawContentItem{{Type: "text", Text: text}}
}
var items []openClawContentItem
if err := json.Unmarshal(raw, &items); err == nil {
return items
}
return nil
}
func extractMessageText(raw json.RawMessage) string {
items := parseContentItems(raw)
if len(items) == 0 {
return ""
}
parts := []string{}
for _, item := range items {
if item.Type == "text" && strings.TrimSpace(item.Text) != "" {
parts = append(parts, strings.TrimSpace(item.Text))
}
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
func normalizeUserText(text string) string {
trimmed := strings.TrimSpace(text)
if !strings.HasPrefix(trimmed, "Sender (untrusted metadata):") {
return trimmed
}
if index := strings.LastIndex(trimmed, "\n\n"); index >= 0 && index+2 < len(trimmed) {
return strings.TrimSpace(trimmed[index+2:])
}
return trimmed
}
func extractOpenClawToolContext(item openClawContentItem) openClawToolContext {
context := openClawToolContext{Tool: item.Name}
if item.Arguments == nil {
return context
}
context.Query = stringifyOpenClawArg(item.Arguments["query"])
context.URL = stringifyOpenClawArg(item.Arguments["url"])
context.Path = stringifyOpenClawArg(item.Arguments["path"])
context.Command = stringifyOpenClawArg(item.Arguments["command"])
return context
}
func stringifyOpenClawArg(value interface{}) string {
text, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(text)
}
func buildToolCallSummary(context openClawToolContext) string {
target := firstNonEmpty(context.Query, context.URL, context.Path, context.Command)
if target == "" {
return fmt.Sprintf("%s called", context.Tool)
}
return fmt.Sprintf("%s: %s", context.Tool, target)
}
func resolveToolResultStatus(entry openClawTranscriptEntry) (bool, string) {
if entry.Message != nil && entry.Message.IsError {
return false, stringifyOpenClawArg(entry.Details["error"])
}
if status, ok := entry.Details["status"].(string); ok && strings.EqualFold(status, "error") {
return false, stringifyOpenClawArg(entry.Details["error"])
}
text := strings.TrimSpace(extractMessageText(entry.Message.Content))
if strings.HasPrefix(text, "{") {
var payload map[string]interface{}
if err := json.Unmarshal([]byte(text), &payload); err == nil {
if status, ok := payload["status"].(string); ok && strings.EqualFold(status, "error") {
return false, stringifyOpenClawArg(payload["error"])
}
}
}
return true, stringifyOpenClawArg(entry.Details["error"])
}
func summarizeToolResultText(text string, okValue bool) string {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return ""
}
if okValue {
if path := extractWriteSuccessPath(trimmed); path != "" {
return fmt.Sprintf("Successfully wrote %s", path)
}
return ""
}
return trimmed
}
func extractWriteSuccessPath(text string) string {
matches := writeSuccessPathPattern.FindStringSubmatch(strings.TrimSpace(text))
if len(matches) < 2 {
return ""
}
return strings.TrimSpace(matches[1])
}
func buildToolResultSummary(tool string, context openClawToolContext, okValue bool, errorText string, text string) string {
target := firstNonEmpty(context.Query, context.URL, context.Path, context.Command, extractWriteSuccessPath(text))
status := "ok"
details := target
if !okValue {
status = "failed"
details = firstNonEmpty(errorText, target)
}
if details == "" {
return fmt.Sprintf("%s %s", tool, status)
}
return fmt.Sprintf("%s %s: %s", tool, status, details)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func isHeartbeatText(text string) bool {
return strings.HasPrefix(strings.TrimSpace(text), "Read HEARTBEAT.md")
}
func truncateText(text string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(text))
if len(runes) <= max {
return string(runes)
}
return string(runes[:max-1]) + "…"
}

View File

@@ -254,7 +254,11 @@ func (a *Ormer) open() error {
dataSourceName = a.dataSourceName
}
engine, err := xorm.NewEngine(a.driverName, dataSourceName)
driverName := a.driverName
if driverName == "sqlite3" {
driverName = "sqlite"
}
engine, err := xorm.NewEngine(driverName, dataSourceName)
if err != nil {
return err
}
@@ -278,7 +282,11 @@ func (a *Ormer) openFromDb(db *sql.DB) error {
xormDb := core.FromDB(db)
engine, err := xorm.NewEngineWithDB(a.driverName, dataSourceName, xormDb)
driverName := a.driverName
if driverName == "sqlite3" {
driverName = "sqlite"
}
engine, err := xorm.NewEngineWithDB(driverName, dataSourceName, xormDb)
if err != nil {
return err
}

View File

@@ -120,18 +120,6 @@ func checkPermissionValid(permission *Permission) error {
return nil
}
groupingPolicies, err := getGroupingPolicies(permission)
if err != nil {
return err
}
if len(groupingPolicies) > 0 {
_, err = enforcer.AddGroupingPolicies(groupingPolicies)
if err != nil {
return err
}
}
return nil
}
@@ -171,11 +159,6 @@ func UpdatePermission(id string, permission *Permission) (bool, error) {
}
if affected != 0 {
err = removeGroupingPolicies(oldPermission)
if err != nil {
return false, err
}
err = removePolicies(oldPermission)
if err != nil {
return false, err
@@ -191,11 +174,6 @@ func UpdatePermission(id string, permission *Permission) (bool, error) {
// }
// }
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
err = addPolicies(permission)
if err != nil {
return false, err
@@ -212,11 +190,6 @@ func AddPermission(permission *Permission) (bool, error) {
}
if affected != 0 {
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
err = addPolicies(permission)
if err != nil {
return false, err
@@ -241,11 +214,6 @@ func AddPermissions(permissions []*Permission) (bool, error) {
for _, permission := range permissions {
// add using for loop
if affected != 0 {
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
err = addPolicies(permission)
if err != nil {
return false, err
@@ -302,11 +270,6 @@ func DeletePermission(permission *Permission) (bool, error) {
}
if affected {
err = removeGroupingPolicies(permission)
if err != nil {
return false, err
}
err = removePolicies(permission)
if err != nil {
return false, err

View File

@@ -52,11 +52,9 @@ func getPermissionEnforcer(p *Permission, permissionIDs ...string) (*casbin.Enfo
}
policyFilter := xormadapter.Filter{
V5: policyFilterV5,
}
if !HasRoleDefinition(enforcer.GetModel()) {
policyFilter.Ptype = []string{"p"}
// Permission enforcers only persist p rules. Legacy g rows are rebuilt from roles at runtime.
Ptype: []string{"p"},
V5: policyFilterV5,
}
err = enforcer.LoadFilteredPolicy(policyFilter)
@@ -64,6 +62,12 @@ func getPermissionEnforcer(p *Permission, permissionIDs ...string) (*casbin.Enfo
return nil, err
}
// we can rebuild group policies in memory
err = loadRuntimeGroupingPolicies(enforcer, p, permissionIDs...)
if err != nil {
return nil, err
}
return enforcer, nil
}
@@ -141,13 +145,47 @@ func getPolicies(permission *Permission) [][]string {
return policies
}
func getRolesInRole(roleId string, visited map[string]struct{}) ([]*Role, error) {
type permissionRoleResolver struct {
rolesByOwner map[string][]*Role
roleByID map[string]*Role
}
func newPermissionRoleResolver() *permissionRoleResolver {
return &permissionRoleResolver{
rolesByOwner: map[string][]*Role{},
roleByID: map[string]*Role{},
}
}
func (r *permissionRoleResolver) getRoles(owner string) ([]*Role, error) {
if roles, ok := r.rolesByOwner[owner]; ok {
return roles, nil
}
roles, err := GetRoles(owner)
if err != nil {
return nil, err
}
r.rolesByOwner[owner] = roles
for _, role := range roles {
r.roleByID[role.GetId()] = role
}
return roles, nil
}
func (r *permissionRoleResolver) getRolesInRole(permissionOwner string, roleId string, visited map[string]struct{}) ([]*Role, error) {
if roleId == "*" {
roleId = util.GetId(permissionOwner, "*")
}
roleOwner, roleName, err := util.GetOwnerAndNameFromIdWithError(roleId)
if err != nil {
return []*Role{}, err
}
if roleName == "*" {
roles, err := GetRoles(roleOwner)
roles, err := r.getRoles(roleOwner)
if err != nil {
return []*Role{}, err
}
@@ -155,11 +193,13 @@ func getRolesInRole(roleId string, visited map[string]struct{}) ([]*Role, error)
return roles, nil
}
role, err := GetRole(roleId)
_, err = r.getRoles(roleOwner)
if err != nil {
return []*Role{}, err
}
role := r.roleByID[roleId]
if role == nil {
return []*Role{}, nil
}
@@ -168,55 +208,94 @@ func getRolesInRole(roleId string, visited map[string]struct{}) ([]*Role, error)
roles := []*Role{role}
for _, subRole := range role.Roles {
if _, ok := visited[subRole]; !ok {
r, err := getRolesInRole(subRole, visited)
subRoles, err := r.getRolesInRole(roleOwner, subRole, visited)
if err != nil {
return []*Role{}, err
}
roles = append(roles, r...)
roles = append(roles, subRoles...)
}
}
return roles, nil
}
func getGroupingPolicies(permission *Permission) ([][]string, error) {
var groupingPolicies [][]string
func getPermissionEnforcerTargets(permission *Permission, permissionIDs ...string) ([]*Permission, error) {
if len(permissionIDs) == 0 {
return []*Permission{permission}, nil
}
domainExist := len(permission.Domains) > 0
permissionId := permission.GetId()
for _, roleId := range permission.Roles {
visited := map[string]struct{}{}
if roleId == "*" {
roleId = util.GetId(permission.Owner, "*")
permissions := make([]*Permission, 0, len(permissionIDs))
visited := map[string]struct{}{}
for _, permissionID := range permissionIDs {
if _, ok := visited[permissionID]; ok {
continue
}
rolesInRole, err := getRolesInRole(roleId, visited)
targetPermission, err := GetPermission(permissionID)
if err != nil {
return nil, err
}
if targetPermission == nil {
return nil, fmt.Errorf("the permission: %s doesn't exist", permissionID)
}
for _, role := range rolesInRole {
roleId = role.GetId()
for _, subUser := range role.Users {
if domainExist {
for _, domain := range permission.Domains {
groupingPolicies = append(groupingPolicies, []string{subUser, roleId, domain, "", "", permissionId})
}
} else {
groupingPolicies = append(groupingPolicies, []string{subUser, roleId, "", "", "", permissionId})
}
permissions = append(permissions, targetPermission)
visited[permissionID] = struct{}{}
}
return permissions, nil
}
func newRuntimeGroupingPolicy(sub string, roleId string, domain string) []string {
return []string{sub, roleId, domain, "", "", ""}
}
func appendRuntimeGroupingPolicy(groupingPolicies *[][]string, visited map[string]struct{}, rule []string) {
// we can't use []string as key, so use null character
key := strings.Join(rule, "\x00")
if _, ok := visited[key]; ok {
return
}
*groupingPolicies = append(*groupingPolicies, rule)
visited[key] = struct{}{}
}
func getRuntimeGroupingPolicies(permissions []*Permission) ([][]string, error) {
var groupingPolicies [][]string
visitedPolicies := map[string]struct{}{}
roleResolver := newPermissionRoleResolver()
for _, permission := range permissions {
domainExist := len(permission.Domains) > 0
for _, roleId := range permission.Roles {
visited := map[string]struct{}{}
rolesInRole, err := roleResolver.getRolesInRole(permission.Owner, roleId, visited)
if err != nil {
return nil, err
}
for _, subRole := range role.Roles {
if domainExist {
for _, domain := range permission.Domains {
groupingPolicies = append(groupingPolicies, []string{subRole, roleId, domain, "", "", permissionId})
for _, role := range rolesInRole {
currentRoleID := role.GetId()
for _, subUser := range role.Users {
if domainExist {
for _, domain := range permission.Domains {
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subUser, currentRoleID, domain))
}
} else {
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subUser, currentRoleID, ""))
}
}
for _, subRole := range role.Roles {
if domainExist {
for _, domain := range permission.Domains {
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subRole, currentRoleID, domain))
}
} else {
appendRuntimeGroupingPolicy(&groupingPolicies, visitedPolicies, newRuntimeGroupingPolicy(subRole, currentRoleID, ""))
}
} else {
groupingPolicies = append(groupingPolicies, []string{subRole, roleId, "", "", "", permissionId})
}
}
}
@@ -225,6 +304,35 @@ func getGroupingPolicies(permission *Permission) ([][]string, error) {
return groupingPolicies, nil
}
func loadRuntimeGroupingPolicies(enforcer *casbin.Enforcer, permission *Permission, permissionIDs ...string) error {
if !HasRoleDefinition(enforcer.GetModel()) {
return nil
}
targetPermissions, err := getPermissionEnforcerTargets(permission, permissionIDs...)
if err != nil {
return err
}
groupingPolicies, err := getRuntimeGroupingPolicies(targetPermissions)
if err != nil {
return err
}
if len(groupingPolicies) == 0 {
return nil
}
enforcer.EnableAutoSave(false)
defer enforcer.EnableAutoSave(true)
_, err = enforcer.AddGroupingPolicies(groupingPolicies)
if err != nil {
return err
}
return nil
}
func addPolicies(permission *Permission) error {
enforcer, err := getPermissionEnforcer(permission)
if err != nil {
@@ -249,68 +357,27 @@ func removePolicies(permission *Permission) error {
return err
}
func addGroupingPolicies(permission *Permission) error {
enforcer, err := getPermissionEnforcer(permission)
if err != nil {
return err
}
groupingPolicies, err := getGroupingPolicies(permission)
if err != nil {
return err
}
if len(groupingPolicies) > 0 {
_, err = enforcer.AddGroupingPolicies(groupingPolicies)
if err != nil {
return err
}
}
return nil
}
func removeGroupingPolicies(permission *Permission) error {
enforcer, err := getPermissionEnforcer(permission)
if err != nil {
return err
}
groupingPolicies, err := getGroupingPolicies(permission)
if err != nil {
return err
}
if len(groupingPolicies) > 0 {
_, err = enforcer.RemoveGroupingPolicies(groupingPolicies)
if err != nil {
return err
}
}
return nil
}
func Enforce(permission *Permission, request []string, permissionIds ...string) (bool, error) {
func Enforce(permission *Permission, request []interface{}, permissionIds ...string) (bool, error) {
enforcer, err := getPermissionEnforcer(permission, permissionIds...)
if err != nil {
return false, err
}
// type transformation
interfaceRequest := util.StringToInterfaceArray(request)
// Convert each element: JSON-object strings and maps become anonymous structs
// so Casbin can evaluate ABAC rules with dot-notation (e.g. r.sub.DivisionGuid).
interfaceRequest := util.InterfaceToEnforceArray(request)
return enforcer.Enforce(interfaceRequest...)
}
func BatchEnforce(permission *Permission, requests [][]string, permissionIds ...string) ([]bool, error) {
func BatchEnforce(permission *Permission, requests [][]interface{}, permissionIds ...string) ([]bool, error) {
enforcer, err := getPermissionEnforcer(permission, permissionIds...)
if err != nil {
return nil, err
}
// type transformation
interfaceRequests := util.StringToInterfaceArray2d(requests)
// Convert each element in every row for ABAC support.
interfaceRequests := util.InterfaceToEnforceArray2d(requests)
return enforcer.BatchEnforce(interfaceRequests)
}

View File

@@ -0,0 +1,314 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build !skipCi
package object
import (
"fmt"
"sync"
"testing"
"github.com/casdoor/casdoor/util"
)
type permissionRuleRecord struct {
Id int64 `xorm:"pk autoincr"`
Ptype string `xorm:"varchar(100) index not null default ''"`
V0 string `xorm:"varchar(100) index not null default ''"`
V1 string `xorm:"varchar(100) index not null default ''"`
V2 string `xorm:"varchar(100) index not null default ''"`
V3 string `xorm:"varchar(100) index not null default ''"`
V4 string `xorm:"varchar(100) index not null default ''"`
V5 string `xorm:"varchar(100) index not null default ''"`
}
func (permissionRuleRecord) TableName() string {
return "permission_rule"
}
var permissionRbacTestInit sync.Once
func initPermissionRbacTestDb(t *testing.T) {
t.Helper()
permissionRbacTestInit.Do(func() {
oldCreateDatabase := createDatabase
createDatabase = false
InitConfig()
createDatabase = oldCreateDatabase
})
}
func newPermissionRbacTestOwner(t *testing.T) string {
t.Helper()
initPermissionRbacTestDb(t)
owner := "rbac-dedup-" + util.GenerateId()
t.Cleanup(func() {
_, err := ormer.Engine.Where("v5 like ?", owner+"/%").Delete(&permissionRuleRecord{})
if err != nil {
t.Fatalf("failed to delete permission rules for owner %s: %v", owner, err)
}
_, err = ormer.Engine.Where("owner = ?", owner).Delete(&Permission{})
if err != nil {
t.Fatalf("failed to delete permissions for owner %s: %v", owner, err)
}
_, err = ormer.Engine.Where("owner = ?", owner).Delete(&Role{})
if err != nil {
t.Fatalf("failed to delete roles for owner %s: %v", owner, err)
}
})
return owner
}
func newTestPermission(owner string, name string, roleIDs ...string) *Permission {
return &Permission{
Owner: owner,
Name: name,
Roles: roleIDs,
Resources: []string{"data1"},
Actions: []string{"read"},
Effect: "Allow",
}
}
func getPermissionRulesByPermissionID(t *testing.T, permissionID string) []permissionRuleRecord {
t.Helper()
rules := make([]permissionRuleRecord, 0)
err := ormer.Engine.Where("v5 = ?", permissionID).Asc("id").Find(&rules)
if err != nil {
t.Fatalf("failed to query permission rules for %s: %v", permissionID, err)
}
return rules
}
func TestPermissionRuntimeGroupingIgnoresPersistedG(t *testing.T) {
owner := newPermissionRbacTestOwner(t)
role := &Role{
Owner: owner,
Name: "reader",
Users: []string{owner + "/alice"},
}
affected, err := AddRole(role)
if err != nil {
t.Fatalf("AddRole() error: %v", err)
}
if !affected {
t.Fatalf("expected AddRole to affect rows")
}
permission := newTestPermission(owner, "perm-reader", role.GetId())
affected, err = AddPermission(permission)
if err != nil {
t.Fatalf("AddPermission() error: %v", err)
}
if !affected {
t.Fatalf("expected AddPermission to affect rows")
}
rules := getPermissionRulesByPermissionID(t, permission.GetId())
if len(rules) != 1 || rules[0].Ptype != "p" {
t.Fatalf("expected exactly one persisted p rule, got %+v", rules)
}
allowed, err := Enforce(permission, []string{owner + "/alice", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for alice error: %v", err)
}
if !allowed {
t.Fatalf("expected alice to be allowed")
}
_, err = ormer.Engine.Insert(&permissionRuleRecord{
Ptype: "g",
V0: owner + "/mallory",
V1: role.GetId(),
V5: permission.GetId(),
})
if err != nil {
t.Fatalf("failed to insert legacy g rule: %v", err)
}
allowed, err = Enforce(permission, []string{owner + "/mallory", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for mallory error: %v", err)
}
if allowed {
t.Fatalf("expected legacy persisted g rule to be ignored")
}
}
func TestUpdateRoleUsesRuntimeGroupingAndOnlyRenameRewritesP(t *testing.T) {
owner := newPermissionRbacTestOwner(t)
role := &Role{
Owner: owner,
Name: "reader-old",
Users: []string{owner + "/alice"},
}
affected, err := AddRole(role)
if err != nil {
t.Fatalf("AddRole() error: %v", err)
}
if !affected {
t.Fatalf("expected AddRole to affect rows")
}
permission := newTestPermission(owner, "perm-reader", role.GetId())
affected, err = AddPermission(permission)
if err != nil {
t.Fatalf("AddPermission() error: %v", err)
}
if !affected {
t.Fatalf("expected AddPermission to affect rows")
}
rulesBefore := getPermissionRulesByPermissionID(t, permission.GetId())
updatedRole := *role
updatedRole.Users = []string{owner + "/bob"}
affected, err = UpdateRole(role.GetId(), &updatedRole)
if err != nil {
t.Fatalf("UpdateRole() for membership change error: %v", err)
}
if !affected {
t.Fatalf("expected UpdateRole membership change to affect rows")
}
rulesAfterMembershipChange := getPermissionRulesByPermissionID(t, permission.GetId())
if fmt.Sprintf("%#v", rulesBefore) != fmt.Sprintf("%#v", rulesAfterMembershipChange) {
t.Fatalf("expected membership change to keep persisted permission rules unchanged")
}
allowed, err := Enforce(permission, []string{owner + "/alice", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for alice after membership change error: %v", err)
}
if allowed {
t.Fatalf("expected alice to lose permission after membership change")
}
allowed, err = Enforce(permission, []string{owner + "/bob", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for bob after membership change error: %v", err)
}
if !allowed {
t.Fatalf("expected bob to gain permission after membership change")
}
renamedRole := updatedRole
renamedRole.Name = "reader-new"
affected, err = UpdateRole(updatedRole.GetId(), &renamedRole)
if err != nil {
t.Fatalf("UpdateRole() for rename error: %v", err)
}
if !affected {
t.Fatalf("expected UpdateRole rename to affect rows")
}
updatedPermission, err := GetPermission(permission.GetId())
if err != nil {
t.Fatalf("GetPermission() error: %v", err)
}
if len(updatedPermission.Roles) != 1 || updatedPermission.Roles[0] != renamedRole.GetId() {
t.Fatalf("expected permission role reference to be renamed")
}
rulesAfterRename := getPermissionRulesByPermissionID(t, permission.GetId())
if len(rulesAfterRename) != 1 || rulesAfterRename[0].Ptype != "p" || rulesAfterRename[0].V0 != renamedRole.GetId() {
t.Fatalf("expected rename to rebuild persisted p rule with new role id, got %+v", rulesAfterRename)
}
allowed, err = Enforce(updatedPermission, []string{owner + "/bob", "data1", "read"})
if err != nil {
t.Fatalf("Enforce() for bob after rename error: %v", err)
}
if !allowed {
t.Fatalf("expected bob to stay allowed after role rename")
}
}
// issue 5346
func TestPermissionEnforcerDeduplicatesRuntimeGroupingPoliciesAcross1000Permissions(t *testing.T) {
owner := newPermissionRbacTestOwner(t)
const (
permissionCount = 1000
userCount = 1000
)
users := make([]string, 0, userCount)
for i := range userCount {
users = append(users, fmt.Sprintf("%s/user-%04d", owner, i))
}
role := &Role{
Owner: owner,
Name: "shared-role",
Users: users,
}
affected, err := AddRole(role)
if err != nil {
t.Fatalf("AddRole() error: %v", err)
}
if !affected {
t.Fatalf("expected AddRole to affect rows")
}
permissions := make([]*Permission, 0, permissionCount)
permissionIDs := make([]string, 0, permissionCount)
for i := 0; i < permissionCount; i++ {
permission := newTestPermission(owner, fmt.Sprintf("perm-%04d", i), role.GetId())
permissions = append(permissions, permission)
permissionIDs = append(permissionIDs, permission.GetId())
}
affected, err = AddPermissions(permissions)
if err != nil {
t.Fatalf("AddPermissions() error: %v", err)
}
if !affected {
t.Fatalf("expected AddPermissions to affect rows")
}
enforcer, err := getPermissionEnforcer(permissions[0], permissionIDs...)
if err != nil {
t.Fatalf("getPermissionEnforcer() error: %v", err)
}
if len(enforcer.GetPolicy()) != permissionCount {
t.Fatalf("expected %d p rules in merged enforcer, got %d", permissionCount, len(enforcer.GetPolicy()))
}
if len(enforcer.GetGroupingPolicy()) != userCount {
t.Fatalf("expected deduplicated runtime g rules to stay at %d, got %d", userCount, len(enforcer.GetGroupingPolicy()))
}
allowed, err := enforcer.Enforce(users[userCount-1], "data1", "read")
if err != nil {
t.Fatalf("Enforce() in 1000x1000 scenario error: %v", err)
}
if !allowed {
t.Fatalf("expected last user to be allowed in 1000x1000 scenario")
}
}

View File

@@ -17,6 +17,8 @@ package object
import (
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
@@ -48,6 +50,7 @@ type Provider struct {
CustomAuthUrl string `xorm:"varchar(200)" json:"customAuthUrl"`
CustomTokenUrl string `xorm:"varchar(200)" json:"customTokenUrl"`
CustomUserInfoUrl string `xorm:"varchar(200)" json:"customUserInfoUrl"`
CustomLogoutUrl string `xorm:"varchar(200)" json:"customLogoutUrl"`
CustomLogo string `xorm:"varchar(200)" json:"customLogo"`
Scopes string `xorm:"varchar(100)" json:"scopes"`
UserMapping map[string]string `xorm:"varchar(500)" json:"userMapping"`
@@ -81,6 +84,8 @@ type Provider struct {
ProviderUrl string `xorm:"varchar(200)" json:"providerUrl"`
EnableProxy bool `json:"enableProxy"`
EnablePkce bool `json:"enablePkce"`
State string `xorm:"varchar(100)" json:"state"`
}
func GetMaskedProvider(provider *Provider, isMaskEnabled bool) *Provider {
@@ -232,6 +237,10 @@ func UpdateProvider(id string, provider *Provider) (bool, error) {
}
}
if err := fillOpenClawProviderDefaults(provider); err != nil {
return false, err
}
if name != provider.Name {
err := providerChangeTrigger(name, provider.Name)
if err != nil {
@@ -257,6 +266,10 @@ func UpdateProvider(id string, provider *Provider) (bool, error) {
return false, err
}
if affected != 0 {
refreshLogProviderRuntime(util.GetId(owner, name), provider)
}
return affected != 0, nil
}
@@ -273,11 +286,19 @@ func AddProvider(provider *Provider) (bool, error) {
}
}
if err := fillOpenClawProviderDefaults(provider); err != nil {
return false, err
}
affected, err := ormer.Engine.Insert(provider)
if err != nil {
return false, err
}
if affected != 0 {
refreshLogProviderRuntime("", provider)
}
return affected != 0, nil
}
@@ -287,6 +308,10 @@ func DeleteProvider(provider *Provider) (bool, error) {
return false, err
}
if affected != 0 {
stopLogProviderRuntime(provider.GetId())
}
return affected != 0, nil
}
@@ -638,6 +663,11 @@ func GetLogProviderFromProvider(provider *Provider) (log.LogProvider, error) {
if provider.Type == "Agent" && provider.SubType == "OpenClaw" {
providerName := provider.Name
return log.NewOpenClawProvider(providerName, func(entryType, message, clientIp, userAgent string) error {
// Bypass: metrics entries are temporarily not persisted to the database.
if entryType == "metrics" {
return nil
}
name := log.GenerateEntryName()
currentTime := util.GetCurrentTime()
entry := &Entry{
@@ -659,3 +689,43 @@ func GetLogProviderFromProvider(provider *Provider) (log.LogProvider, error) {
return log.GetLogProvider(provider.Type, provider.Host, provider.Port, provider.Title)
}
// InvokeCustomProviderLogout iterates through the application's Custom OAuth2 providers
// and calls their logout endpoint (if configured) to terminate the upstream session.
func InvokeCustomProviderLogout(application *Application, accessToken string) {
if application == nil {
return
}
for _, providerItem := range application.Providers {
provider := providerItem.Provider
if provider == nil || provider.Category != "OAuth" || !strings.HasPrefix(provider.Type, "Custom") {
continue
}
if provider.CustomLogoutUrl == "" {
continue
}
go callProviderLogoutUrl(provider, accessToken)
}
}
// callProviderLogoutUrl sends a logout/token-revocation request to the provider's logout URL.
// Supports RFC 7009 token revocation and Keycloak-style end_session endpoints.
func callProviderLogoutUrl(provider *Provider, accessToken string) {
params := url.Values{}
params.Set("token", accessToken)
params.Set("client_id", provider.ClientId)
params.Set("client_secret", provider.ClientSecret)
resp, err := http.PostForm(provider.CustomLogoutUrl, params)
if err != nil {
util.LogWarning(nil, "InvokeCustomProviderLogout: failed to call logout URL %s for provider %s: %v", provider.CustomLogoutUrl, provider.Name, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
util.LogWarning(nil, "InvokeCustomProviderLogout: logout URL %s returned status %d for provider %s", provider.CustomLogoutUrl, resp.StatusCode, provider.Name)
}
}

View File

@@ -98,40 +98,23 @@ func UpdateRole(id string, role *Role) (bool, error) {
return false, nil
}
visited := map[string]struct{}{}
permissions, err := GetPermissionsByRole(id)
if err != nil {
return false, err
}
for _, permission := range permissions {
removeGroupingPolicies(permission)
removePolicies(permission)
visited[permission.GetId()] = struct{}{}
}
ancestorRoles, err := GetAncestorRoles(id)
if err != nil {
return false, err
}
for _, r := range ancestorRoles {
permissions, err := GetPermissionsByRole(r.GetId())
renameRole := name != role.Name
oldPermissions := []*Permission{}
if renameRole {
oldPermissions, err = GetPermissionsByRole(id)
if err != nil {
return false, err
}
for _, permission := range permissions {
permissionId := permission.GetId()
if _, ok := visited[permissionId]; !ok {
removeGroupingPolicies(permission)
visited[permissionId] = struct{}{}
for _, permission := range oldPermissions {
err = removePolicies(permission)
if err != nil {
return false, err
}
}
}
if name != role.Name {
if renameRole {
err := roleChangeTrigger(name, role.Name)
if err != nil {
return false, err
@@ -143,47 +126,16 @@ func UpdateRole(id string, role *Role) (bool, error) {
return false, err
}
visited = map[string]struct{}{}
newRoleID := role.GetId()
permissions, err = GetPermissionsByRole(newRoleID)
if err != nil {
return false, err
}
for _, permission := range permissions {
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
err = addPolicies(permission)
if err != nil {
return false, err
}
visited[permission.GetId()] = struct{}{}
}
ancestorRoles, err = GetAncestorRoles(newRoleID)
if err != nil {
return false, err
}
for _, r := range ancestorRoles {
permissions, err := GetPermissionsByRole(r.GetId())
if renameRole && affected != 0 {
permissions, err := GetPermissionsByRole(role.GetId())
if err != nil {
return false, err
}
for _, permission := range permissions {
permissionId := permission.GetId()
if _, ok := visited[permissionId]; !ok {
err = addGroupingPolicies(permission)
if err != nil {
return false, err
}
visited[permissionId] = struct{}{}
err = addPolicies(permission)
if err != nil {
return false, err
}
}
}

View File

@@ -385,7 +385,7 @@ func GetSamlResponse(application *Application, user *User, samlRequest string, h
}
if application.EnableSamlC14n10 {
ctx.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList("")
ctx.Canonicalizer = dsig.MakeC14N10ExclusiveCanonicalizerWithPrefixList("xs")
}
// signedXML, err := ctx.SignEnvelopedLimix(samlResponse)

View File

@@ -21,6 +21,7 @@ import (
"fmt"
"strings"
"github.com/casdoor/casdoor/util"
"github.com/go-sql-driver/mysql"
"golang.org/x/crypto/ssh"
)
@@ -122,6 +123,10 @@ func (p *DatabaseSyncerProvider) AddUser(user *OriginalUser) (bool, error) {
// UpdateUser updates an existing user in the database
func (p *DatabaseSyncerProvider) UpdateUser(user *OriginalUser) (bool, error) {
key := p.Syncer.getTargetTablePrimaryKey()
if !util.FilterSQLIdentifier(key) {
return false, fmt.Errorf("object.UpdateUser: invalid primary key column name: %s", key)
}
m := p.Syncer.getMapFromOriginalUser(user)
pkValue := m[key]
delete(m, key)

View File

@@ -43,7 +43,8 @@ type Token struct {
CodeChallenge string `xorm:"varchar(100)" json:"codeChallenge"`
CodeIsUsed bool `json:"codeIsUsed"`
CodeExpireIn int64 `json:"codeExpireIn"`
Resource string `xorm:"varchar(255)" json:"resource"` // RFC 8707 Resource Indicator
Resource string `xorm:"varchar(255)" json:"resource"` // RFC 8707 Resource Indicator
DPoPJkt string `xorm:"varchar(255) 'dpop_jkt'" json:"dPoPJkt"` // RFC 9449 DPoP JWK thumbprint binding
}
func GetTokenCount(owner, organization, field, value string) (int64, error) {
@@ -235,3 +236,9 @@ func ExpireTokenByUser(owner, username string) (bool, error) {
return affected != 0, nil
}
// updateTokenDPoP updates the token_type and dpop_jkt columns for DPoP binding (RFC 9449).
func updateTokenDPoP(token *Token) error {
_, err := ormer.Engine.ID(core.PK{token.Owner, token.Name}).Cols("token_type", "dpop_jkt").Update(token)
return err
}

157
object/token_dpop.go Normal file
View File

@@ -0,0 +1,157 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"crypto"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/golang-jwt/jwt/v5"
)
const dpopMaxAgeSeconds = 300
// DPoPProofClaims represents the payload claims of a DPoP proof JWT (RFC 9449).
type DPoPProofClaims struct {
Jti string `json:"jti"`
Htm string `json:"htm"`
Htu string `json:"htu"`
Ath string `json:"ath,omitempty"`
jwt.RegisteredClaims
}
// ValidateDPoPProof validates a DPoP proof JWT as specified in RFC 9449.
//
// - proofToken: the compact-serialized DPoP proof JWT from the DPoP HTTP header
// - method: the HTTP request method (e.g., "POST", "GET")
// - htu: the HTTP request URL without query string or fragment
// - accessToken: the access token string; empty at the token endpoint,
// non-empty at protected resource endpoints (enables ath claim validation)
//
// On success it returns the base64url-encoded SHA-256 JWK thumbprint (jkt) of
// the DPoP public key embedded in the proof header.
func ValidateDPoPProof(proofToken, method, htu, accessToken string) (string, error) {
parts := strings.Split(proofToken, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid DPoP proof JWT format")
}
// Decode and inspect the JOSE header before signature verification.
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("failed to decode DPoP proof header: %w", err)
}
var header struct {
Typ string `json:"typ"`
Alg string `json:"alg"`
JWK json.RawMessage `json:"jwk"`
}
if err = json.Unmarshal(headerBytes, &header); err != nil {
return "", fmt.Errorf("failed to parse DPoP proof header: %w", err)
}
// typ MUST be exactly "dpop+jwt" (RFC 9449 §4.2).
if header.Typ != "dpop+jwt" {
return "", fmt.Errorf("DPoP proof typ must be \"dpop+jwt\", got %q", header.Typ)
}
// alg MUST identify an asymmetric digital signature algorithm;
// symmetric algorithms (HS*) are explicitly forbidden (RFC 9449 §4.2).
if header.Alg == "" || strings.HasPrefix(header.Alg, "HS") {
return "", fmt.Errorf("DPoP proof must use an asymmetric algorithm, got %q", header.Alg)
}
// jwk MUST be present (RFC 9449 §4.2).
if len(header.JWK) == 0 {
return "", fmt.Errorf("DPoP proof header must contain the jwk claim")
}
var jwkKey jose.JSONWebKey
if err = jwkKey.UnmarshalJSON(header.JWK); err != nil {
return "", fmt.Errorf("failed to parse DPoP JWK: %w", err)
}
// Compute the JWK SHA-256 thumbprint per RFC 7638.
thumbprintBytes, err := jwkKey.Thumbprint(crypto.SHA256)
if err != nil {
return "", fmt.Errorf("failed to compute DPoP JWK thumbprint: %w", err)
}
jkt := base64.RawURLEncoding.EncodeToString(thumbprintBytes)
// Verify the proof's signature using the public key embedded in the header.
// WithoutClaimsValidation is used so that we can perform all claim checks
// ourselves (jwt library exp/nbf validation is not appropriate here).
t, err := jwt.ParseWithClaims(proofToken, &DPoPProofClaims{}, func(token *jwt.Token) (interface{}, error) {
return jwkKey.Key, nil
}, jwt.WithoutClaimsValidation())
if err != nil || !t.Valid {
return "", fmt.Errorf("DPoP proof signature verification failed: %w", err)
}
claims, ok := t.Claims.(*DPoPProofClaims)
if !ok {
return "", fmt.Errorf("failed to parse DPoP proof claims")
}
// htm MUST match the HTTP request method (RFC 9449 §4.2).
if !strings.EqualFold(claims.Htm, method) {
return "", fmt.Errorf("DPoP proof htm %q does not match request method %q", claims.Htm, method)
}
// htu MUST match the request URL without query/fragment (RFC 9449 §4.2).
if !strings.EqualFold(claims.Htu, htu) {
return "", fmt.Errorf("DPoP proof htu %q does not match request URL %q", claims.Htu, htu)
}
// iat MUST be present and within the acceptable time window (RFC 9449 §4.2).
if claims.IssuedAt == nil {
return "", fmt.Errorf("DPoP proof missing iat claim")
}
age := time.Since(claims.IssuedAt.Time).Abs()
if age > time.Duration(dpopMaxAgeSeconds)*time.Second {
return "", fmt.Errorf("DPoP proof iat is outside the acceptable time window (%d seconds)", dpopMaxAgeSeconds)
}
// jti MUST be present to support replay detection (RFC 9449 §4.2).
if claims.Jti == "" {
return "", fmt.Errorf("DPoP proof missing jti claim")
}
// ath MUST be validated at protected resource endpoints (RFC 9449 §4.2).
// It is the base64url-encoded SHA-256 hash of the ASCII access token string.
if accessToken != "" {
hash := sha256.Sum256([]byte(accessToken))
expectedAth := base64.RawURLEncoding.EncodeToString(hash[:])
if claims.Ath != expectedAth {
return "", fmt.Errorf("DPoP proof ath claim does not match access token hash")
}
}
return jkt, nil
}
// GetDPoPHtu constructs the full DPoP htu URL for a given host and path.
// It uses the same origin-detection logic as the rest of the backend.
func GetDPoPHtu(host, path string) string {
_, originBackend := getOriginFromHost(host)
return originBackend + path
}

View File

@@ -15,247 +15,15 @@
package object
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/idp"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
const (
hourSeconds = int(time.Hour / time.Second)
InvalidRequest = "invalid_request"
InvalidClient = "invalid_client"
InvalidGrant = "invalid_grant"
UnauthorizedClient = "unauthorized_client"
UnsupportedGrantType = "unsupported_grant_type"
InvalidScope = "invalid_scope"
EndpointError = "endpoint_error"
)
var DeviceAuthMap = sync.Map{}
type Code struct {
Message string `xorm:"varchar(100)" json:"message"`
Code string `xorm:"varchar(100)" json:"code"`
}
type TokenWrapper struct {
AccessToken string `json:"access_token"`
IdToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
type TokenError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
type IntrospectionResponse struct {
Active bool `json:"active"`
Scope string `json:"scope,omitempty"`
ClientId string `json:"client_id,omitempty"`
Username string `json:"username,omitempty"`
TokenType string `json:"token_type,omitempty"`
Exp int64 `json:"exp,omitempty"`
Iat int64 `json:"iat,omitempty"`
Nbf int64 `json:"nbf,omitempty"`
Sub string `json:"sub,omitempty"`
Aud []string `json:"aud,omitempty"`
Iss string `json:"iss,omitempty"`
Jti string `json:"jti,omitempty"`
}
type DeviceAuthCache struct {
UserSignIn bool
UserName string
ApplicationId string
Scope string
RequestAt time.Time
}
type DeviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationUri string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// validateResourceURI validates that the resource parameter is a valid absolute URI
// according to RFC 8707 Section 2
func validateResourceURI(resource string) error {
if resource == "" {
return nil // empty resource is allowed (backward compatibility)
}
parsedURL, err := url.Parse(resource)
if err != nil {
return fmt.Errorf("resource must be a valid URI")
}
// RFC 8707: The resource parameter must be an absolute URI
if !parsedURL.IsAbs() {
return fmt.Errorf("resource must be an absolute URI")
}
return nil
}
func ExpireTokenByAccessToken(accessToken string) (bool, *Application, *Token, error) {
token, err := GetTokenByAccessToken(accessToken)
if err != nil {
return false, nil, nil, err
}
if token == nil {
return false, nil, nil, nil
}
token.ExpiresIn = 0
affected, err := ormer.Engine.ID(core.PK{token.Owner, token.Name}).Cols("expires_in").Update(token)
if err != nil {
return false, nil, nil, err
}
application, err := getApplication(token.Owner, token.Application)
if err != nil {
return false, nil, nil, err
}
return affected != 0, application, token, nil
}
func CheckOAuthLogin(clientId string, responseType string, redirectUri string, scope string, state string, lang string) (string, *Application, error) {
if responseType != "code" && responseType != "token" && responseType != "id_token" {
return fmt.Sprintf(i18n.Translate(lang, "token:Grant_type: %s is not supported in this application"), responseType), nil, nil
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return "", nil, err
}
if application == nil {
return i18n.Translate(lang, "token:Invalid client_id"), nil, nil
}
if !application.IsRedirectUriValid(redirectUri) {
return fmt.Sprintf(i18n.Translate(lang, "token:Redirect URI: %s doesn't exist in the allowed Redirect URI list"), redirectUri), application, nil
}
if !IsScopeValid(scope, application) {
return i18n.Translate(lang, "token:Invalid scope"), application, nil
}
// Mask application for /api/get-app-login
application.ClientSecret = ""
return "", application, nil
}
func GetOAuthCode(userId string, clientId string, provider string, signinMethod string, responseType string, redirectUri string, scope string, state string, nonce string, challenge string, resource string, host string, lang string) (*Code, error) {
user, err := GetUser(userId)
if err != nil {
return nil, err
}
if user == nil {
return &Code{
Message: fmt.Sprintf("general:The user: %s doesn't exist", userId),
Code: "",
}, nil
}
if user.IsForbidden {
return &Code{
Message: "error: the user is forbidden to sign in, please contact the administrator",
Code: "",
}, nil
}
msg, application, err := CheckOAuthLogin(clientId, responseType, redirectUri, scope, state, lang)
if err != nil {
return nil, err
}
if msg != "" {
return &Code{
Message: msg,
Code: "",
}, nil
}
// Expand regex/wildcard scopes to concrete scope names.
expandedScope, ok := IsScopeValidAndExpand(scope, application)
if !ok {
return &Code{
Message: i18n.Translate(lang, "token:Invalid scope"),
Code: "",
}, nil
}
scope = expandedScope
// Validate resource parameter (RFC 8707)
if err := validateResourceURI(resource); err != nil {
return &Code{
Message: err.Error(),
Code: "",
}, nil
}
err = ExtendUserWithRolesAndPermissions(user)
if err != nil {
return nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, provider, signinMethod, nonce, scope, resource, host)
if err != nil {
return nil, err
}
if challenge == "null" {
challenge = ""
}
token := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: user.Owner,
User: user.Name,
Code: util.GenerateClientId(),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: scope,
TokenType: "Bearer",
CodeChallenge: challenge,
CodeIsUsed: false,
CodeExpireIn: time.Now().Add(time.Minute * 5).Unix(),
Resource: resource,
}
_, err = AddToken(token)
if err != nil {
return nil, err
}
return &Code{
Message: "",
Code: token.Code,
}, nil
}
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string) (interface{}, error) {
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string, dpopProof string) (interface{}, error) {
var (
application *Application
err error
@@ -292,7 +60,6 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
}
// Check if grantType is allowed in the current application
if !IsGrantTypeValid(grantType, application.GrantTypes) && tag == "" {
return &TokenError{
Error: UnsupportedGrantType,
@@ -305,7 +72,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
switch grantType {
case "authorization_code": // Authorization Code Grant
token, tokenError, err = GetAuthorizationCodeToken(application, clientSecret, code, verifier, resource)
case "password": // Resource Owner Password Credentials Grant
case "password": // Resource Owner Password Credentials Grant
token, tokenError, err = GetPasswordToken(application, username, password, scope, host)
case "client_credentials": // Client Credentials Grant
token, tokenError, err = GetClientCredentialsToken(application, clientSecret, scope, host)
@@ -318,7 +85,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
case "urn:ietf:params:oauth:grant-type:token-exchange": // Token Exchange Grant (RFC 8693)
token, tokenError, err = GetTokenExchangeToken(application, clientSecret, subjectToken, subjectTokenType, audience, scope, host)
case "refresh_token":
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host, dpopProof)
if err != nil {
return nil, err
}
@@ -341,6 +108,23 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
return tokenError, nil
}
// Apply DPoP binding (RFC 9449) if a DPoP proof was supplied by the client.
if dpopProof != "" {
dpopHtu := GetDPoPHtu(host, "/api/login/oauth/access_token")
jkt, dpopErr := ValidateDPoPProof(dpopProof, "POST", dpopHtu, "")
if dpopErr != nil {
return &TokenError{
Error: "invalid_dpop_proof",
ErrorDescription: dpopErr.Error(),
}, nil
}
token.TokenType = "DPoP"
token.DPoPJkt = jkt
if err = updateTokenDPoP(token); err != nil {
return nil, err
}
}
token.CodeIsUsed = true
_, err = updateUsedByCode(token)
@@ -360,392 +144,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
return tokenWrapper, nil
}
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string) (interface{}, error) {
// check parameters
if grantType != "refresh_token" {
return &TokenError{
Error: UnsupportedGrantType,
ErrorDescription: "grant_type should be refresh_token",
}, nil
}
var err error
if application == nil {
application, err = GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
if application == nil {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_id is invalid",
}, nil
}
}
if clientSecret != "" && application.ClientSecret != clientSecret {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_secret is invalid",
}, nil
}
// check whether the refresh token is valid, and has not expired.
token, err := GetTokenByRefreshToken(refreshToken)
if err != nil || token == nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "refresh token is invalid or revoked",
}, nil
}
// check if the token has been invalidated (e.g., by SSO logout)
if token.ExpiresIn <= 0 {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "refresh token is expired",
}, nil
}
cert, err := getCertByApplication(application)
if err != nil {
return nil, err
}
if cert == nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("cert: %s cannot be found", application.Cert),
}, nil
}
var oldTokenScope string
if application.TokenFormat == "JWT-Standard" {
oldToken, err := ParseStandardJwtToken(refreshToken, cert)
if err != nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("parse refresh token error: %s", err.Error()),
}, nil
}
oldTokenScope = oldToken.Scope
} else {
oldToken, err := ParseJwtToken(refreshToken, cert)
if err != nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("parse refresh token error: %s", err.Error()),
}, nil
}
oldTokenScope = oldToken.Scope
}
if scope == "" {
scope = oldTokenScope
}
// generate a new token
user, err := getUser(application.Organization, token.User)
if err != nil {
return nil, err
}
if user == nil {
return "", fmt.Errorf("The user: %s doesn't exist", util.GetId(application.Organization, token.User))
}
if user.IsForbidden {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
}, nil
}
err = ExtendUserWithRolesAndPermissions(user)
if err != nil {
return nil, err
}
newAccessToken, newRefreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
if err != nil {
return &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("generate jwt token error: %s", err.Error()),
}, nil
}
newToken := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: user.Owner,
User: user.Name,
Code: util.GenerateClientId(),
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: scope,
TokenType: "Bearer",
}
_, err = AddToken(newToken)
if err != nil {
return nil, err
}
_, err = DeleteToken(token)
if err != nil {
return nil, err
}
tokenWrapper := &TokenWrapper{
AccessToken: newToken.AccessToken,
IdToken: newToken.AccessToken,
RefreshToken: newToken.RefreshToken,
TokenType: newToken.TokenType,
ExpiresIn: newToken.ExpiresIn,
Scope: newToken.Scope,
}
return tokenWrapper, nil
}
// PkceChallenge: base64-URL-encoded SHA256 hash of verifier, per rfc 7636
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
challenge := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(sum[:])
return challenge
}
// IsGrantTypeValid
// Check if grantType is allowed in the current application
// authorization_code is allowed by default
func IsGrantTypeValid(method string, grantTypes []string) bool {
if method == "authorization_code" {
return true
}
for _, m := range grantTypes {
if m == method {
return true
}
}
return false
}
// isRegexScope returns true if the scope string contains regex metacharacters.
func isRegexScope(scope string) bool {
return strings.ContainsAny(scope, ".*+?^${}()|[]\\")
}
// IsScopeValidAndExpand expands any regex patterns in the space-separated scope string
// against the application's configured scopes. Literal scopes are kept as-is
// after verifying they exist in the allowed list. Regex scopes are matched
// against every allowed scope name; all matches replace the pattern.
// If the application has no defined scopes, the original scope string is
// returned unchanged (backward-compatible behaviour).
// Returns the expanded scope string and whether the scope is valid.
func IsScopeValidAndExpand(scope string, application *Application) (string, bool) {
if len(application.Scopes) == 0 || scope == "" {
return scope, true
}
allowedNames := make([]string, 0, len(application.Scopes))
allowedSet := make(map[string]bool, len(application.Scopes))
for _, s := range application.Scopes {
allowedNames = append(allowedNames, s.Name)
allowedSet[s.Name] = true
}
seen := make(map[string]bool)
var expanded []string
for _, s := range strings.Fields(scope) {
// Try exact match first.
if allowedSet[s] {
if !seen[s] {
seen[s] = true
expanded = append(expanded, s)
}
continue
}
// Not an exact match if it looks like a regex, try pattern matching.
if !isRegexScope(s) {
return "", false
}
// Treat as regex pattern must be a valid regex and match ≥ 1 scope.
re, err := regexp.Compile("^" + s + "$")
if err != nil {
return "", false
}
matched := false
for _, name := range allowedNames {
if re.MatchString(name) {
matched = true
if !seen[name] {
seen[name] = true
expanded = append(expanded, name)
}
}
}
if !matched {
return "", false
}
}
return strings.Join(expanded, " "), true
}
// IsScopeValid checks whether all space-separated scopes in the scope string
// are defined in the application's Scopes list (including regex expansion).
// If the application has no defined scopes, every scope is considered valid
// (backward-compatible behaviour).
func IsScopeValid(scope string, application *Application) bool {
_, ok := IsScopeValidAndExpand(scope, application)
return ok
}
// createGuestUserToken creates a new guest user and returns a token for them
func createGuestUserToken(application *Application, clientSecret string, verifier string) (*Token, *TokenError, error) {
// Verify client secret if provided
if clientSecret != "" && application.ClientSecret != clientSecret {
return nil, &TokenError{
Error: InvalidClient,
ErrorDescription: "client_secret is invalid",
}, nil
}
// Generate a unique guest username
guestUsername := generateGuestUsername()
// Generate a random password for the guest user
guestPassword := util.GenerateId()
// Get organization
organization, err := GetOrganization(util.GetId("admin", application.Organization))
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to get organization: %s", err.Error()),
}, nil
}
if organization == nil {
return nil, &TokenError{
Error: InvalidClient,
ErrorDescription: fmt.Sprintf("organization: %s does not exist", application.Organization),
}, nil
}
// Get initial score
initScore, err := organization.GetInitScore()
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to get init score: %s", err.Error()),
}, nil
}
// Generate a unique user ID within the confines of the application
newUserId, idErr := GenerateIdForNewUser(application)
if idErr != nil {
// If we fail to generate a unique user ID, we can fallback to a random ID
newUserId = util.GenerateId()
}
// Create the guest user
guestUser := &User{
Owner: application.Organization,
Name: guestUsername,
CreatedTime: util.GetCurrentTime(),
Id: newUserId,
Type: "normal-user",
Password: guestPassword,
Tag: "guest-user",
DisplayName: fmt.Sprintf("Guest_%s", guestUsername[:8]),
Avatar: "",
Address: []string{},
Email: "",
Phone: "",
Score: initScore,
IsAdmin: false,
IsForbidden: false,
IsDeleted: false,
SignupApplication: application.Name,
Properties: map[string]string{},
RegisterType: "Guest Signup",
RegisterSource: fmt.Sprintf("%s/%s", application.Organization, application.Name),
}
// Add the user
affected, err := AddUser(guestUser, "en")
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to create guest user: %s", err.Error()),
}, nil
}
if !affected {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: "failed to create guest user",
}, nil
}
// Extend user with roles and permissions
err = ExtendUserWithRolesAndPermissions(guestUser)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to extend user: %s", err.Error()),
}, nil
}
// Generate JWT token
accessToken, refreshToken, tokenName, err := generateJwtToken(application, guestUser, "", "", "", "", "", "")
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to generate token: %s", err.Error()),
}, nil
}
// Create token object
token := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: guestUser.Owner,
User: guestUser.Name,
Code: util.GenerateClientId(),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: "",
TokenType: "Bearer",
CodeChallenge: "",
CodeIsUsed: true,
CodeExpireIn: 0,
}
_, err = AddToken(token)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to add token: %s", err.Error()),
}, nil
}
return token, nil, nil
}
// generateGuestUsername generates a unique username for guest users
func generateGuestUsername() string {
return fmt.Sprintf("guest_%s", util.GenerateUUID())
}
// GetAuthorizationCodeToken
// Authorization code flow
// GetAuthorizationCodeToken handles the Authorization Code Grant flow.
func GetAuthorizationCodeToken(application *Application, clientSecret string, code string, verifier string, resource string) (*Token, *TokenError, error) {
if code == "" {
return nil, &TokenError{
@@ -851,8 +250,7 @@ func GetAuthorizationCodeToken(application *Application, clientSecret string, co
return token, nil, nil
}
// GetPasswordToken
// Resource Owner Password Credentials flow
// GetPasswordToken handles the Resource Owner Password Credentials Grant flow.
func GetPasswordToken(application *Application, username string, password string, scope string, host string) (*Token, *TokenError, error) {
expandedScope, ok := IsScopeValidAndExpand(scope, application)
if !ok {
@@ -935,8 +333,7 @@ func GetPasswordToken(application *Application, username string, password string
return token, nil, nil
}
// GetClientCredentialsToken
// Client Credentials flow
// GetClientCredentialsToken handles the Client Credentials Grant flow.
func GetClientCredentialsToken(application *Application, clientSecret string, scope string, host string) (*Token, *TokenError, error) {
if application.ClientSecret != clientSecret {
return nil, &TokenError{
@@ -988,44 +385,7 @@ func GetClientCredentialsToken(application *Application, clientSecret string, sc
return token, nil, nil
}
// mintImplicitToken mints a token for an already-authenticated user.
// Callers must verify user identity before calling this function.
func mintImplicitToken(application *Application, username string, scope string, nonce string, host string) (*Token, *TokenError, error) {
expandedScope, ok := IsScopeValidAndExpand(scope, application)
if !ok {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: "the requested scope is invalid or not defined in the application",
}, nil
}
scope = expandedScope
user, err := GetUserByFields(application.Organization, username)
if err != nil {
return nil, nil, err
}
if user == nil {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user does not exist",
}, nil
}
if user.IsForbidden {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
}, nil
}
token, err := GetTokenByUser(application, user, scope, nonce, host)
if err != nil {
return nil, nil, err
}
return token, nil, nil
}
// GetImplicitToken
// Implicit flow - requires password verification before minting a token
// GetImplicitToken handles the Implicit Grant flow (requires password verification).
func GetImplicitToken(application *Application, username string, password string, scope string, nonce string, host string) (*Token, *TokenError, error) {
user, err := GetUserByFields(application.Organization, username)
if err != nil {
@@ -1059,8 +419,7 @@ func GetImplicitToken(application *Application, username string, password string
return mintImplicitToken(application, username, scope, nonce, host)
}
// GetJwtBearerToken
// RFC 7523
// GetJwtBearerToken handles the JWT Bearer Grant flow (RFC 7523).
func GetJwtBearerToken(application *Application, assertion string, scope string, nonce string, host string) (*Token, *TokenError, error) {
ok, claims, err := ValidateJwtAssertion(assertion, application, host)
if err != nil || !ok {
@@ -1081,65 +440,7 @@ func GetJwtBearerToken(application *Application, assertion string, scope string,
return mintImplicitToken(application, claims.Subject, scope, nonce, host)
}
func ValidateJwtAssertion(clientAssertion string, application *Application, host string) (bool, *Claims, error) {
_, originBackend := getOriginFromHost(host)
clientCert, err := getCert(application.Owner, application.ClientCert)
if err != nil {
return false, nil, err
}
if clientCert == nil {
return false, nil, fmt.Errorf("client certificate is not configured for application: [%s]", application.GetId())
}
claims, err := ParseJwtToken(clientAssertion, clientCert)
if err != nil {
return false, nil, err
}
if !slices.Contains(application.RedirectUris, claims.Issuer) {
return false, nil, nil
}
if !slices.Contains(claims.Audience, fmt.Sprintf("%s/api/login/oauth/access_token", originBackend)) {
return false, nil, nil
}
return true, claims, nil
}
func ValidateClientAssertion(clientAssertion string, host string) (bool, *Application, error) {
token, err := ParseJwtTokenWithoutValidation(clientAssertion)
if err != nil {
return false, nil, err
}
clientId, err := token.Claims.GetSubject()
if err != nil {
return false, nil, err
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return false, nil, err
}
if application == nil {
return false, nil, fmt.Errorf("application not found for client: [%s]", clientId)
}
ok, _, err := ValidateJwtAssertion(clientAssertion, application, host)
if err != nil {
return false, application, err
}
if !ok {
return false, application, nil
}
return true, application, nil
}
// GetTokenByUser
// Implicit flow
// GetTokenByUser mints a token for the given user (Implicit flow helper).
func GetTokenByUser(application *Application, user *User, scope string, nonce string, host string) (*Token, error) {
err := ExtendUserWithRolesAndPermissions(user)
if err != nil {
@@ -1174,8 +475,7 @@ func GetTokenByUser(application *Application, user *User, scope string, nonce st
return token, nil
}
// GetWechatMiniProgramToken
// Wechat Mini Program flow
// GetWechatMiniProgramToken handles the WeChat Mini Program flow.
func GetWechatMiniProgramToken(application *Application, code string, host string, username string, avatar string, lang string) (*Token, *TokenError, error) {
mpProvider := GetWechatMiniProgramProvider(application)
if mpProvider == nil {
@@ -1290,72 +590,9 @@ func GetWechatMiniProgramToken(application *Application, code string, host strin
return token, nil, nil
}
// parseAndValidateSubjectToken validates a subject_token for RFC 8693 token exchange.
// It uses the ISSUING application's certificate (not the requesting client's) and
// enforces audience binding to prevent cross-client token reuse.
func parseAndValidateSubjectToken(subjectToken string, requestingClientId string) (owner, name, scope string, tokenErr *TokenError, err error) {
unverifiedToken, err := ParseJwtTokenWithoutValidation(subjectToken)
if err != nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
}
unverifiedClaims, ok := unverifiedToken.Claims.(*Claims)
if !ok || unverifiedClaims.Azp == "" {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: "subject_token is missing the azp claim"}, nil
}
issuingApp, err := GetApplicationByClientId(unverifiedClaims.Azp)
if err != nil {
return "", "", "", nil, err
}
if issuingApp == nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("subject_token issuing application not found: %s", unverifiedClaims.Azp)}, nil
}
cert, err := getCertByApplication(issuingApp)
if err != nil {
return "", "", "", nil, err
}
if cert == nil {
return "", "", "", &TokenError{Error: EndpointError, ErrorDescription: fmt.Sprintf("cert for issuing application %s cannot be found", unverifiedClaims.Azp)}, nil
}
if issuingApp.TokenFormat == "JWT-Standard" {
standardClaims, err := ParseStandardJwtToken(subjectToken, cert)
if err != nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
}
return standardClaims.Owner, standardClaims.Name, standardClaims.Scope, nil, nil
}
claims, err := ParseJwtToken(subjectToken, cert)
if err != nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
}
// Audience binding: requesting client must be the issuer itself or appear in token's aud.
// Prevents an attacker from exchanging App A's token to obtain an App B token (RFC 8693 §2.1).
if issuingApp.ClientId != requestingClientId {
audienceMatched := false
for _, aud := range claims.Audience {
if aud == requestingClientId {
audienceMatched = true
break
}
}
if !audienceMatched {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("subject_token audience does not include the requesting client '%s'", requestingClientId)}, nil
}
}
return claims.Owner, claims.Name, claims.Scope, nil, nil
}
// GetTokenExchangeToken
// Token Exchange Grant (RFC 8693)
// Exchanges a subject token for a new token with different audience or scope
// GetTokenExchangeToken handles the Token Exchange Grant flow (RFC 8693).
// Exchanges a subject token for a new token with different audience or scope.
func GetTokenExchangeToken(application *Application, clientSecret string, subjectToken string, subjectTokenType string, audience string, scope string, host string) (*Token, *TokenError, error) {
// Verify client secret
if application.ClientSecret != clientSecret {
return nil, &TokenError{
Error: InvalidClient,
@@ -1363,7 +600,6 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
}, nil
}
// Validate subject_token parameter
if subjectToken == "" {
return nil, &TokenError{
Error: InvalidRequest,
@@ -1371,13 +607,11 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
}, nil
}
// Validate subject_token_type parameter
// RFC 8693 defines standard token type identifiers
if subjectTokenType == "" {
subjectTokenType = "urn:ietf:params:oauth:token-type:access_token" // Default to access_token
}
// Support common token types
supportedTokenTypes := []string{
"urn:ietf:params:oauth:token-type:access_token",
"urn:ietf:params:oauth:token-type:jwt",
@@ -1407,7 +641,6 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
return nil, tokenError, nil
}
// Get the user from the subject token
user, err := getUser(subjectOwner, subjectName)
if err != nil {
return nil, nil, err
@@ -1426,20 +659,17 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
}, nil
}
// Handle scope parameter
// If scope is not provided, use the scope from the subject token
// If scope is provided, it should be a subset of the subject token's scope (downscoping)
// If scope is not provided, use the scope from the subject token.
// If scope is provided, it should be a subset of the subject token's scope (downscoping).
if scope == "" {
scope = subjectScope
} else {
// Validate scope downscoping (basic implementation)
// In a production environment, you would implement more sophisticated scope validation
if subjectScope != "" {
subjectScopes := strings.Split(subjectScope, " ")
requestedScopes := strings.Split(scope, " ")
for _, requestedScope := range requestedScopes {
if requestedScope == "" {
continue // Skip empty strings
continue
}
found := false
for _, existingScope := range subjectScopes {
@@ -1458,13 +688,11 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
}
}
// Extend user with roles and permissions
err = ExtendUserWithRolesAndPermissions(user)
if err != nil {
return nil, nil, err
}
// Generate new JWT token
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
if err != nil {
return nil, &TokenError{
@@ -1473,7 +701,6 @@ func GetTokenExchangeToken(application *Application, clientSecret string, subjec
}, nil
}
// Create token object
token := &Token{
Owner: application.Owner,
Name: tokenName,

800
object/token_oauth_util.go Normal file
View File

@@ -0,0 +1,800 @@
// Copyright 2024 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/casdoor/casdoor/i18n"
"github.com/casdoor/casdoor/util"
"github.com/xorm-io/core"
)
const (
hourSeconds = int(time.Hour / time.Second)
InvalidRequest = "invalid_request"
InvalidClient = "invalid_client"
InvalidGrant = "invalid_grant"
UnauthorizedClient = "unauthorized_client"
UnsupportedGrantType = "unsupported_grant_type"
InvalidScope = "invalid_scope"
EndpointError = "endpoint_error"
)
var DeviceAuthMap = sync.Map{}
type Code struct {
Message string `xorm:"varchar(100)" json:"message"`
Code string `xorm:"varchar(100)" json:"code"`
}
type TokenWrapper struct {
AccessToken string `json:"access_token"`
IdToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
type TokenError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
// DPoPConfirmation holds the DPoP key confirmation claim (RFC 9449).
type DPoPConfirmation struct {
JKT string `json:"jkt"`
}
type IntrospectionResponse struct {
Active bool `json:"active"`
Scope string `json:"scope,omitempty"`
ClientId string `json:"client_id,omitempty"`
Username string `json:"username,omitempty"`
TokenType string `json:"token_type,omitempty"`
Exp int64 `json:"exp,omitempty"`
Iat int64 `json:"iat,omitempty"`
Nbf int64 `json:"nbf,omitempty"`
Sub string `json:"sub,omitempty"`
Aud []string `json:"aud,omitempty"`
Iss string `json:"iss,omitempty"`
Jti string `json:"jti,omitempty"`
Cnf *DPoPConfirmation `json:"cnf,omitempty"` // RFC 9449 DPoP key binding
}
type DeviceAuthCache struct {
UserSignIn bool
UserName string
ApplicationId string
Scope string
RequestAt time.Time
}
type DeviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationUri string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// validateResourceURI validates that the resource parameter is a valid absolute URI
// according to RFC 8707 Section 2
func validateResourceURI(resource string) error {
if resource == "" {
return nil // empty resource is allowed (backward compatibility)
}
parsedURL, err := url.Parse(resource)
if err != nil {
return fmt.Errorf("resource must be a valid URI")
}
// RFC 8707: The resource parameter must be an absolute URI
if !parsedURL.IsAbs() {
return fmt.Errorf("resource must be an absolute URI")
}
return nil
}
// pkceChallenge returns the base64-URL-encoded SHA256 hash of verifier, per RFC 7636
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(sum[:])
}
// IsGrantTypeValid checks if grantType is allowed in the current application.
// authorization_code is allowed by default.
func IsGrantTypeValid(method string, grantTypes []string) bool {
if method == "authorization_code" {
return true
}
for _, m := range grantTypes {
if m == method {
return true
}
}
return false
}
// isRegexScope returns true if the scope string contains regex metacharacters.
func isRegexScope(scope string) bool {
return strings.ContainsAny(scope, ".*+?^${}()|[]\\")
}
// IsScopeValidAndExpand expands any regex patterns in the space-separated scope string
// against the application's configured scopes. Literal scopes are kept as-is
// after verifying they exist in the allowed list. Regex scopes are matched
// against every allowed scope name; all matches replace the pattern.
// If the application has no defined scopes, the original scope string is
// returned unchanged (backward-compatible behaviour).
// Returns the expanded scope string and whether the scope is valid.
func IsScopeValidAndExpand(scope string, application *Application) (string, bool) {
if len(application.Scopes) == 0 || scope == "" {
return scope, true
}
allowedNames := make([]string, 0, len(application.Scopes))
allowedSet := make(map[string]bool, len(application.Scopes))
for _, s := range application.Scopes {
allowedNames = append(allowedNames, s.Name)
allowedSet[s.Name] = true
}
seen := make(map[string]bool)
var expanded []string
for _, s := range strings.Fields(scope) {
// Try exact match first.
if allowedSet[s] {
if !seen[s] {
seen[s] = true
expanded = append(expanded, s)
}
continue
}
// Not an exact match if it looks like a regex, try pattern matching.
if !isRegexScope(s) {
return "", false
}
// Treat as regex pattern must be a valid regex and match ≥ 1 scope.
re, err := regexp.Compile("^" + s + "$")
if err != nil {
return "", false
}
matched := false
for _, name := range allowedNames {
if re.MatchString(name) {
matched = true
if !seen[name] {
seen[name] = true
expanded = append(expanded, name)
}
}
}
if !matched {
return "", false
}
}
return strings.Join(expanded, " "), true
}
// IsScopeValid checks whether all space-separated scopes in the scope string
// are defined in the application's Scopes list (including regex expansion).
// If the application has no defined scopes, every scope is considered valid
// (backward-compatible behaviour).
func IsScopeValid(scope string, application *Application) bool {
_, ok := IsScopeValidAndExpand(scope, application)
return ok
}
func ExpireTokenByAccessToken(accessToken string) (bool, *Application, *Token, error) {
token, err := GetTokenByAccessToken(accessToken)
if err != nil {
return false, nil, nil, err
}
if token == nil {
return false, nil, nil, nil
}
token.ExpiresIn = 0
affected, err := ormer.Engine.ID(core.PK{token.Owner, token.Name}).Cols("expires_in").Update(token)
if err != nil {
return false, nil, nil, err
}
application, err := getApplication(token.Owner, token.Application)
if err != nil {
return false, nil, nil, err
}
return affected != 0, application, token, nil
}
func CheckOAuthLogin(clientId string, responseType string, redirectUri string, scope string, state string, lang string) (string, *Application, error) {
if responseType != "code" && responseType != "token" && responseType != "id_token" {
return fmt.Sprintf(i18n.Translate(lang, "token:Grant_type: %s is not supported in this application"), responseType), nil, nil
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return "", nil, err
}
if application == nil {
return i18n.Translate(lang, "token:Invalid client_id"), nil, nil
}
if !application.IsRedirectUriValid(redirectUri) {
return fmt.Sprintf(i18n.Translate(lang, "token:Redirect URI: %s doesn't exist in the allowed Redirect URI list"), redirectUri), application, nil
}
if !IsScopeValid(scope, application) {
return i18n.Translate(lang, "token:Invalid scope"), application, nil
}
// Mask application for /api/get-app-login
application.ClientSecret = ""
return "", application, nil
}
func GetOAuthCode(userId string, clientId string, provider string, signinMethod string, responseType string, redirectUri string, scope string, state string, nonce string, challenge string, resource string, host string, lang string) (*Code, error) {
user, err := GetUser(userId)
if err != nil {
return nil, err
}
if user == nil {
return &Code{
Message: fmt.Sprintf("general:The user: %s doesn't exist", userId),
Code: "",
}, nil
}
if user.IsForbidden {
return &Code{
Message: "error: the user is forbidden to sign in, please contact the administrator",
Code: "",
}, nil
}
msg, application, err := CheckOAuthLogin(clientId, responseType, redirectUri, scope, state, lang)
if err != nil {
return nil, err
}
if msg != "" {
return &Code{
Message: msg,
Code: "",
}, nil
}
// Expand regex/wildcard scopes to concrete scope names.
expandedScope, ok := IsScopeValidAndExpand(scope, application)
if !ok {
return &Code{
Message: i18n.Translate(lang, "token:Invalid scope"),
Code: "",
}, nil
}
scope = expandedScope
// Validate resource parameter (RFC 8707)
if err := validateResourceURI(resource); err != nil {
return &Code{
Message: err.Error(),
Code: "",
}, nil
}
err = ExtendUserWithRolesAndPermissions(user)
if err != nil {
return nil, err
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, user, provider, signinMethod, nonce, scope, resource, host)
if err != nil {
return nil, err
}
if challenge == "null" {
challenge = ""
}
token := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: user.Owner,
User: user.Name,
Code: util.GenerateClientId(),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: scope,
TokenType: "Bearer",
CodeChallenge: challenge,
CodeIsUsed: false,
CodeExpireIn: time.Now().Add(time.Minute * 5).Unix(),
Resource: resource,
}
_, err = AddToken(token)
if err != nil {
return nil, err
}
return &Code{
Message: "",
Code: token.Code,
}, nil
}
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string, dpopProof string) (interface{}, error) {
if grantType != "refresh_token" {
return &TokenError{
Error: UnsupportedGrantType,
ErrorDescription: "grant_type should be refresh_token",
}, nil
}
var err error
if application == nil {
application, err = GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
if application == nil {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_id is invalid",
}, nil
}
}
if clientSecret != "" && application.ClientSecret != clientSecret {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_secret is invalid",
}, nil
}
// check whether the refresh token is valid, and has not expired.
token, err := GetTokenByRefreshToken(refreshToken)
if err != nil || token == nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "refresh token is invalid or revoked",
}, nil
}
// check if the token has been invalidated (e.g., by SSO logout)
if token.ExpiresIn <= 0 {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "refresh token is expired",
}, nil
}
cert, err := getCertByApplication(application)
if err != nil {
return nil, err
}
if cert == nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("cert: %s cannot be found", application.Cert),
}, nil
}
var oldTokenScope string
if application.TokenFormat == "JWT-Standard" {
oldToken, err := ParseStandardJwtToken(refreshToken, cert)
if err != nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("parse refresh token error: %s", err.Error()),
}, nil
}
oldTokenScope = oldToken.Scope
} else {
oldToken, err := ParseJwtToken(refreshToken, cert)
if err != nil {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("parse refresh token error: %s", err.Error()),
}, nil
}
oldTokenScope = oldToken.Scope
}
if scope == "" {
scope = oldTokenScope
}
// generate a new token
user, err := getUser(application.Organization, token.User)
if err != nil {
return nil, err
}
if user == nil {
return "", fmt.Errorf("The user: %s doesn't exist", util.GetId(application.Organization, token.User))
}
if user.IsForbidden {
return &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
}, nil
}
err = ExtendUserWithRolesAndPermissions(user)
if err != nil {
return nil, err
}
newAccessToken, newRefreshToken, tokenName, err := generateJwtToken(application, user, "", "", "", scope, "", host)
if err != nil {
return &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("generate jwt token error: %s", err.Error()),
}, nil
}
newToken := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: user.Owner,
User: user.Name,
Code: util.GenerateClientId(),
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: scope,
TokenType: "Bearer",
}
_, err = AddToken(newToken)
if err != nil {
return nil, err
}
// Apply DPoP binding to the refreshed token if a DPoP proof was provided.
if dpopProof != "" {
dpopHtu := GetDPoPHtu(host, "/api/login/oauth/access_token")
jkt, err := ValidateDPoPProof(dpopProof, "POST", dpopHtu, "")
if err != nil {
return &TokenError{
Error: "invalid_dpop_proof",
ErrorDescription: err.Error(),
}, nil
}
newToken.TokenType = "DPoP"
newToken.DPoPJkt = jkt
if err = updateTokenDPoP(newToken); err != nil {
return nil, err
}
}
_, err = DeleteToken(token)
if err != nil {
return nil, err
}
tokenWrapper := &TokenWrapper{
AccessToken: newToken.AccessToken,
IdToken: newToken.AccessToken,
RefreshToken: newToken.RefreshToken,
TokenType: newToken.TokenType,
ExpiresIn: newToken.ExpiresIn,
Scope: newToken.Scope,
}
return tokenWrapper, nil
}
func ValidateJwtAssertion(clientAssertion string, application *Application, host string) (bool, *Claims, error) {
_, originBackend := getOriginFromHost(host)
clientCert, err := getCert(application.Owner, application.ClientCert)
if err != nil {
return false, nil, err
}
if clientCert == nil {
return false, nil, fmt.Errorf("client certificate is not configured for application: [%s]", application.GetId())
}
claims, err := ParseJwtToken(clientAssertion, clientCert)
if err != nil {
return false, nil, err
}
if !slices.Contains(application.RedirectUris, claims.Issuer) {
return false, nil, nil
}
if !slices.Contains(claims.Audience, fmt.Sprintf("%s/api/login/oauth/access_token", originBackend)) {
return false, nil, nil
}
return true, claims, nil
}
func ValidateClientAssertion(clientAssertion string, host string) (bool, *Application, error) {
token, err := ParseJwtTokenWithoutValidation(clientAssertion)
if err != nil {
return false, nil, err
}
clientId, err := token.Claims.GetSubject()
if err != nil {
return false, nil, err
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return false, nil, err
}
if application == nil {
return false, nil, fmt.Errorf("application not found for client: [%s]", clientId)
}
ok, _, err := ValidateJwtAssertion(clientAssertion, application, host)
if err != nil {
return false, application, err
}
if !ok {
return false, application, nil
}
return true, application, nil
}
// mintImplicitToken mints a token for an already-authenticated user.
// Callers must verify user identity before calling this function.
func mintImplicitToken(application *Application, username string, scope string, nonce string, host string) (*Token, *TokenError, error) {
expandedScope, ok := IsScopeValidAndExpand(scope, application)
if !ok {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: "the requested scope is invalid or not defined in the application",
}, nil
}
scope = expandedScope
user, err := GetUserByFields(application.Organization, username)
if err != nil {
return nil, nil, err
}
if user == nil {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user does not exist",
}, nil
}
if user.IsForbidden {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: "the user is forbidden to sign in, please contact the administrator",
}, nil
}
token, err := GetTokenByUser(application, user, scope, nonce, host)
if err != nil {
return nil, nil, err
}
return token, nil, nil
}
// parseAndValidateSubjectToken validates a subject_token for RFC 8693 token exchange.
// It uses the ISSUING application's certificate (not the requesting client's) and
// enforces audience binding to prevent cross-client token reuse.
func parseAndValidateSubjectToken(subjectToken string, requestingClientId string) (owner, name, scope string, tokenErr *TokenError, err error) {
unverifiedToken, err := ParseJwtTokenWithoutValidation(subjectToken)
if err != nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
}
unverifiedClaims, ok := unverifiedToken.Claims.(*Claims)
if !ok || unverifiedClaims.Azp == "" {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: "subject_token is missing the azp claim"}, nil
}
issuingApp, err := GetApplicationByClientId(unverifiedClaims.Azp)
if err != nil {
return "", "", "", nil, err
}
if issuingApp == nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("subject_token issuing application not found: %s", unverifiedClaims.Azp)}, nil
}
cert, err := getCertByApplication(issuingApp)
if err != nil {
return "", "", "", nil, err
}
if cert == nil {
return "", "", "", &TokenError{Error: EndpointError, ErrorDescription: fmt.Sprintf("cert for issuing application %s cannot be found", unverifiedClaims.Azp)}, nil
}
if issuingApp.TokenFormat == "JWT-Standard" {
standardClaims, err := ParseStandardJwtToken(subjectToken, cert)
if err != nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
}
return standardClaims.Owner, standardClaims.Name, standardClaims.Scope, nil, nil
}
claims, err := ParseJwtToken(subjectToken, cert)
if err != nil {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("invalid subject_token: %s", err.Error())}, nil
}
// Audience binding: requesting client must be the issuer itself or appear in token's aud.
// Prevents an attacker from exchanging App A's token to obtain an App B token (RFC 8693 §2.1).
if issuingApp.ClientId != requestingClientId {
audienceMatched := false
for _, aud := range claims.Audience {
if aud == requestingClientId {
audienceMatched = true
break
}
}
if !audienceMatched {
return "", "", "", &TokenError{Error: InvalidGrant, ErrorDescription: fmt.Sprintf("subject_token audience does not include the requesting client '%s'", requestingClientId)}, nil
}
}
return claims.Owner, claims.Name, claims.Scope, nil, nil
}
// createGuestUserToken creates a new guest user and returns a token for them.
func createGuestUserToken(application *Application, clientSecret string, verifier string) (*Token, *TokenError, error) {
if clientSecret != "" && application.ClientSecret != clientSecret {
return nil, &TokenError{
Error: InvalidClient,
ErrorDescription: "client_secret is invalid",
}, nil
}
guestUsername := generateGuestUsername()
guestPassword := util.GenerateId()
organization, err := GetOrganization(util.GetId("admin", application.Organization))
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to get organization: %s", err.Error()),
}, nil
}
if organization == nil {
return nil, &TokenError{
Error: InvalidClient,
ErrorDescription: fmt.Sprintf("organization: %s does not exist", application.Organization),
}, nil
}
initScore, err := organization.GetInitScore()
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to get init score: %s", err.Error()),
}, nil
}
newUserId, idErr := GenerateIdForNewUser(application)
if idErr != nil {
newUserId = util.GenerateId()
}
guestUser := &User{
Owner: application.Organization,
Name: guestUsername,
CreatedTime: util.GetCurrentTime(),
Id: newUserId,
Type: "normal-user",
Password: guestPassword,
Tag: "guest-user",
DisplayName: fmt.Sprintf("Guest_%s", guestUsername[:8]),
Avatar: "",
Address: []string{},
Email: "",
Phone: "",
Score: initScore,
IsAdmin: false,
IsForbidden: false,
IsDeleted: false,
SignupApplication: application.Name,
Properties: map[string]string{},
RegisterType: "Guest Signup",
RegisterSource: fmt.Sprintf("%s/%s", application.Organization, application.Name),
}
affected, err := AddUser(guestUser, "en")
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to create guest user: %s", err.Error()),
}, nil
}
if !affected {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: "failed to create guest user",
}, nil
}
err = ExtendUserWithRolesAndPermissions(guestUser)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to extend user: %s", err.Error()),
}, nil
}
accessToken, refreshToken, tokenName, err := generateJwtToken(application, guestUser, "", "", "", "", "", "")
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to generate token: %s", err.Error()),
}, nil
}
token := &Token{
Owner: application.Owner,
Name: tokenName,
CreatedTime: util.GetCurrentTime(),
Application: application.Name,
Organization: guestUser.Owner,
User: guestUser.Name,
Code: util.GenerateClientId(),
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(application.ExpireInHours * float64(hourSeconds)),
Scope: "",
TokenType: "Bearer",
CodeChallenge: "",
CodeIsUsed: true,
CodeExpireIn: 0,
}
_, err = AddToken(token)
if err != nil {
return nil, &TokenError{
Error: EndpointError,
ErrorDescription: fmt.Sprintf("failed to add token: %s", err.Error()),
}, nil
}
return token, nil, nil
}
// generateGuestUsername generates a unique username for guest users.
func generateGuestUsername() string {
return fmt.Sprintf("guest_%s", util.GenerateUUID())
}

View File

@@ -406,6 +406,9 @@ func GetUsersByTagWithFilter(owner string, tag string, cond builder.Cond) ([]*Us
func GetSortedUsers(owner string, sorter string, limit int) ([]*User, error) {
users := []*User{}
if !util.FilterSQLIdentifier(sorter) {
return nil, fmt.Errorf("object.GetSortedUsers() error: invalid sorter field: %s", sorter)
}
err := ormer.Engine.Desc(sorter).Limit(limit, 0).Find(&users, &User{Owner: owner})
if err != nil {
return nil, err

View File

@@ -37,6 +37,10 @@ func GetUserByField(organizationName string, field string, value string) (*User,
return nil, nil
}
if !util.FilterSQLIdentifier(field) {
return nil, nil
}
user := User{Owner: organizationName}
existed, err := ormer.Engine.Where(fmt.Sprintf("%s=?", strings.ToLower(field)), value).Get(&user)
if err != nil {

View File

@@ -46,6 +46,7 @@ type OidcDiscovery struct {
RequestParameterSupported bool `json:"request_parameter_supported"`
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
EndSessionEndpoint string `json:"end_session_endpoint"`
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` // RFC 9449
}
type WebFinger struct {
@@ -167,6 +168,7 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
RequestParameterSupported: true,
RequestObjectSigningAlgValuesSupported: []string{"HS256", "HS384", "HS512"},
EndSessionEndpoint: fmt.Sprintf("%s/api/logout", originBackend),
DPoPSigningAlgValuesSupported: []string{"RS256", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"},
}
return oidcDiscovery

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "casdoor",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -141,6 +141,19 @@ func getObject(ctx *context.Context) (string, string, error) {
}
}
// For non-GET requests, if the `id` query param is present it is the
// authoritative identifier of the object being operated on. Use it
// instead of the request body so that an attacker cannot spoof the
// object owner by injecting "owner":"admin" (or any other value) into
// the request body while pointing the URL at a different organization's
// resource.
if id := ctx.Input.Query("id"); id != "" {
owner, name, err := util.GetOwnerAndNameFromIdWithError(id)
if err == nil {
return owner, name, nil
}
}
body := ctx.Input.RequestBody
if len(body) == 0 {
return ctx.Request.Form.Get("owner"), ctx.Request.Form.Get("name"), nil
@@ -347,6 +360,9 @@ func writePermissionLog(objOwner, subOwner, subName, method, urlPath string, all
if provider.Type == "System Log" {
continue
}
if provider.State == "Disabled" {
continue
}
logProvider, err := object.GetLogProviderFromProvider(provider)
if err != nil {
continue

View File

@@ -70,6 +70,25 @@ func AutoSigninFilter(ctx *context.Context) {
return
}
// Validate DPoP proof for DPoP-bound tokens (RFC 9449).
if token.TokenType == "DPoP" {
dpopProof := ctx.Request.Header.Get("DPoP")
if dpopProof == "" {
responseError(ctx, "DPoP proof header required for DPoP-bound access token")
return
}
htu := object.GetDPoPHtu(ctx.Request.Host, ctx.Request.URL.Path)
jkt, dpopErr := object.ValidateDPoPProof(dpopProof, ctx.Request.Method, htu, accessToken)
if dpopErr != nil {
responseError(ctx, fmt.Sprintf("DPoP proof validation failed: %s", dpopErr.Error()))
return
}
if jkt != token.DPoPJkt {
responseError(ctx, "DPoP proof key binding mismatch")
return
}
}
userId := util.GetId(token.Organization, token.User)
application, err := object.GetApplicationByUserId(fmt.Sprintf("app/%s", token.Application))
if err != nil {
@@ -97,6 +116,17 @@ func AutoSigninFilter(ctx *context.Context) {
return
}
// "/page?accessKey=123&accessSecret=456"
userId, err = getUsernameByAccessKey(ctx)
if err != nil {
responseError(ctx, err.Error())
return
}
if userId != "" {
setSessionUser(ctx, userId)
return
}
// "/page?username=built-in/admin&password=123"
userId = ctx.Input.Query("username")
password := ctx.Input.Query("password")

View File

@@ -22,6 +22,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/beego/beego/v2/server/web/context"
"github.com/casdoor/casdoor/conf"
@@ -110,8 +111,8 @@ func denyMcpRequest(ctx *context.Context) {
}
func getUsernameByClientIdSecret(ctx *context.Context) (string, error) {
clientId, clientSecret, ok := ctx.Request.BasicAuth()
if !ok {
clientId, clientSecret, fromBasicAuth := ctx.Request.BasicAuth()
if !fromBasicAuth {
clientId = ctx.Input.Query("clientId")
clientSecret = ctx.Input.Query("clientSecret")
}
@@ -125,16 +126,71 @@ func getUsernameByClientIdSecret(ctx *context.Context) (string, error) {
return "", err
}
if application == nil {
if fromBasicAuth {
// The Basic Auth credentials may come from a reverse proxy protecting Casdoor with
// HTTP Basic Auth. In that case, the username is not an OAuth client ID, so we
// silently ignore it instead of returning an error that would break the whole system.
return "", nil
}
return "", fmt.Errorf("Application not found for client ID: %s", clientId)
}
if application.ClientSecret != clientSecret {
if fromBasicAuth {
// Same as above: the secret mismatch may be due to proxy-level Basic Auth credentials.
return "", nil
}
return "", fmt.Errorf("Incorrect client secret for application: %s", application.Name)
}
return fmt.Sprintf("app/%s", application.Name), nil
}
func getUsernameByAccessKey(ctx *context.Context) (string, error) {
accessKey := ctx.Input.Query("accessKey")
accessSecret := ctx.Input.Query("accessSecret")
if accessKey == "" || accessSecret == "" {
return "", nil
}
key, err := object.GetKeyByAccessKey(accessKey)
if err != nil {
return "", err
}
if key == nil {
return "", fmt.Errorf("Access key not found: %s", accessKey)
}
if key.AccessSecret != accessSecret {
return "", fmt.Errorf("Incorrect access secret for key: %s", key.Name)
}
if key.State != "Active" {
return "", fmt.Errorf("Access key is not active: %s", key.Name)
}
if key.ExpireTime != "" {
expireTime, err := time.Parse(time.RFC3339, key.ExpireTime)
if err != nil {
return "", fmt.Errorf("Invalid expire time format for key: %s", key.Name)
}
if time.Now().After(expireTime) {
return "", fmt.Errorf("Access key has expired, expireTime = %s", key.ExpireTime)
}
}
if key.User != "" {
return util.GetId(key.Organization, key.User), nil
}
if key.Application != "" {
return fmt.Sprintf("app/%s", key.Application), nil
}
return "", nil
}
func getSessionUser(ctx *context.Context) string {
user := ctx.Input.CruSession.Get(stdcontext.Background(), "username")
if user == nil {
@@ -182,8 +238,9 @@ func parseBearerToken(ctx *context.Context) string {
return ""
}
// Accept both "Bearer" (RFC 6750) and "DPoP" (RFC 9449) authorization schemes.
prefix := tokens[0]
if prefix != "Bearer" {
if prefix != "Bearer" && prefix != "DPoP" {
return ""
}

View File

@@ -0,0 +1,48 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package routers
import (
"bytes"
"io"
"net/http"
"github.com/beego/beego/v2/server/web/context"
)
// RequestBodyFilter reads the raw request body early (before Beego's CopyBody
// and ParseForm) and caches it in Input.RequestBody. This prevents silent data
// corruption when clients send requests without a Content-Type: application/json
// header: without this filter, Beego's ParseForm may consume the body before
// controllers can read it, causing json.Unmarshal to receive empty bytes and
// produce zero-value structs that overwrite real data on AllCols().Update().
func RequestBodyFilter(ctx *context.Context) {
if ctx.Request.Method == http.MethodGet || ctx.Request.Method == http.MethodHead {
return
}
if ctx.Request.Body == nil || ctx.Request.Body == http.NoBody {
return
}
body, err := io.ReadAll(ctx.Request.Body)
if err != nil || len(body) == 0 {
return
}
// Restore Request.Body so Beego's subsequent CopyBody and ParseForm can read it.
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
// Cache the raw bytes directly so controllers always have access to them.
ctx.Input.RequestBody = body
}

View File

@@ -140,10 +140,12 @@ func InitAPI() {
web.Router("/api/sync-mcp-tool", &controllers.ApiController{}, "POST:SyncMcpTool")
web.Router("/api/add-server", &controllers.ApiController{}, "POST:AddServer")
web.Router("/api/delete-server", &controllers.ApiController{}, "POST:DeleteServer")
web.Router("/api/server/:owner/:name", &controllers.ApiController{}, "GET:ProxyServer")
web.Router("/api/server/:owner/:name", &controllers.ApiController{}, "POST:ProxyServer")
web.Router("/api/get-entries", &controllers.ApiController{}, "GET:GetEntries")
web.Router("/api/get-entry", &controllers.ApiController{}, "GET:GetEntry")
web.Router("/api/get-openclaw-session-graph", &controllers.ApiController{}, "GET:GetOpenClawSessionGraph")
web.Router("/api/update-entry", &controllers.ApiController{}, "POST:UpdateEntry")
web.Router("/api/add-entry", &controllers.ApiController{}, "POST:AddEntry")
web.Router("/api/delete-entry", &controllers.ApiController{}, "POST:DeleteEntry")

View File

@@ -67,3 +67,35 @@ func TryJsonToAnonymousStruct(j string) (interface{}, error) {
}
return i, nil
}
// InterfaceToEnforceValue converts a single request value for use in Casbin ABAC enforcement.
// - Strings that are valid JSON objects are converted to anonymous structs so Casbin can
// access their fields (e.g. r.sub.DivisionGuid).
// - Maps (map[string]interface{}) produced by direct JSON unmarshaling are re-marshaled and
// then converted to anonymous structs in the same way.
// - All other values are returned unchanged.
func InterfaceToEnforceValue(v interface{}) interface{} {
switch val := v.(type) {
case string:
jStruct, err := TryJsonToAnonymousStruct(val)
if err == nil {
return jStruct
}
return val
case map[string]interface{}:
// The value was already decoded as a JSON object; re-encode it so we
// can reuse TryJsonToAnonymousStruct to produce a named-field struct
// that Casbin can evaluate with dot-notation (r.sub.Field).
jsonBytes, err := json.Marshal(val)
if err != nil {
return val
}
jStruct, err := TryJsonToAnonymousStruct(string(jsonBytes))
if err == nil {
return jStruct
}
return val
default:
return v
}
}

View File

@@ -381,3 +381,25 @@ func StringToInterfaceArray2d(arrays [][]string) [][]interface{} {
}
return interfaceArrays
}
// InterfaceToEnforceArray converts a []interface{} request for use in Casbin ABAC enforcement.
// Each element is processed by InterfaceToEnforceValue: plain strings that are valid JSON
// objects and map values decoded directly from JSON are both converted to anonymous structs
// so Casbin can evaluate attribute-based rules with dot-notation (r.sub.Field).
func InterfaceToEnforceArray(array []interface{}) []interface{} {
result := make([]interface{}, len(array))
for i, elem := range array {
result[i] = InterfaceToEnforceValue(elem)
}
return result
}
// InterfaceToEnforceArray2d applies InterfaceToEnforceArray to every row in a
// two-dimensional slice, for use with Casbin BatchEnforce.
func InterfaceToEnforceArray2d(arrays [][]interface{}) [][]interface{} {
result := make([][]interface{}, len(arrays))
for i, arr := range arrays {
result[i] = InterfaceToEnforceArray(arr)
}
return result
}

View File

@@ -25,17 +25,19 @@ import (
)
var (
rePhone *regexp.Regexp
ReWhiteSpace *regexp.Regexp
ReFieldWhiteList *regexp.Regexp
ReUserName *regexp.Regexp
ReUserNameWithEmail *regexp.Regexp
rePhone *regexp.Regexp
ReWhiteSpace *regexp.Regexp
ReFieldWhiteList *regexp.Regexp
ReFieldWhiteListIdentifier *regexp.Regexp
ReUserName *regexp.Regexp
ReUserNameWithEmail *regexp.Regexp
)
func init() {
rePhone, _ = regexp.Compile(`(\d{3})\d*(\d{4})`)
ReWhiteSpace, _ = regexp.Compile(`\s`)
ReFieldWhiteList, _ = regexp.Compile(`^[A-Za-z0-9]+$`)
ReFieldWhiteListIdentifier, _ = regexp.Compile(`^[A-Za-z][A-Za-z0-9_]*$`)
ReUserName, _ = regexp.Compile("^[a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*$")
ReUserNameWithEmail, _ = regexp.Compile(`^([a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*)|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$`) // Add support for email formats
}
@@ -104,6 +106,13 @@ func FilterField(field string) bool {
return ReFieldWhiteList.MatchString(field)
}
// FilterSQLIdentifier validates that field is a safe SQL column identifier.
// It allows letters, digits, and underscores (e.g. "id_card", "created_time"),
// and requires the name to start with a letter to block numeric/special-char attacks.
func FilterSQLIdentifier(field string) bool {
return ReFieldWhiteListIdentifier.MatchString(field)
}
func IsValidOrigin(origin string) (bool, error) {
urlObj, err := url.Parse(origin)
if err != nil {

View File

@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@ant-design/cssinjs": "^1.23.0",
"@ant-design/icons": "^5.6.1",
"@ant-design/cssinjs": "^2.1.2",
"@ant-design/icons": "^6.1.1",
"@craco/craco": "^6.4.5",
"@crowdin/cli": "^3.7.10",
"@ctrl/tinycolor": "^3.5.0",
@@ -24,11 +24,11 @@
"@web3-onboard/sequence": "^2.0.8",
"@web3-onboard/taho": "^2.0.5",
"@web3-onboard/trust": "^2.0.4",
"antd": "5.24.1",
"antd-token-previewer": "^2.0.8",
"antd": "6.3.5",
"antd-token-previewer": "^3.0.0",
"buffer": "^6.0.3",
"cookie": "0.5.0",
"codemirror": "^6.0.1",
"cookie": "0.5.0",
"copy-to-clipboard": "^3.3.1",
"core-js": "^3.25.0",
"craco-less": "^2.0.0",
@@ -54,6 +54,7 @@
"react-highlight-words": "^0.18.0",
"react-i18next": "^11.8.7",
"react-metamask-avatar": "^1.2.1",
"reactflow": "^11.11.4",
"react-router-dom": "^5.3.3",
"react-scripts": "5.0.1",
"react-social-login-buttons": "^3.4.0",
@@ -106,6 +107,10 @@
"stylelint-config-recommended-less": "^1.0.4",
"stylelint-config-standard": "^28.0.0"
},
"resolutions": {
"@ant-design/cssinjs": "^2.1.2",
"rc-util": "^5.43.0"
},
"lint-staged": {
"src/**/*.{css,less}": [
"stylelint --fix"

View File

@@ -125,7 +125,7 @@ class AdapterListPage extends BaseListPage {
title: i18next.t("adapter:Use same DB"),
dataIndex: "useSameDb",
key: "useSameDb",
width: "120px",
width: "130px",
sorter: true,
render: (text, record, index) => {
return (
@@ -148,7 +148,7 @@ class AdapterListPage extends BaseListPage {
title: i18next.t("syncer:Database type"),
dataIndex: "databaseType",
key: "databaseType",
width: "120px",
width: "140px",
sorter: (a, b) => a.databaseType.localeCompare(b.databaseType),
},
{

View File

@@ -23,6 +23,7 @@ import {Alert, Button, ConfigProvider, Drawer, FloatButton, Layout, Result, Tool
import {Route, Switch, withRouter} from "react-router-dom";
import CustomGithubCorner from "./common/CustomGithubCorner";
import * as Conf from "./Conf";
import {shadcnThemeComponents, shadcnThemeToken} from "./shadcnTheme";
import * as Auth from "./auth/Auth";
import EntryPage from "./EntryPage";
@@ -177,7 +178,7 @@ class App extends Component {
"/applications", "/providers", "/resources", "/certs", "/keys", // Identity
"/roles", "/permissions", "/models", "/adapters", "/enforcers", // Authorization
"/agents", "/servers", "/server-store", "/entries", "/sites", "/rules", // LLM AI
"/sessions", "/records", "/tokens", "/verifications", // Logging & Auditing
"/sessions", "/records", "/tokens", "/verifications", // Auditing
"/products", "/orders", "/payments", "/plans", "/pricings", "/subscriptions", "/transactions", // Business
"/sysinfo", "/forms", "/syncers", "/webhooks", "/webhook-events", "/tickets", "/swagger", // Admin
];
@@ -614,9 +615,11 @@ class App extends Component {
locale={getAntdLocale(Setting.getLanguage())}
theme={{
token: {
...shadcnThemeToken,
colorPrimary: themeData.colorPrimary,
borderRadius: themeData.borderRadius,
},
components: shadcnThemeComponents,
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
}}>
<StyleProvider hashPriority="high" transformers={[legacyLogicalPropertiesTransformer]}>
@@ -756,10 +759,12 @@ class App extends Component {
locale={getAntdLocale(Setting.getLanguage())}
theme={{
token: {
...shadcnThemeToken,
colorPrimary: this.state.themeData.colorPrimary,
colorInfo: this.state.themeData.colorPrimary,
borderRadius: this.state.themeData.borderRadius,
},
components: shadcnThemeComponents,
algorithm: Setting.getAlgorithm(this.state.themeAlgorithm),
}}>
<StyleProvider hashPriority="high" transformers={[legacyLogicalPropertiesTransformer]}>

View File

@@ -49,7 +49,7 @@ img {
justify-content: center;
border-radius: 5px;
width: 45px;
height: 64px;
height: 52px;
float: right;
cursor: pointer;
@@ -58,14 +58,34 @@ img {
}
}
.saas-hosting-btn {
font-weight: bold;
background-color: rgba(87, 52, 211, 0.4);
padding: 0 8px;
display: flex;
align-items: center;
height: 40px;
border-radius: 5px;
transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
background-color: rgba(87, 52, 211, 0.65);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(87, 52, 211, 0.35);
}
&:active {
transform: translateY(0);
box-shadow: none;
}
}
.org-select {
display: flex;
position: relative;
transform: translateY(50%);
margin: 0 10px !important;
float: right;
min-width: 120px;
max-width: 180px;
min-width: 200px;
max-width: 360px;
}
.rightDropDown {
@@ -85,6 +105,8 @@ img {
box-shadow: 0 1px 5px 0 rgb(51 51 51 / 14%);
flex: 1;
align-items: stretch;
margin: 0 3px 3px 3px !important;
border-radius: 4px !important;
}
.side-image {
@@ -151,3 +173,12 @@ img {
.ant-menu-horizontal {
border-bottom: none !important;
}
.ant-layout-sider-children {
display: flex;
flex-direction: column;
}
.ant-menu-inline {
flex: 1;
}

View File

@@ -27,8 +27,8 @@ export let StaticBaseUrl = "https://cdn.casbin.org";
export const InitThemeAlgorithm = true;
export const ThemeDefault = {
themeType: "default",
colorPrimary: "#5734d3",
borderRadius: 6,
colorPrimary: "#262626",
borderRadius: 10,
isCompact: false,
};

View File

@@ -18,12 +18,22 @@ import {Button, Popover, Table} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as EntryBackend from "./backend/EntryBackend";
import * as ProviderBackend from "./backend/ProviderBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import Editor from "./common/Editor";
import EntryMessageViewer from "./EntryMessageViewer";
class EntryListPage extends BaseListPage {
constructor(props) {
super(props);
this.state = {
...this.state,
providerMap: {},
providerOwner: "",
};
}
newEntry() {
const randomHex = Math.random().toString(16).slice(2, 18);
const owner = Setting.getRequestOrganization(this.props.account);
@@ -77,31 +87,75 @@ class EntryListPage extends BaseListPage {
});
}
getProviders(owner) {
if (!owner) {
return Promise.resolve({});
}
if (this.state.providerOwner === owner) {
return Promise.resolve(this.state.providerMap);
}
return ProviderBackend.getProviders(owner)
.then((res) => {
if (res.status !== "ok") {
return {};
}
const providerMap = {};
(res.data || []).forEach((provider) => {
if (provider?.category === "Log" && provider?.name) {
providerMap[provider.name] = provider;
}
});
this.setState({
providerMap,
providerOwner: owner,
});
return providerMap;
})
.catch(() => {
this.setState({
providerMap: {},
providerOwner: "",
});
return {};
});
}
fetch = (params = {}) => {
const field = params.searchedColumn, value = params.searchText;
const sortField = params.sortField, sortOrder = params.sortOrder;
const owner = Setting.getRequestOrganization(this.props.account);
if (!params.pagination) {
params.pagination = {current: 1, pageSize: 10};
}
this.setState({loading: true});
EntryBackend.getEntries(Setting.getRequestOrganization(this.props.account), params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => {
this.setState({loading: false});
if (res.status === "ok") {
this.setState({
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to get")}: ${res.msg}`);
}
});
Promise.all([
EntryBackend.getEntries(owner, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder),
this.getProviders(owner),
]).then(([res]) => {
this.setState({loading: false});
if (res.status === "ok") {
this.setState({
data: res.data,
pagination: {
...params.pagination,
total: res.data2,
},
searchText: params.searchText,
searchedColumn: params.searchedColumn,
});
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to get")}: ${res.msg}`);
}
}).catch(error => {
this.setState({loading: false});
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
};
renderTable(entries) {
@@ -197,14 +251,26 @@ class EntryListPage extends BaseListPage {
key: "message",
sorter: true,
...this.getColumnSearchProps("message"),
render: (text) => {
render: (text, record) => {
if (!text) {
return null;
}
return (
<Popover placement="topRight" content={() => (
<Editor value={text} readOnly={true} />
)} title="" trigger="hover">
<Popover
placement="topRight"
content={(
<div style={{width: Setting.isMobile() ? Math.min(window.innerWidth - 40, 720) : 720}}>
<EntryMessageViewer
entry={record}
provider={this.state.providerMap[record.provider] ?? null}
labelSpan={24}
contentSpan={24}
/>
</div>
)}
title=""
trigger="hover"
>
{Setting.getShortText(text, 60)}
</Popover>
);

View File

@@ -17,6 +17,10 @@ import {Alert, Button, Col, Descriptions, Drawer, Row, Table} from "antd";
import * as Setting from "./Setting";
import i18next from "i18next";
import Editor from "./common/Editor";
import SELinuxEntryViewer from "./SELinuxEntryViewer";
import * as ProviderBackend from "./backend/ProviderBackend";
import OpenClawSessionGraphViewer from "./OpenClawSessionGraphViewer";
import {isOpenClawSessionEntry} from "./OpenClawSessionGraphUtils";
class EntryMessageViewer extends React.Component {
constructor(props) {
@@ -24,7 +28,80 @@ class EntryMessageViewer extends React.Component {
this.state = {
traceSpanDrawerVisible: false,
selectedTraceSpan: null,
provider: null,
};
this.pendingProviderRequestKey = "";
this.isUnmounted = false;
}
componentDidMount() {
this.isUnmounted = false;
this.getProvider();
}
componentDidUpdate(prevProps) {
if (
prevProps.entry?.provider !== this.props.entry?.provider
|| prevProps.entry?.owner !== this.props.entry?.owner
|| prevProps.provider !== this.props.provider
) {
this.getProvider();
}
}
componentWillUnmount() {
this.isUnmounted = true;
this.pendingProviderRequestKey = "";
}
getProvider() {
if (this.props.provider) {
this.pendingProviderRequestKey = "";
if (this.state.provider !== null) {
this.setState({provider: null});
}
return;
}
const providerName = this.props.entry?.provider;
const owner = this.props.entry?.owner;
if (!providerName || !owner) {
this.pendingProviderRequestKey = "";
if (this.state.provider !== null) {
this.setState({provider: null});
}
return;
}
const requestKey = `${owner}/${providerName}`;
this.pendingProviderRequestKey = requestKey;
ProviderBackend.getProvider(owner, providerName)
.then((res) => {
if (this.isUnmounted || this.pendingProviderRequestKey !== requestKey) {
return;
}
if (res.status === "ok") {
this.setState({
provider: res.data ?? null,
});
} else {
this.setState({
provider: null,
});
}
})
.catch(() => {
if (this.isUnmounted || this.pendingProviderRequestKey !== requestKey) {
return;
}
this.setState({
provider: null,
});
});
}
getEditorMaxWidth() {
@@ -383,15 +460,54 @@ class EntryMessageViewer extends React.Component {
}
renderMessageEditor() {
const message = this.formatJsonValue(this.props.entry?.message) || "";
const lang = this.shouldRenderTraceViewer() ? "json" : undefined;
return (
<Editor
value={this.formatJsonValue(this.props.entry?.message) || ""}
lang="json"
value={message}
lang={lang}
readOnly
/>
);
}
shouldRenderTraceViewer() {
return `${this.props.entry?.type ?? ""}`.trim().toLowerCase() === "trace";
}
getProviderViewerType() {
const provider = this.props.provider ?? this.state.provider;
if (!provider) {
return "";
}
const category = `${provider.category ?? ""}`.trim();
const type = `${provider.type ?? ""}`.trim();
if (category === "Log" && type === "SELinux Log") {
return "selinux";
}
return "";
}
renderSpecializedViewer() {
const provider = this.props.provider ?? this.state.provider;
switch (this.getProviderViewerType()) {
case "selinux":
return <SELinuxEntryViewer entry={this.props.entry} labelSpan={this.getLabelSpan()} contentSpan={this.getContentSpan()} />;
default:
if (this.shouldRenderTraceViewer()) {
return this.renderTraceSpans();
}
if (isOpenClawSessionEntry(this.props.entry, provider)) {
return <OpenClawSessionGraphViewer entry={this.props.entry} provider={provider} labelSpan={this.getLabelSpan()} contentSpan={this.getContentSpan()} />;
}
return null;
}
}
renderTraceSpans() {
if (this.props.entry?.type !== "trace") {
return null;
@@ -579,7 +695,7 @@ class EntryMessageViewer extends React.Component {
render() {
return (
<>
{this.renderTraceSpans()}
{this.renderSpecializedViewer()}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
{i18next.t("payment:Message")}:

View File

@@ -164,7 +164,7 @@ class InvitationListPage extends BaseListPage {
title: i18next.t("invitation:Used count"),
dataIndex: "usedCount",
key: "usedCount",
width: "130px",
width: "140px",
sorter: true,
...this.getColumnSearchProps("usedCount"),
},

View File

@@ -14,17 +14,19 @@
import * as Setting from "./Setting";
import {Avatar, Button, Card, Drawer, Dropdown, Menu, Result, Tooltip} from "antd";
import Sider from "antd/es/layout/Sider";
import EnableMfaNotification from "./common/notifaction/EnableMfaNotification";
import {Link, Redirect, Route, Switch, withRouter} from "react-router-dom";
import React, {useState} from "react";
import React, {useEffect, useState} from "react";
import i18next from "i18next";
import {
AppstoreTwoTone,
BarsOutlined, CheckCircleTwoTone, DeploymentUnitOutlined, DollarTwoTone, DownOutlined,
HomeTwoTone,
LockTwoTone, LogoutOutlined,
SafetyCertificateTwoTone, SettingOutlined, SettingTwoTone,
WalletTwoTone
AppstoreOutlined,
BarsOutlined, CheckCircleOutlined, DeploymentUnitOutlined, DollarOutlined, DownOutlined,
HomeOutlined,
LockOutlined, LogoutOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
SafetyCertificateOutlined, SettingOutlined,
WalletOutlined
} from "@ant-design/icons";
import Dashboard from "./basic/Dashboard";
import AppListPage from "./basic/AppListPage";
@@ -97,6 +99,7 @@ import ThemeSelect from "./common/select/ThemeSelect";
import OpenTour from "./common/OpenTour";
import OrganizationSelect from "./common/select/OrganizationSelect";
import AccountAvatar from "./account/AccountAvatar";
import BreadcrumbBar from "./common/BreadcrumbBar";
import {Content, Header} from "antd/es/layout/layout";
import * as AuthBackend from "./auth/AuthBackend";
import {clearWeb3AuthToken} from "./auth/Web3Auth";
@@ -119,11 +122,57 @@ import SiteEditPage from "./SiteEditPage";
import RuleListPage from "./RuleListPage";
import RuleEditPage from "./RuleEditPage";
function getMenuParentKey(uri) {
if (!uri) {return null;}
if (uri === "/" || uri.includes("/shortcuts") || uri.includes("/apps")) {return "/home";}
if (uri.includes("/organizations") || uri.includes("/trees") || uri.includes("/groups") || uri.includes("/users") || uri.includes("/invitations")) {return "/orgs";}
if (uri.includes("/applications") || uri.includes("/providers") || uri.includes("/resources") || uri.includes("/certs") || uri.includes("/keys")) {return "/identity";}
if (uri.includes("/agents") || uri.includes("/servers") || uri.includes("/entries") || uri.includes("/sites") || uri.includes("/rules")) {return "/gateway";}
if (uri.includes("/roles") || uri.includes("/permissions") || uri.includes("/models") || uri.includes("/adapters") || uri.includes("/enforcers")) {return "/auth";}
if (uri.includes("/records") || uri.includes("/tokens") || uri.includes("/sessions") || uri.includes("/verifications")) {return "/logs";}
if (uri.includes("/products") || uri.includes("/orders") || uri.includes("/payments") || uri.includes("/plans") || uri.includes("/pricings") || uri.includes("/subscriptions") || uri.includes("/transactions") || uri.includes("/cart")) {return "/business";}
if (uri.includes("/sysinfo") || uri.includes("/forms") || uri.includes("/syncers") || uri.includes("/webhooks") || uri.includes("/tickets")) {return "/admin";}
return null;
}
function ManagementPage(props) {
const [menuVisible, setMenuVisible] = useState(false);
const [siderCollapsed, setSiderCollapsed] = useState(() => localStorage.getItem("siderCollapsed") === "true");
const [menuOpenKeys, setMenuOpenKeys] = useState(() => {
const parentKey = getMenuParentKey(props.uri || location.pathname);
return parentKey ? [parentKey] : [];
});
useEffect(() => {
const parentKey = getMenuParentKey(props.uri);
if (parentKey) {
setMenuOpenKeys(prev =>
prev.includes(parentKey) ? prev : [...prev, parentKey]
);
}
}, [props.uri]);
const organization = props.account?.organization;
const navItems = Setting.isLocalAdminUser(props.account) ? organization?.navItems : (organization?.userNavItems ?? []);
const widgetItems = organization?.widgetItems;
const currentUri = props.uri || location.pathname;
const selectedLeafKey = "/" + (currentUri.split("/").filter(Boolean)[0] || "");
const isDark = props.themeAlgorithm.includes("dark");
const textColor = isDark ? "white" : "black";
const siderLogo = (() => {
if (!props.account?.organization) {return Setting.getLogo(props.themeAlgorithm);}
let logo = props.account.organization.logo || Setting.getLogo(props.themeAlgorithm);
if (isDark && props.account.organization.logoDark) {
logo = props.account.organization.logoDark;
}
return logo;
})();
const toggleSider = () => {
const next = !siderCollapsed;
setSiderCollapsed(next);
localStorage.setItem("siderCollapsed", String(next));
};
function logout() {
AuthBackend.logout()
@@ -150,13 +199,13 @@ function ManagementPage(props) {
function renderAvatar() {
if (props.account.avatar === "") {
return (
<Avatar style={{backgroundColor: Setting.getAvatarColor(props.account.name), verticalAlign: "middle"}} size="large">
<Avatar style={{backgroundColor: Setting.getAvatarColor(props.account.name), verticalAlign: "middle", marginLeft: 8}} size="large">
{Setting.getShortName(props.account.name)}
</Avatar>
);
} else {
return (
<Avatar src={props.account.avatar} style={{verticalAlign: "middle"}} size="large"
<Avatar src={props.account.avatar} style={{verticalAlign: "middle", marginLeft: 8}} size="large"
icon={<AccountAvatar src={props.account.avatar} style={{verticalAlign: "middle"}} size={40} />}
>
{Setting.getShortName(props.account.name)}
@@ -235,7 +284,7 @@ function ManagementPage(props) {
Setting.getItem(<ThemeSelect themeAlgorithm={props.themeAlgorithm} onChange={props.setLogoAndThemeAlgorithm} />, "theme"),
Setting.getItem(<LanguageSelect languages={props.account.organization.languages} />, "language"),
Setting.getItem(Conf.AiAssistantUrl?.trim() && (
<Tooltip title="Click to open AI assistant">
<Tooltip title={i18next.t("general:Click to open AI assistant")}>
<div className="select-box" onClick={props.openAiAssistant}>
<DeploymentUnitOutlined style={{fontSize: "24px"}} />
</div>
@@ -245,10 +294,10 @@ function ManagementPage(props) {
];
if (widgetItemsIsAll()) {
return widgets.map(item => item.label);
return widgets.reverse().map(item => item.label);
}
return widgets.filter(item => widgetItems.includes(item.key)).map(item => item.label);
return widgets.filter(item => widgetItems.includes(item.key)).reverse().map(item => item.label);
}
function renderAccountMenu() {
@@ -263,8 +312,13 @@ function ManagementPage(props) {
} else {
return (
<React.Fragment>
{renderRightDropdown()}
{renderWidgets()}
{Setting.isLocalAdminUser(props.account) && Conf.ShowGithubCorner && !Setting.isMobile() &&
<a href={"https://casdoor.com"} target="_blank" rel="noreferrer" style={{marginRight: "8px"}}>
<span className="saas-hosting-btn">
🚀 SaaS Hosting 🔥
</span>
</a>
}
{Setting.isAdminUser(props.account) && (props.uri.indexOf("/trees") === -1) &&
<OrganizationSelect
initValue={Setting.getOrganization()}
@@ -276,6 +330,8 @@ function ManagementPage(props) {
}}
/>
}
{renderWidgets()}
{renderRightDropdown()}
</React.Fragment>
);
}
@@ -288,51 +344,20 @@ function ManagementPage(props) {
return [];
}
let textColor = "black";
const twoToneColor = props.themeData.colorPrimary;
let logo = props.account.organization.logo ? props.account.organization.logo : Setting.getLogo(props.themeAlgorithm);
if (props.themeAlgorithm.includes("dark")) {
if (props.account.organization.logoDark) {
logo = props.account.organization.logoDark;
}
textColor = "white";
}
!Setting.isMobile() ? res.push({
label:
<Link to="/">
<img className="logo" src={logo ?? props.logo} alt="logo" />
</Link>,
disabled: true, key: "logo",
style: {
padding: 0,
height: "auto",
},
}) : null;
res.push(Setting.getItem(<Link style={{color: textColor}} to="/">{i18next.t("general:Home")}</Link>, "/home", <HomeTwoTone twoToneColor={twoToneColor} />, [
res.push(Setting.getItem(<Link style={{color: textColor}} to="/">{i18next.t("general:Home")}</Link>, "/home", <HomeOutlined />, [
Setting.getItem(<Link to="/">{i18next.t("general:Dashboard")}</Link>, "/"),
Setting.getItem(<Link to="/shortcuts">{i18next.t("general:Shortcuts")}</Link>, "/shortcuts"),
Setting.getItem(<Link to="/apps">{i18next.t("general:Apps")}</Link>, "/apps"),
]));
if (Setting.isLocalAdminUser(props.account) && Conf.ShowGithubCorner) {
res.push(Setting.getItem(<a href={"https://casdoor.com"}>
<span style={{fontWeight: "bold", backgroundColor: "rgba(87,52,211,0.4)", marginTop: "12px", paddingLeft: "5px", paddingRight: "5px", display: "flex", alignItems: "center", height: "40px", borderRadius: "5px"}}>
🚀 SaaS Hosting 🔥
</span>
</a>, "#"));
}
res.push(Setting.getItem(<Link style={{color: textColor}} to="/organizations">{i18next.t("general:User Management")}</Link>, "/orgs", <AppstoreTwoTone twoToneColor={twoToneColor} />, [
res.push(Setting.getItem(<Link style={{color: textColor}} to="/organizations">{i18next.t("general:User Management")}</Link>, "/orgs", <AppstoreOutlined />, [
Setting.getItem(<Link to="/organizations">{i18next.t("general:Organizations")}</Link>, "/organizations"),
Setting.getItem(<Link to="/groups">{i18next.t("general:Groups")}</Link>, "/groups"),
Setting.getItem(<Link to="/users">{i18next.t("general:Users")}</Link>, "/users"),
Setting.getItem(<Link to="/invitations">{i18next.t("general:Invitations")}</Link>, "/invitations"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/applications">{i18next.t("general:Identity")}</Link>, "/identity", <LockTwoTone twoToneColor={twoToneColor} />, [
res.push(Setting.getItem(<Link style={{color: textColor}} to="/applications">{i18next.t("general:Identity")}</Link>, "/identity", <LockOutlined />, [
Setting.getItem(<Link to="/applications">{i18next.t("general:Applications")}</Link>, "/applications"),
Setting.getItem(<Link to="/providers">{i18next.t("application:Providers")}</Link>, "/providers"),
Setting.getItem(<Link to="/resources">{i18next.t("general:Resources")}</Link>, "/resources"),
@@ -340,7 +365,7 @@ function ManagementPage(props) {
Setting.getItem(<Link to="/keys">{i18next.t("general:Keys")}</Link>, "/keys"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/roles">{i18next.t("general:Authorization")}</Link>, "/auth", <SafetyCertificateTwoTone twoToneColor={twoToneColor} />, [
res.push(Setting.getItem(<Link style={{color: textColor}} to="/roles">{i18next.t("general:Authorization")}</Link>, "/auth", <SafetyCertificateOutlined />, [
Setting.getItem(<Link to="/roles">{i18next.t("general:Roles")}</Link>, "/roles"),
Setting.getItem(<Link to="/permissions">{i18next.t("general:Permissions")}</Link>, "/permissions"),
Setting.getItem(<Link to="/models">{i18next.t("general:Models")}</Link>, "/models"),
@@ -354,7 +379,7 @@ function ManagementPage(props) {
}
})));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sites">{i18next.t("general:LLM AI")}</Link>, "/gateway", <CheckCircleTwoTone twoToneColor={twoToneColor} />, [
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sites">{i18next.t("general:LLM AI")}</Link>, "/gateway", <CheckCircleOutlined />, [
Setting.getItem(<Link to="/agents">{i18next.t("general:Agents")}</Link>, "/agents"),
Setting.getItem(<Link to="/servers">{i18next.t("general:MCP Servers")}</Link>, "/servers"),
Setting.getItem(<Link to="/server-store">{i18next.t("general:MCP Store")}</Link>, "/server-store"),
@@ -363,14 +388,14 @@ function ManagementPage(props) {
Setting.getItem(<Link to="/rules">{i18next.t("general:Rules")}</Link>, "/rules"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sessions">{i18next.t("general:Logging & Auditing")}</Link>, "/logs", <WalletTwoTone twoToneColor={twoToneColor} />, [
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sessions">{i18next.t("general:Auditing")}</Link>, "/logs", <WalletOutlined />, [
Setting.getItem(<Link to="/sessions">{i18next.t("general:Sessions")}</Link>, "/sessions"),
Setting.getItem(<Link to="/records">{i18next.t("general:Records")}</Link>, "/records"),
Setting.getItem(<Link to="/tokens">{i18next.t("general:Tokens")}</Link>, "/tokens"),
Setting.getItem(<Link to="/verifications">{i18next.t("general:Verifications")}</Link>, "/verifications"),
]));
res.push(Setting.getItem(<Link style={{color: textColor}} to="/products">{i18next.t("general:Business & Payments")}</Link>, "/business", <DollarTwoTone twoToneColor={twoToneColor} />, [
res.push(Setting.getItem(<Link style={{color: textColor}} to="/products">{i18next.t("general:Business")}</Link>, "/business", <DollarOutlined />, [
Setting.getItem(<Link to="/product-store">{i18next.t("general:Product Store")}</Link>, "/product-store"),
Setting.getItem(<Link to="/products">{i18next.t("general:Products")}</Link>, "/products"),
Setting.getItem(<Link to="/cart">{i18next.t("general:Cart")}</Link>, "/cart"),
@@ -383,7 +408,7 @@ function ManagementPage(props) {
]));
if (Setting.isAdminUser(props.account)) {
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sysinfo">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
res.push(Setting.getItem(<Link style={{color: textColor}} to="/sysinfo">{i18next.t("general:Admin")}</Link>, "/admin", <SettingOutlined />, [
Setting.getItem(<Link to="/sysinfo">{i18next.t("general:System Info")}</Link>, "/sysinfo"),
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
@@ -392,7 +417,7 @@ function ManagementPage(props) {
Setting.getItem(<Link to="/tickets">{i18next.t("general:Tickets")}</Link>, "/tickets"),
Setting.getItem(<a target="_blank" rel="noreferrer" href={Setting.isLocalhost() ? `${Setting.ServerUrl}/swagger` : "/swagger"}>{i18next.t("general:Swagger")}</a>, "/swagger")]));
} else {
res.push(Setting.getItem(<Link style={{color: textColor}} to="/syncers">{i18next.t("general:Admin")}</Link>, "/admin", <SettingTwoTone twoToneColor={twoToneColor} />, [
res.push(Setting.getItem(<Link style={{color: textColor}} to="/syncers">{i18next.t("general:Admin")}</Link>, "/admin", <SettingOutlined />, [
Setting.getItem(<Link to="/forms">{i18next.t("general:Forms")}</Link>, "/forms"),
Setting.getItem(<Link to="/syncers">{i18next.t("general:Syncers")}</Link>, "/syncers"),
Setting.getItem(<Link to="/webhooks">{i18next.t("general:Webhooks")}</Link>, "/webhooks"),
@@ -580,52 +605,113 @@ function ManagementPage(props) {
setMenuVisible(true);
};
const siderWidth = 256;
const siderCollapsedWidth = 80;
const showSider = !Setting.isMobile() && !props.requiredEnableMfa;
const contentMarginLeft = showSider ? (siderCollapsed ? siderCollapsedWidth : siderWidth) : 0;
return (
<React.Fragment>
<EnableMfaNotification account={props.account} />
<Header style={{display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0", marginBottom: "4px", backgroundColor: props.themeAlgorithm.includes("dark") ? "black" : "white"}} >
{
props.requiredEnableMfa || (Setting.isMobile() ? (
<React.Fragment>
<Drawer title={i18next.t("general:Close")} placement="left" open={menuVisible} onClose={onClose}>
<Menu
items={getMenuItems()}
mode={"inline"}
selectedKeys={[props.selectedMenuKey]}
style={{lineHeight: "64px"}}
onClick={onClose}
>
</Menu>
</Drawer>
<Button icon={<BarsOutlined />} onClick={showMenu} type="text">
{i18next.t("general:Menu")}
</Button>
</React.Fragment>
) : (
// Padding 1px for Menu Item Highlight border
<div style={{flex: 1, overflow: "hidden", paddingBottom: "1px"}}>
<Menu
onClick={onClose}
items={getMenuItems()}
mode={"horizontal"}
selectedKeys={[props.selectedMenuKey]}
style={{backgroundColor: props.themeAlgorithm.includes("dark") ? "black" : "white"}}
{showSider && (
<Sider
collapsed={siderCollapsed}
collapsedWidth={siderCollapsedWidth}
width={siderWidth}
trigger={null}
theme={isDark ? "dark" : "light"}
style={{
height: "100vh",
position: "fixed",
left: 0,
top: 0,
bottom: 0,
zIndex: 100,
boxShadow: "2px 0 8px rgba(0,0,0,0.08)",
display: "flex",
flexDirection: "column",
}}
>
<div style={{
height: 52,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: siderCollapsed ? "center" : "flex-start",
padding: siderCollapsed ? "0" : "0 16px 0 24px",
overflow: "hidden",
}}>
<Link to="/">
<img
src={siderCollapsed ? (organization?.favicon || siderLogo || props.logo) : (siderLogo ?? props.logo)}
alt="logo"
style={{
height: siderCollapsed ? 28 : 40,
width: siderCollapsed ? 28 : undefined,
maxWidth: siderCollapsed ? 28 : 160,
objectFit: "contain",
borderRadius: siderCollapsed ? 4 : 0,
transition: "max-width 0.2s, height 0.2s, width 0.2s",
}}
/>
</div>
))
}
<div style={{flexShrink: 0}}>
{renderAccountMenu()}
</div>
</Header>
<Content style={{display: "flex", flexDirection: "column"}} >
{isWithoutCard() ?
renderRouter() :
<Card className="content-warp-card">
{renderRouter()}
</Card>
}
</Content>
</Link>
</div>
<div className="sider-menu-container" style={{flex: 1, overflow: "auto"}}>
<Menu
mode="inline"
items={getMenuItems()}
selectedKeys={[selectedLeafKey]}
openKeys={menuOpenKeys}
onOpenChange={setMenuOpenKeys}
theme={isDark ? "dark" : "light"}
style={{borderRight: 0}}
/>
</div>
</Sider>
)}
<div style={{marginLeft: contentMarginLeft, transition: "margin-left 0.2s", display: "flex", flexDirection: "column", minHeight: "100vh"}}>
<Header style={{display: "flex", justifyContent: "space-between", alignItems: "center", padding: "0", marginBottom: "4px", backgroundColor: isDark ? "black" : "white", position: "sticky", top: 0, zIndex: 99, boxShadow: "0 1px 4px rgba(0,0,0,0.08)", height: "52px", lineHeight: "52px"}}>
<div style={{display: "flex", alignItems: "center"}}>
{props.requiredEnableMfa ? null : (Setting.isMobile() ? (
<React.Fragment>
<Drawer title={i18next.t("general:Close")} placement="left" open={menuVisible} onClose={onClose}>
<Menu
items={getMenuItems()}
mode={"inline"}
selectedKeys={[selectedLeafKey]}
openKeys={menuOpenKeys}
onOpenChange={setMenuOpenKeys}
style={{lineHeight: "48px"}}
onClick={onClose}
/>
</Drawer>
<Button icon={<BarsOutlined />} onClick={showMenu} type="text">
{i18next.t("general:Menu")}
</Button>
</React.Fragment>
) : (
<Button
icon={siderCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={toggleSider}
type="text"
style={{fontSize: 16, width: 40, height: 40}}
/>
))}
<BreadcrumbBar uri={currentUri} />
</div>
<div style={{flexShrink: 0, display: "flex", alignItems: "center"}}>
{renderAccountMenu()}
</div>
</Header>
<Content style={{display: "flex", flexDirection: "column"}}>
{isWithoutCard() ?
renderRouter() :
<Card className="content-warp-card" styles={{body: {padding: 0, margin: 0}}}>
{renderRouter()}
</Card>
}
</Content>
</div>
</React.Fragment>
);
}

View File

@@ -0,0 +1,351 @@
const openClawPayloadKinds = new Set(["task", "tool_call", "tool_result", "final"]);
export function isOpenClawSessionEntry(entry, provider) {
if (!entry || `${entry.type ?? ""}`.trim().toLowerCase() !== "session") {
return false;
}
if (provider?.category === "Log" && provider?.type === "Agent" && provider?.subType === "OpenClaw") {
return true;
}
if (provider) {
return false;
}
const payload = parseOpenClawBehaviorPayload(entry.message);
return Boolean(payload?.sessionId && payload?.entryId && payload?.kind);
}
function parseOpenClawBehaviorPayload(message) {
if (!message) {
return null;
}
const source = typeof message === "string" ? message : JSON.stringify(message);
if (!source) {
return null;
}
try {
const payload = JSON.parse(source);
const kind = `${payload?.kind ?? ""}`.trim();
const sessionId = `${payload?.sessionId ?? ""}`.trim();
const entryId = `${payload?.entryId ?? ""}`.trim();
if (!kind || !sessionId || !entryId || !openClawPayloadKinds.has(kind)) {
return null;
}
return payload;
} catch (e) {
return null;
}
}
export function getOpenClawNodeTarget(node) {
return node?.query || node?.url || node?.path || node?.tool || "";
}
export function getOpenClawNodeColor(node) {
switch (node?.kind) {
case "task":
return "#4c6ef5";
case "assistant_step":
return "#0f766e";
case "tool_call":
return "#f08c00";
case "tool_result":
return node?.ok === false ? "#e03131" : "#2f9e44";
case "final":
return "#6c5ce7";
default:
return "#868e96";
}
}
function normalizeText(value) {
return `${value ?? ""}`.replace(/\s+/g, " ").trim();
}
function stripLeadingPrefix(text, prefix) {
const normalizedText = normalizeText(text);
const normalizedPrefix = normalizeText(prefix);
if (!normalizedText || !normalizedPrefix) {
return normalizedText;
}
if (normalizedText.toLowerCase().startsWith(normalizedPrefix.toLowerCase())) {
return normalizedText.slice(normalizedPrefix.length).trim();
}
return normalizedText;
}
function getAssistantStepTitle(node) {
const summary = normalizeText(node?.summary);
const match = summary.match(/^(\d+\s+tool calls?)(?:\s*:\s*.+)?$/i);
if (match) {
return match[1];
}
return summary || node?.id || "-";
}
function getToolCallTitle(node) {
const target = normalizeText(getOpenClawNodeTarget(node));
if (target) {
return target;
}
const prefix = node?.tool ? `${node.tool}:` : "";
return stripLeadingPrefix(node?.summary, prefix) || normalizeText(node?.summary) || node?.id || "-";
}
function getToolResultTitle(node) {
const target = normalizeText(getOpenClawNodeTarget(node));
if (target) {
return target;
}
if (node?.ok === false && node?.error) {
return normalizeText(node.error);
}
const prefix = node?.tool ? `${node.tool} ${node.ok === false ? "failed" : "ok"}:` : "";
return stripLeadingPrefix(node?.summary, prefix) || normalizeText(node?.summary) || node?.id || "-";
}
function getNodeTitle(node) {
switch (node?.kind) {
case "assistant_step":
return getAssistantStepTitle(node);
case "tool_call":
return getToolCallTitle(node);
case "tool_result":
return getToolResultTitle(node);
default:
return normalizeText(node?.summary) || node?.id || "-";
}
}
function compareNodes(left, right) {
const leftTimestamp = `${left?.timestamp ?? ""}`.trim();
const rightTimestamp = `${right?.timestamp ?? ""}`.trim();
const leftMillis = parseTimestampMillis(leftTimestamp);
const rightMillis = parseTimestampMillis(rightTimestamp);
if (leftMillis !== null && rightMillis !== null) {
if (leftMillis !== rightMillis) {
return leftMillis - rightMillis;
}
} else if (leftTimestamp !== rightTimestamp) {
return leftTimestamp.localeCompare(rightTimestamp);
}
return `${left?.id ?? ""}`.localeCompare(`${right?.id ?? ""}`);
}
function parseTimestampMillis(timestamp) {
if (!timestamp) {
return null;
}
const milliseconds = Date.parse(timestamp);
if (Number.isNaN(milliseconds)) {
return null;
}
return milliseconds;
}
function buildTreeIndexes(graph) {
const sourceNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
const sourceEdges = Array.isArray(graph?.edges) ? graph.edges : [];
const nodeMap = Object.fromEntries(sourceNodes.map(node => [node.id, node]));
const childrenMap = new Map();
const incomingCount = new Map();
sourceNodes.forEach((node) => {
childrenMap.set(node.id, []);
incomingCount.set(node.id, 0);
});
sourceEdges.forEach((edge) => {
if (!nodeMap[edge.source] || !nodeMap[edge.target]) {
return;
}
if (!childrenMap.has(edge.source)) {
childrenMap.set(edge.source, []);
}
childrenMap.get(edge.source).push(edge.target);
incomingCount.set(edge.target, (incomingCount.get(edge.target) || 0) + 1);
});
childrenMap.forEach((childIds) => childIds.sort((left, right) => compareNodes(nodeMap[left], nodeMap[right])));
const roots = sourceNodes
.filter(node => !incomingCount.get(node.id))
.sort(compareNodes)
.map(node => node.id);
return {nodeMap, childrenMap, roots};
}
function computeTreeLayout(graph) {
const {nodeMap, childrenMap, roots} = buildTreeIndexes(graph);
const positions = new Map();
const visited = new Set();
const verticalGap = 160;
const horizontalGap = 320;
let cursor = 0;
function placeNode(nodeId, depth, stack) {
if (!nodeMap[nodeId]) {
return {top: cursor * verticalGap, bottom: cursor * verticalGap, center: cursor * verticalGap};
}
if (positions.has(nodeId)) {
const y = positions.get(nodeId).y;
return {top: y, bottom: y, center: y};
}
if (stack.has(nodeId)) {
const y = cursor * verticalGap;
cursor += 1;
positions.set(nodeId, {x: depth * horizontalGap, y});
visited.add(nodeId);
return {top: y, bottom: y, center: y};
}
stack.add(nodeId);
const childIds = (childrenMap.get(nodeId) || []).filter(childId => nodeMap[childId]);
if (childIds.length === 0) {
const y = cursor * verticalGap;
cursor += 1;
positions.set(nodeId, {x: depth * horizontalGap, y});
visited.add(nodeId);
stack.delete(nodeId);
return {top: y, bottom: y, center: y};
}
const childBoxes = childIds.map(childId => placeNode(childId, depth + 1, stack));
const top = childBoxes[0].top;
const bottom = childBoxes[childBoxes.length - 1].bottom;
const center = childBoxes.length === 1 ? childBoxes[0].center : (top + bottom) / 2;
positions.set(nodeId, {x: depth * horizontalGap, y: center});
visited.add(nodeId);
stack.delete(nodeId);
return {top, bottom, center};
}
roots.forEach(rootId => placeNode(rootId, 0, new Set()));
Object.values(nodeMap)
.filter(node => !visited.has(node.id))
.sort(compareNodes)
.forEach((node) => {
placeNode(node.id, 0, new Set());
});
return positions;
}
function getNodeSubtitle(node) {
switch (node?.kind) {
case "assistant_step": {
const summary = normalizeText(node?.summary);
const parts = summary.split(":");
const detail = parts.length > 1 ? parts.slice(1).join(":").trim() : "";
return detail || node?.timestamp || "-";
}
case "tool_call":
return normalizeText(node?.tool) || node?.timestamp || "-";
case "tool_result":
if (node?.ok === false) {
return normalizeText(node?.error) || `${normalizeText(node?.tool) || "tool"} failed`;
}
return `${normalizeText(node?.tool) || "tool"} ok`;
default:
return getOpenClawNodeTarget(node) || node?.timestamp || "-";
}
}
function getNodeBackground(node) {
switch (node?.kind) {
case "assistant_step":
return "#f0fdfa";
case "tool_call":
return "#fff7ed";
case "tool_result":
return node?.ok === false ? "#fff5f5" : "#f3faf4";
case "final":
return "#f5f3ff";
default:
return "#ffffff";
}
}
function getEdgeStyle(edge, nodeMap) {
const targetNode = nodeMap[edge.target];
if (targetNode?.kind === "tool_result" && targetNode?.ok === false) {
return {
stroke: "#e03131",
strokeWidth: 2.5,
};
}
if (targetNode?.originalParentId && targetNode.originalParentId !== targetNode.parentId) {
return {
stroke: "#0f766e",
strokeWidth: 2.5,
strokeDasharray: "6 4",
};
}
return {
stroke: "#94a3b8",
strokeWidth: 2,
};
}
export function buildOpenClawFlowElements(graph) {
const sourceNodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
const sourceEdges = Array.isArray(graph?.edges) ? graph.edges : [];
const nodeMap = Object.fromEntries(sourceNodes.map(node => [node.id, node]));
const positions = computeTreeLayout(graph);
const flowNodes = sourceNodes
.slice()
.sort(compareNodes)
.map((node) => {
const color = getOpenClawNodeColor(node);
const position = positions.get(node.id) || {x: 0, y: 0};
return {
id: node.id,
position,
data: {
title: getNodeTitle(node),
subtitle: getNodeSubtitle(node),
rawNode: node,
isAnchor: node.isAnchor,
},
draggable: false,
selectable: true,
style: {
width: 250,
minHeight: 76,
padding: "12px 14px",
borderRadius: 14,
border: node.isAnchor ? `3px solid ${color}` : `1px solid ${color}`,
boxShadow: node.isAnchor ? "0 8px 24px rgba(0, 0, 0, 0.12)" : "0 4px 14px rgba(0, 0, 0, 0.08)",
background: getNodeBackground(node),
color: "#1f2937",
},
};
});
const flowEdges = sourceEdges.map(edge => ({
id: `${edge.source}-${edge.target}`,
source: edge.source,
target: edge.target,
type: "smoothstep",
animated: false,
style: getEdgeStyle(edge, nodeMap),
}));
return {nodes: flowNodes, edges: flowEdges};
}

View File

@@ -0,0 +1,390 @@
import React from "react";
import {
Alert,
Col,
Descriptions,
Drawer,
Row,
Spin,
Tag,
Typography
} from "antd";
import i18next from "i18next";
import ReactFlow, {
Background,
Controls,
MiniMap,
ReactFlowProvider
} from "reactflow";
import "reactflow/dist/style.css";
import * as EntryBackend from "./backend/EntryBackend";
import * as Setting from "./Setting";
import {
buildOpenClawFlowElements,
getOpenClawNodeColor,
getOpenClawNodeTarget
} from "./OpenClawSessionGraphUtils";
const {Text} = Typography;
function OpenClawNodeLabel({title, subtitle}) {
return (
<div style={{display: "flex", flexDirection: "column", gap: "6px"}}>
<div style={{fontSize: 13, fontWeight: 600, lineHeight: 1.35}}>
{title || "-"}
</div>
<div style={{fontSize: 12, color: "#64748b", lineHeight: 1.35}}>
{subtitle || "-"}
</div>
</div>
);
}
function getStatusTag(node) {
if (
node?.kind !== "tool_result" ||
node?.ok === undefined ||
node?.ok === null
) {
return null;
}
return node.ok ? (
<Tag color="success">{i18next.t("general:OK")}</Tag>
) : (
<Tag color="error">{i18next.t("entry:Failed", {defaultValue: "Failed"})}</Tag>
);
}
function OpenClawSessionGraphCanvas(props) {
const {graph, onNodeSelect} = props;
const [reactFlowInstance, setReactFlowInstance] = React.useState(null);
const elements = React.useMemo(() => {
const flowElements = buildOpenClawFlowElements(graph);
return {
nodes: flowElements.nodes.map((node) => ({
...node,
data: {
...node.data,
label: (
<OpenClawNodeLabel
title={node.data.title}
subtitle={node.data.subtitle}
/>
),
},
})),
edges: flowElements.edges,
};
}, [graph]);
React.useEffect(() => {
if (!reactFlowInstance || elements.nodes.length === 0) {
return;
}
reactFlowInstance.fitView({padding: 0.2, duration: 0});
const anchorNode = elements.nodes.find((node) => node.data?.isAnchor);
if (!anchorNode) {
return;
}
window.setTimeout(() => {
reactFlowInstance.setCenter(
anchorNode.position.x + 125,
anchorNode.position.y + 38,
{zoom: 1.02, duration: 0}
);
}, 0);
}, [elements.nodes, reactFlowInstance]);
return (
<div
style={{
height: 460,
border: "1px solid #e5e7eb",
borderRadius: 16,
overflow: "hidden",
}}
>
<ReactFlow
nodes={elements.nodes}
edges={elements.edges}
fitView
nodesDraggable={false}
nodesConnectable={false}
onInit={setReactFlowInstance}
onNodeClick={(_, node) => onNodeSelect(node.data?.rawNode ?? null)}
>
<MiniMap
pannable
zoomable
nodeColor={(node) => getOpenClawNodeColor(node.data?.rawNode)}
/>
<Controls showInteractive={false} />
<Background color="#f1f5f9" gap={16} />
</ReactFlow>
</div>
);
}
class OpenClawSessionGraphViewer extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: false,
error: "",
graph: null,
selectedNode: null,
};
this.requestKey = "";
this.isUnmounted = false;
}
componentDidMount() {
this.isUnmounted = false;
this.loadGraph();
}
componentDidUpdate(prevProps) {
if (
prevProps.entry?.owner !== this.props.entry?.owner ||
prevProps.entry?.name !== this.props.entry?.name ||
prevProps.provider !== this.props.provider
) {
this.loadGraph();
}
}
componentWillUnmount() {
this.isUnmounted = true;
this.requestKey = "";
}
getLabelSpan() {
return this.props.labelSpan ?? (Setting.isMobile() ? 22 : 2);
}
getContentSpan() {
return this.props.contentSpan ?? 22;
}
loadGraph() {
if (!this.props.entry?.owner || !this.props.entry?.name) {
this.requestKey = "";
this.setState({
loading: false,
error: "",
graph: null,
selectedNode: null,
});
return;
}
const requestKey = `${this.props.entry.owner}/${this.props.entry.name}`;
this.requestKey = requestKey;
this.setState({loading: true, error: "", selectedNode: null});
EntryBackend.getOpenClawSessionGraph(
this.props.entry.owner,
this.props.entry.name
)
.then((res) => {
if (this.isUnmounted || this.requestKey !== requestKey) {
return;
}
if (res.status === "ok" && res.data) {
this.setState({
loading: false,
error: "",
graph: res.data,
});
} else if (res.status === "ok") {
this.setState({
loading: false,
error: "",
graph: null,
});
} else {
this.setState({
loading: false,
error: `${i18next.t("entry:Failed to load session graph", {defaultValue: "Failed to load session graph"})}: ${res.msg}`,
graph: null,
});
}
})
.catch((error) => {
if (this.isUnmounted || this.requestKey !== requestKey) {
return;
}
this.setState({
loading: false,
error: `${i18next.t("entry:Failed to load session graph", {defaultValue: "Failed to load session graph"})}: ${error}`,
graph: null,
});
});
}
renderStats() {
const stats = this.state.graph?.stats;
if (!stats) {
return null;
}
return (
<div
style={{display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 12}}
>
<Tag color="default">{i18next.t("entry:Nodes", {defaultValue: "Nodes"})}: {stats.totalNodes}</Tag>
<Tag color="blue">{i18next.t("entry:Tasks", {defaultValue: "Tasks"})}: {stats.taskCount}</Tag>
<Tag color="orange">{i18next.t("entry:Tool calls", {defaultValue: "Tool calls"})}: {stats.toolCallCount}</Tag>
<Tag color="green">{i18next.t("entry:Results", {defaultValue: "Results"})}: {stats.toolResultCount}</Tag>
<Tag color="purple">{i18next.t("entry:Finals", {defaultValue: "Finals"})}: {stats.finalCount}</Tag>
{stats.failedCount > 0 ? (
<Tag color="red">{i18next.t("entry:Failed", {defaultValue: "Failed"})}: {stats.failedCount}</Tag>
) : null}
</div>
);
}
renderNodeText(value) {
if (!value) {
return "-";
}
return (
<div style={{whiteSpace: "pre-wrap", wordBreak: "break-word"}}>
{value}
</div>
);
}
renderNodeDrawer() {
const node = this.state.selectedNode;
return (
<Drawer
title={node?.summary || i18next.t("entry:Session graph node", {defaultValue: "Session graph node"})}
width={Setting.isMobile() ? "100%" : 720}
placement="right"
onClose={() => this.setState({selectedNode: null})}
open={this.state.selectedNode !== null}
destroyOnClose
>
{node ? (
<Descriptions
bordered
size="small"
column={1}
layout={Setting.isMobile() ? "vertical" : "horizontal"}
style={{padding: "12px", height: "100%", overflowY: "auto"}}
>
<Descriptions.Item label={i18next.t("general:Type")}>
<div style={{display: "flex", alignItems: "center", gap: 8}}>
<Text>{node.kind || "-"}</Text>
{getStatusTag(node)}
</div>
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Summary", {defaultValue: "Summary"})}>
{node.summary || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:Timestamp")}>
{node.timestamp || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Entry ID", {defaultValue: "Entry ID"})}>
{node.entryId || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Tool Call ID", {defaultValue: "Tool Call ID"})}>
{node.toolCallId || "-"}
</Descriptions.Item>
<Descriptions.Item label={`${i18next.t("general:Parent")} ${i18next.t("general:ID")}`}>
{node.parentId || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Original Parent ID", {defaultValue: "Original Parent ID"})}>
{node.originalParentId || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Target", {defaultValue: "Target"})}>
{getOpenClawNodeTarget(node) || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Tool", {defaultValue: "Tool"})}>
{node.tool || "-"}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Query", {defaultValue: "Query"})}>
{this.renderNodeText(node.query)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:URL")}>
{this.renderNodeText(node.url)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Path", {defaultValue: "Path"})}>
{this.renderNodeText(node.path)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:Error")}>
{this.renderNodeText(node.error)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Text", {defaultValue: "Text"})}>
{this.renderNodeText(node.text)}
</Descriptions.Item>
</Descriptions>
) : null}
</Drawer>
);
}
renderContent() {
if (this.state.loading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
padding: "48px 0",
}}
>
<Spin />
</div>
);
}
if (this.state.error) {
return <Alert type="warning" showIcon message={this.state.error} />;
}
if (!this.state.graph) {
return null;
}
return (
<>
{this.renderStats()}
<ReactFlowProvider>
<OpenClawSessionGraphCanvas
graph={this.state.graph}
onNodeSelect={(selectedNode) => this.setState({selectedNode})}
/>
</ReactFlowProvider>
{this.renderNodeDrawer()}
</>
);
}
render() {
if (!this.state.loading && !this.state.error && !this.state.graph) {
return null;
}
return (
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
{i18next.t("entry:Session graph", {defaultValue: "Session Graph"})}:
</Col>
<Col span={this.getContentSpan()}>
<div data-testid="openclaw-session-graph">{this.renderContent()}</div>
</Col>
</Row>
);
}
}
export default OpenClawSessionGraphViewer;

View File

@@ -223,7 +223,7 @@ class OrganizationListPage extends BaseListPage {
title: i18next.t("general:Password type"),
dataIndex: "passwordType",
key: "passwordType",
width: "150px",
width: "160px",
sorter: true,
filterMultiple: false,
filters: [
@@ -267,7 +267,7 @@ class OrganizationListPage extends BaseListPage {
title: i18next.t("organization:User balance"),
dataIndex: "userBalance",
key: "userBalance",
width: "120px",
width: "130px",
sorter: true,
render: (text, record, index) => {
return text ?? 0;
@@ -277,7 +277,7 @@ class OrganizationListPage extends BaseListPage {
title: i18next.t("organization:Balance credit"),
dataIndex: "balanceCredit",
key: "balanceCredit",
width: "120px",
width: "130px",
sorter: true,
render: (text, record, index) => {
return text ?? 0;
@@ -287,7 +287,7 @@ class OrganizationListPage extends BaseListPage {
title: i18next.t("organization:Balance currency"),
dataIndex: "balanceCurrency",
key: "balanceCurrency",
width: "140px",
width: "160px",
sorter: true,
render: (text, record, index) => {
return text || "USD";

View File

@@ -415,7 +415,7 @@ class PermissionListPage extends BaseListPage {
dataIndex: "approveTime",
key: "approveTime",
filterMultiple: false,
width: "120px",
width: "130px",
sorter: true,
render: (text, record, index) => {
return Setting.getFormattedDate(text);

View File

@@ -341,7 +341,7 @@ class ProductStorePage extends React.Component {
render() {
return (
<div>
<div style={{padding: "16px"}}>
<FloatingCartButton
itemCount={this.state.cartItemCount}
onClick={() => this.props.history.push("/cart")}

View File

@@ -114,6 +114,17 @@ class ProviderEditPage extends React.Component {
}
getProvider() {
if (this.state.mode === "add" && this.props.location.provider) {
const provider = this.props.location.provider;
provider.userMapping = provider.userMapping || defaultUserMapping;
this.setState({
provider: provider,
nameNotUserEdited: isDefaultProviderName(provider.name),
displayNameNotUserEdited: isDefaultProviderDisplayName(provider.displayName),
});
return;
}
ProviderBackend.getProvider(this.state.owner, this.state.providerName)
.then((res) => {
if (res.data === null) {
@@ -770,6 +781,7 @@ class ProviderEditPage extends React.Component {
this.updateProviderField("host", "");
this.updateProviderField("port", 0);
this.updateProviderField("title", "");
this.updateProviderField("state", "Enabled");
}
if (defaultType) {
if (this.state.nameNotUserEdited) {
@@ -1079,13 +1091,18 @@ class ProviderEditPage extends React.Component {
submitProviderEdit(exitAfterSave) {
const provider = Setting.deepCopy(this.state.provider);
ProviderBackend.updateProvider(this.state.owner, this.state.providerName, provider)
const isAdd = this.state.mode === "add";
const apiCall = isAdd
? ProviderBackend.addProvider(provider)
: ProviderBackend.updateProvider(this.state.owner, this.state.providerName, provider);
apiCall
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully saved"));
this.setState({
owner: this.state.provider.owner,
providerName: this.state.provider.name,
mode: "edit",
});
if (exitAfterSave) {
@@ -1095,7 +1112,9 @@ class ProviderEditPage extends React.Component {
}
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to save")}: ${res.msg}`);
this.updateProviderField("name", this.state.providerName);
if (!isAdd) {
this.updateProviderField("name", this.state.providerName);
}
}
})
.catch(error => {
@@ -1104,17 +1123,7 @@ class ProviderEditPage extends React.Component {
}
deleteProvider() {
ProviderBackend.deleteProvider(this.state.provider)
.then((res) => {
if (res.status === "ok") {
this.props.history.push("/providers");
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to delete")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
this.props.history.push("/providers");
}
render() {

View File

@@ -57,18 +57,7 @@ class ProviderListPage extends BaseListPage {
addProvider() {
const newProvider = this.newProvider();
ProviderBackend.addProvider(newProvider)
.then((res) => {
if (res.status === "ok") {
this.props.history.push({pathname: `/providers/${newProvider.owner}/${newProvider.name}`, mode: "add"});
Setting.showMessage("success", i18next.t("general:Successfully added"));
} else {
Setting.showMessage("error", `${i18next.t("general:Failed to add")}: ${res.msg}`);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
this.props.history.push({pathname: `/providers/${newProvider.owner}/${newProvider.name}`, mode: "add", provider: newProvider});
}
deleteProvider(i) {

View File

@@ -72,7 +72,7 @@ class RecordListPage extends BaseListPage {
title: i18next.t("general:Organization"),
dataIndex: "organization",
key: "organization",
width: "110px",
width: "140px",
sorter: true,
...this.getColumnSearchProps("organization"),
render: (text, record, index) => {
@@ -102,7 +102,7 @@ class RecordListPage extends BaseListPage {
title: i18next.t("general:Method"),
dataIndex: "method",
key: "method",
width: "100px",
width: "110px",
sorter: true,
filterMultiple: false,
filters: [
@@ -129,7 +129,7 @@ class RecordListPage extends BaseListPage {
title: i18next.t("user:Language"),
dataIndex: "language",
key: "language",
width: "90px",
width: "120px",
sorter: true,
...this.getColumnSearchProps("language"),
},
@@ -204,13 +204,13 @@ class RecordListPage extends BaseListPage {
sorter: true,
fixed: "right",
render: (text, record, index) => (
<Button type="link" onClick={() => {
<Button onClick={() => {
this.setState({
detailRecord: record,
detailShow: true,
});
}}>
{i18next.t("general:Detail")}
{i18next.t("general:View")}
</Button>
),
},

View File

@@ -0,0 +1,164 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Col, Descriptions, Row, Tag} from "antd";
import * as Setting from "./Setting";
import i18next from "i18next";
class SELinuxEntryViewer extends React.Component {
getLabelSpan() {
return this.props.labelSpan ?? (Setting.isMobile() ? 22 : 2);
}
getContentSpan() {
return this.props.contentSpan ?? 22;
}
getMessage() {
return `${this.props.entry?.message ?? ""}`.trim();
}
getSeverityColor(severity) {
switch ((severity || "").toLowerCase()) {
case "warning":
return "orange";
case "error":
return "red";
case "info":
return "blue";
default:
return "default";
}
}
escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
extractValue(message, key) {
const escapedKey = this.escapeRegExp(key);
const quotedMatch = message.match(new RegExp(`(?:^|\\s)${escapedKey}="([^"]*)"`, "i"));
if (quotedMatch) {
return quotedMatch[1];
}
const plainMatch = message.match(new RegExp(`(?:^|\\s)${escapedKey}=([^\\s]+)`, "i"));
return plainMatch ? plainMatch[1] : "";
}
parseMessage() {
const message = this.getMessage();
const severityMatch = message.match(/^\[([^\]]+)\]\s*/);
const severity = severityMatch ? severityMatch[1] : "";
const body = severityMatch ? message.slice(severityMatch[0].length) : message;
const details = {
severity,
auditType: this.extractValue(body, "type"),
auditStamp: (body.match(/msg=audit\(([^)]+)\)/) || [])[1] || "",
decision: (body.match(/\bavc:\s+([a-z_]+)/i) || [])[1] || "",
permission: (body.match(/\{\s*([^}]+?)\s*\}/) || [])[1] || "",
pid: this.extractValue(body, "pid"),
command: this.extractValue(body, "comm"),
executable: this.extractValue(body, "exe"),
path: this.extractValue(body, "path"),
device: this.extractValue(body, "dev"),
inode: this.extractValue(body, "ino"),
sourceContext: this.extractValue(body, "scontext"),
targetContext: this.extractValue(body, "tcontext"),
targetClass: this.extractValue(body, "tclass"),
permissive: this.extractValue(body, "permissive"),
rawBody: body,
};
return details;
}
renderValue(value, render) {
if (!value) {
return "-";
}
return render ? render(value) : value;
}
render() {
const details = this.parseMessage();
return (
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={this.getLabelSpan()}>
{i18next.t("entry:SELinux event", {defaultValue: "SELinux event"})}:
</Col>
<Col span={this.getContentSpan()}>
<Descriptions
bordered
size="small"
column={Setting.isMobile() ? 1 : 2}
layout={Setting.isMobile() ? "vertical" : "horizontal"}
>
<Descriptions.Item label={i18next.t("general:Severity")}>
{this.renderValue(details.severity, value => <Tag color={this.getSeverityColor(value)}>{value}</Tag>)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:Type")}>
{this.renderValue(details.auditType)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Decision", {defaultValue: "Decision"})}>
{this.renderValue(details.decision)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Permission", {defaultValue: "Permission"})}>
{this.renderValue(details.permission)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Audit stamp", {defaultValue: "Audit stamp"})}>
{this.renderValue(details.auditStamp)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Permissive", {defaultValue: "Permissive"})}>
{this.renderValue(details.permissive)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Process ID", {defaultValue: "Process ID"})}>
{this.renderValue(details.pid)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Command", {defaultValue: "Command"})}>
{this.renderValue(details.command)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Executable", {defaultValue: "Executable"})}>
{this.renderValue(details.executable)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Target class", {defaultValue: "Target class"})}>
{this.renderValue(details.targetClass)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("general:Path")}>
{this.renderValue(details.path)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Device", {defaultValue: "Device"})}>
{this.renderValue(details.device)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Inode", {defaultValue: "Inode"})}>
{this.renderValue(details.inode)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Source context", {defaultValue: "Source context"})} span={Setting.isMobile() ? 1 : 2}>
{this.renderValue(details.sourceContext)}
</Descriptions.Item>
<Descriptions.Item label={i18next.t("entry:Target context", {defaultValue: "Target context"})} span={Setting.isMobile() ? 1 : 2}>
{this.renderValue(details.targetContext)}
</Descriptions.Item>
</Descriptions>
</Col>
</Row>
);
}
}
export default SELinuxEntryViewer;

View File

@@ -215,7 +215,7 @@ class ServerStorePage extends React.Component {
const filteredServers = this.getFilteredOnlineServers();
return (
<div>
<div style={{padding: "16px"}}>
<div style={{display: "flex", gap: "8px", marginBottom: "12px"}}>
<Input
allowClear

View File

@@ -140,6 +140,7 @@ class SessionListPage extends BaseListPage {
const paginationProps = {
total: this.state.pagination.total,
pageSize: this.state.pagination.pageSize,
showQuickJumper: true,
showSizeChanger: true,
showTotal: () => i18next.t("general:{total} in total").replace("{total}", this.state.pagination.total),
@@ -148,6 +149,11 @@ class SessionListPage extends BaseListPage {
return (
<div>
<Table scroll={{x: "max-content"}} columns={columns} dataSource={sessions} rowKey={(record) => `${record.owner}/${record.name}`} size="middle" bordered pagination={paginationProps}
title={() => (
<div>
{i18next.t("general:Sessions")}&nbsp;&nbsp;&nbsp;&nbsp;
</div>
)}
loading={this.state.loading}
onChange={this.handleTableChange}
/>

View File

@@ -48,6 +48,7 @@ export const Countries = [
{label: "Português", key: "pt", country: "PT", alt: "Português"},
{label: "Türkçe", key: "tr", country: "TR", alt: "Türkçe"},
{label: "Polski", key: "pl", country: "PL", alt: "Polski"},
{label: "Русский", key: "ru", country: "RU", alt: "Русский"},
{label: "Українська", key: "uk", country: "UA", alt: "Українська"},
];
@@ -1489,6 +1490,43 @@ function isSigninMethodEnabled(application, signinMethod) {
}
}
export const CaptchaRule = {
Always: "Always",
Never: "Never",
Dynamic: "Dynamic",
InternetOnly: "Internet-Only",
};
export function getCaptchaProviderItems(application) {
const providers = application?.providers;
if (!providers) {
return [];
}
return providers.filter(providerItem => providerItem?.provider?.category === "Captcha");
}
export function getCaptchaRule(application) {
const captchaProviderItems = getCaptchaProviderItems(application);
if (captchaProviderItems.some(providerItem => providerItem.rule === CaptchaRule.Always)) {
return CaptchaRule.Always;
} else if (captchaProviderItems.some(providerItem => providerItem.rule === CaptchaRule.Dynamic)) {
return CaptchaRule.Dynamic;
} else if (captchaProviderItems.some(providerItem => providerItem.rule === CaptchaRule.InternetOnly)) {
return CaptchaRule.InternetOnly;
}
return CaptchaRule.Never;
}
export function isInlineCaptchaEnabled(application) {
return application?.signinItems?.some(signinItem => signinItem.name === "Captcha" && signinItem.rule === "inline") || false;
}
export function isCaptchaEnabled(application) {
return getCaptchaRule(application) !== CaptchaRule.Never;
}
export function isPasswordEnabled(application) {
return isSigninMethodEnabled(application, "Password");
}

View File

@@ -197,7 +197,7 @@ class SiteListPage extends BaseListPage {
title: i18next.t("site:Other domains"),
dataIndex: "otherDomains",
key: "otherDomains",
width: "120px",
width: "140px",
sorter: (a, b) => a.otherDomains.localeCompare(b.otherDomains),
render: (text, record, index) => {
return record.otherDomains.map(domain => {

View File

@@ -162,7 +162,7 @@ class SyncerListPage extends BaseListPage {
title: i18next.t("syncer:Database type"),
dataIndex: "databaseType",
key: "databaseType",
width: "130px",
width: "140px",
sorter: (a, b) => a.databaseType.localeCompare(b.databaseType),
},
{
@@ -215,7 +215,7 @@ class SyncerListPage extends BaseListPage {
title: i18next.t("syncer:Sync interval"),
dataIndex: "syncInterval",
key: "syncInterval",
width: "140px",
width: "150px",
sorter: true,
...this.getColumnSearchProps("syncInterval"),
},

View File

@@ -109,7 +109,7 @@ class TokenListPage extends BaseListPage {
title: i18next.t("general:Application"),
dataIndex: "application",
key: "application",
width: "120px",
width: "130px",
sorter: true,
...this.getColumnSearchProps("application"),
render: (text, record, index) => {
@@ -124,7 +124,7 @@ class TokenListPage extends BaseListPage {
title: i18next.t("general:Organization"),
dataIndex: "organization",
key: "organization",
width: "120px",
width: "140px",
sorter: true,
...this.getColumnSearchProps("organization"),
render: (text, record, index) => {

View File

@@ -396,7 +396,7 @@ class UserListPage extends BaseListPage {
title: i18next.t("application:Real name"),
dataIndex: "realName",
key: "realName",
width: "120px",
width: "130px",
sorter: true,
...this.getColumnSearchProps("realName"),
},
@@ -464,7 +464,7 @@ class UserListPage extends BaseListPage {
title: i18next.t("user:Register source"),
dataIndex: "registerSource",
key: "registerSource",
width: "150px",
width: "160px",
sorter: true,
...this.getColumnSearchProps("registerSource"),
},
@@ -482,7 +482,7 @@ class UserListPage extends BaseListPage {
title: i18next.t("organization:Balance credit"),
dataIndex: "balanceCredit",
key: "balanceCredit",
width: "120px",
width: "130px",
sorter: true,
render: (text, record, index) => {
return text ?? 0;
@@ -492,7 +492,7 @@ class UserListPage extends BaseListPage {
title: i18next.t("organization:Balance currency"),
dataIndex: "balanceCurrency",
key: "balanceCurrency",
width: "140px",
width: "160px",
sorter: true,
render: (text, record, index) => {
return text || "USD";
@@ -514,7 +514,7 @@ class UserListPage extends BaseListPage {
title: i18next.t("user:Is forbidden"),
dataIndex: "isForbidden",
key: "isForbidden",
width: "110px",
width: "120px",
sorter: true,
render: (text, record, index) => {
return (
@@ -545,9 +545,9 @@ class UserListPage extends BaseListPage {
const disabled = (record.owner === this.props.account.owner && record.name === this.props.account.name) || (record.owner === "built-in" && record.name === "admin");
return (
<Space>
<Button size={isTreePage ? "small" : "middle"} type="primary" onClick={() => {
<Button size={isTreePage ? "small" : "middle"} onClick={() => {
this.impersonateUser(`${record.owner}/${record.name}`);
}}>{i18next.t("general:Impersonation")}
}}>{i18next.t("general:Impersonate")}
</Button>
<Button size={isTreePage ? "small" : "middle"} type="primary" onClick={() => {
sessionStorage.setItem("userListUrl", window.location.pathname);

View File

@@ -142,7 +142,7 @@ class VerificationListPage extends BaseListPage {
title: i18next.t("login:Verification code"),
dataIndex: "code",
key: "code",
width: "150px",
width: "160px",
sorter: true,
...this.getColumnSearchProps("code"),
},

View File

@@ -148,7 +148,7 @@ class WebhookListPage extends BaseListPage {
title: i18next.t("webhook:Content type"),
dataIndex: "contentType",
key: "contentType",
width: "140px",
width: "150px",
sorter: true,
filterMultiple: false,
filters: [
@@ -171,7 +171,7 @@ class WebhookListPage extends BaseListPage {
title: i18next.t("webhook:Is user extended"),
dataIndex: "isUserExtended",
key: "isUserExtended",
width: "140px",
width: "150px",
sorter: true,
render: (text, record, index) => {
return (

View File

@@ -32,7 +32,7 @@ import i18next from "i18next";
import CustomGithubCorner from "../common/CustomGithubCorner";
import {SendCodeInput} from "../common/SendCodeInput";
import LanguageSelect from "../common/select/LanguageSelect";
import {CaptchaModal, CaptchaRule} from "../common/modal/CaptchaModal";
import {CaptchaModal} from "../common/modal/CaptchaModal";
import RedirectForm from "../common/RedirectForm";
import {RequiredMfa} from "./mfa/MfaAuthVerifyForm";
import {GoogleOneTapLoginVirtualButton} from "./GoogleLoginButton";
@@ -89,10 +89,6 @@ class LoginPage extends React.Component {
this.captchaRef.current?.loadCaptcha?.();
}
isInlineCaptchaEnabled(application = this.getApplicationObj()) {
return application?.signinItems?.some(signinItem => signinItem.name === "Captcha" && signinItem.rule === "inline");
}
componentDidMount() {
if (this.getApplicationObj() === undefined) {
if (this.state.type === "login" || this.state.type === "saml") {
@@ -144,21 +140,6 @@ class LoginPage extends React.Component {
}
}
getCaptchaRule(application) {
const captchaProviderItems = this.getCaptchaProviderItems(application);
if (captchaProviderItems) {
if (captchaProviderItems.some(providerItem => providerItem.rule === "Always")) {
return CaptchaRule.Always;
} else if (captchaProviderItems.some(providerItem => providerItem.rule === "Dynamic")) {
return CaptchaRule.Dynamic;
} else if (captchaProviderItems.some(providerItem => providerItem.rule === "Internet-Only")) {
return CaptchaRule.InternetOnly;
} else {
return CaptchaRule.Never;
}
}
}
checkCaptchaStatus(values) {
AuthBackend.getCaptchaStatus(values)
.then((res) => {
@@ -459,20 +440,20 @@ class LoginPage extends React.Component {
} else {
values["password"] = passwordCipher;
}
const captchaRule = this.getCaptchaRule(this.getApplicationObj());
const captchaRule = Setting.getCaptchaRule(this.getApplicationObj());
const application = this.getApplicationObj();
const inlineCaptchaEnabled = this.isInlineCaptchaEnabled(application);
const inlineCaptchaEnabled = Setting.isInlineCaptchaEnabled(application);
if (!inlineCaptchaEnabled) {
if (captchaRule === CaptchaRule.Always) {
if (captchaRule === Setting.CaptchaRule.Always) {
this.setState({
openCaptchaModal: true,
values: values,
});
return;
} else if (captchaRule === CaptchaRule.Dynamic) {
} else if (captchaRule === Setting.CaptchaRule.Dynamic) {
this.checkCaptchaStatus(values);
return;
} else if (captchaRule === CaptchaRule.InternetOnly) {
} else if (captchaRule === Setting.CaptchaRule.InternetOnly) {
this.checkCaptchaStatus(values);
return;
}
@@ -489,7 +470,7 @@ class LoginPage extends React.Component {
// here we are supposed to determine whether Casdoor is working as an OAuth server or CAS server
values["language"] = this.state.userLang ?? "";
const usedCaptcha = this.state.captchaValues !== undefined;
const inlineCaptchaEnabled = this.isInlineCaptchaEnabled();
const inlineCaptchaEnabled = Setting.isInlineCaptchaEnabled(this.getApplicationObj());
const shouldRefreshCaptcha = usedCaptcha && inlineCaptchaEnabled && !this.state.loginMethod?.includes("verificationCode");
if (this.state.type === "cas") {
// CAS
@@ -945,7 +926,7 @@ class LoginPage extends React.Component {
{
application.providers.filter(providerItem => this.isProviderVisible(providerItem)).map((providerItem, id) => {
if (providerHint === providerItem.provider.name) {
goToLink(Provider.getAuthUrl(application, providerItem.provider, "signup"));
goToLink(Provider.getAuthUrl(application, providerItem.provider, this.state.mode ?? "signup"));
return;
}
return (
@@ -958,7 +939,7 @@ class LoginPage extends React.Component {
}
}}>
{
ProviderButton.renderProviderLogo(providerItem.provider, application, null, null, signinItem.rule, this.props.location)
ProviderButton.renderProviderLogo(providerItem.provider, application, null, null, signinItem.rule, this.props.location, this.state.mode ?? "signup")
}
</span>
);
@@ -1108,40 +1089,24 @@ class LoginPage extends React.Component {
}
}
getCaptchaProviderItems(application) {
const providers = application?.providers;
if (providers === undefined || providers === null) {
return null;
}
return providers.filter(providerItem => {
if (providerItem.provider === undefined || providerItem.provider === null) {
return false;
}
return providerItem.provider.category === "Captcha";
});
}
renderCaptchaModal(application, noModal) {
if (this.getCaptchaRule(this.getApplicationObj()) === CaptchaRule.Never) {
if (Setting.getCaptchaRule(this.getApplicationObj()) === Setting.CaptchaRule.Never) {
return null;
}
const captchaProviderItems = this.getCaptchaProviderItems(application);
const captchaProviderItems = Setting.getCaptchaProviderItems(application);
const alwaysProviderItems = captchaProviderItems.filter(providerItem => providerItem.rule === "Always");
const dynamicProviderItems = captchaProviderItems.filter(providerItem => providerItem.rule === "Dynamic");
const internetOnlyProviderItems = captchaProviderItems.filter(providerItem => providerItem.rule === "Internet-Only");
// Select provider based on the active captcha rule, not fixed priority
const captchaRule = this.getCaptchaRule(this.getApplicationObj());
const captchaRule = Setting.getCaptchaRule(this.getApplicationObj());
let provider = null;
if (captchaRule === CaptchaRule.Always && alwaysProviderItems.length > 0) {
if (captchaRule === Setting.CaptchaRule.Always && alwaysProviderItems.length > 0) {
provider = alwaysProviderItems[0].provider;
} else if (captchaRule === CaptchaRule.Dynamic && dynamicProviderItems.length > 0) {
} else if (captchaRule === Setting.CaptchaRule.Dynamic && dynamicProviderItems.length > 0) {
provider = dynamicProviderItems[0].provider;
} else if (captchaRule === CaptchaRule.InternetOnly && internetOnlyProviderItems.length > 0) {
} else if (captchaRule === Setting.CaptchaRule.InternetOnly && internetOnlyProviderItems.length > 0) {
provider = internetOnlyProviderItems[0].provider;
}
@@ -1368,10 +1333,10 @@ class LoginPage extends React.Component {
<SendCodeInput
disabled={this.state.username?.length === 0 || !this.state.validEmailOrPhone}
method={"login"}
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application)]}
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application), this.state.username]}
application={application}
captchaValue={this.state.captchaValues}
useInlineCaptcha={this.isInlineCaptchaEnabled(application)}
useInlineCaptcha={Setting.isInlineCaptchaEnabled(application)}
refreshCaptcha={this.refreshInlineCaptcha}
/>
</Form.Item>
@@ -1400,7 +1365,7 @@ class LoginPage extends React.Component {
onButtonClickArgs={[this.state.username, this.state.validEmail ? "email" : "phone", Setting.getApplicationName(application)]}
application={application}
captchaValue={this.state.captchaValues}
useInlineCaptcha={this.isInlineCaptchaEnabled(application)}
useInlineCaptcha={Setting.isInlineCaptchaEnabled(application)}
refreshCaptcha={this.refreshInlineCaptcha}
/>
</Form.Item>
@@ -1585,7 +1550,7 @@ class LoginPage extends React.Component {
const visibleOAuthProviderItems = (application.providers === null) ? [] : application.providers.filter(providerItem => this.isProviderVisible(providerItem) && providerItem.provider?.category !== "SAML");
if (this.props.preview !== "auto" && !Setting.isPasswordEnabled(application) && !Setting.isCodeSigninEnabled(application) && !Setting.isWebAuthnEnabled(application) && !Setting.isLdapEnabled(application) && visibleOAuthProviderItems.length === 1) {
Setting.goToLink(Provider.getAuthUrl(application, visibleOAuthProviderItems[0].provider, "signup"));
Setting.goToLink(Provider.getAuthUrl(application, visibleOAuthProviderItems[0].provider, this.state.mode ?? "signup"));
return (
<div style={{display: "flex", justifyContent: "center", alignItems: "center", width: "100%"}}>
<Spin size="large" tip={i18next.t("login:Signing in...")} />

View File

@@ -143,20 +143,20 @@ export function goToWeb3Url(application, provider, method) {
}
}
export function renderProviderLogo(provider, application, width, margin, size, location) {
export function renderProviderLogo(provider, application, width, margin, size, location, method = "signup") {
if (size === "small") {
if (provider.category === "OAuth") {
if (provider.type === "WeChat" && provider.clientId2 !== "" && provider.clientSecret2 !== "" && provider.disableSsl === true && !navigator.userAgent.includes("MicroMessenger")) {
return (
<a key={provider.displayName} >
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={{margin: margin}} onClick={() => {
WechatOfficialAccountModal(application, provider, "signup");
WechatOfficialAccountModal(application, provider, method);
}} />
</a>
);
} else {
return (
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, "signup")}>
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, method)}>
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={{margin: margin}} />
</a>
);
@@ -169,7 +169,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
);
} else if (provider.category === "Web3") {
return (
<a key={provider.displayName} onClick={() => goToWeb3Url(application, provider, "signup")}>
<a key={provider.displayName} onClick={() => goToWeb3Url(application, provider, method)}>
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={{margin: margin}} />
</a>
);
@@ -183,7 +183,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
const customSpanStyle = {textAlign: "center", width: "100%", fontSize: "19px"};
if (provider.category === "OAuth") {
return (
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, "signup")} style={customAStyle}>
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, method)} style={customAStyle}>
<div style={customButtonStyle}>
<img width={26} src={getProviderLogoURL(provider)} alt={provider.displayName} className="provider-img" style={customImgStyle} />
<span style={customSpanStyle}>{text}</span>
@@ -215,7 +215,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
} else if (provider.category === "Web3") {
return (
<div key={provider.displayName} className="provider-big-img">
<a onClick={() => goToWeb3Url(application, provider, "signup")}>
<a onClick={() => goToWeb3Url(application, provider, method)}>
{
getSigninButton(provider)
}
@@ -225,7 +225,7 @@ export function renderProviderLogo(provider, application, width, margin, size, l
} else {
return (
<div key={provider.displayName} className="provider-big-img">
<a href={Provider.getAuthUrl(application, provider, "signup")}>
<a href={Provider.getAuthUrl(application, provider, method)}>
{
getSigninButton(provider)
}

View File

@@ -106,7 +106,7 @@ export const MfaVerifySmsForm = ({mfaProps, application, onFinish, method, user}
<SendCodeInput
countryCode={form.getFieldValue("countryCode")}
method={method}
onButtonClickArgs={[mfaProps.secret || dest, isEmail() ? "email" : "phone", Setting.getApplicationName(application)]}
onButtonClickArgs={[mfaProps.secret || dest, isEmail() ? "email" : "phone", Setting.getApplicationName(application), user?.name]}
application={application}
/>
</Form.Item>

View File

@@ -28,6 +28,13 @@ export function getEntry(owner, name) {
}).then(res => res.json());
}
export function getOpenClawSessionGraph(owner, name) {
return fetch(`${Setting.ServerUrl}/api/get-openclaw-session-graph?id=${owner}/${encodeURIComponent(name)}`, {
method: "GET",
credentials: "include",
}).then(res => res.json());
}
export function updateEntry(owner, name, entry) {
const newEntry = Setting.deepCopy(entry);
return fetch(`${Setting.ServerUrl}/api/update-entry?id=${owner}/${encodeURIComponent(name)}`, {

View File

@@ -122,8 +122,8 @@ const Dashboard = (props) => {
}
}
return (
<div style={{display: "flex", justifyContent: "center", alignItems: "center"}}>
<Spin size="large" tip={i18next.t("login:Loading")} style={{paddingTop: "10%"}} />
<div style={{display: "flex", justifyContent: "center", alignItems: "center", height: "calc(100vh - 120px)"}}>
<Spin size="large" tip={i18next.t("login:Loading")} />
</div>
);
}

View File

@@ -20,7 +20,7 @@ const ShortcutsPage = () => {
};
return (
<div style={{display: "flex", justifyContent: "center", flexDirection: "column", alignItems: "center"}}>
<div style={{display: "flex", justifyContent: "center", flexDirection: "column", alignItems: "center", padding: "16px"}}>
<GridCards items={getItems()} />
</div>
);

View File

@@ -0,0 +1,109 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Breadcrumb} from "antd";
import {Link} from "react-router-dom";
import i18next from "i18next";
const RESOURCE_LABELS = {
"apps": "general:Apps",
"shortcuts": "general:Shortcuts",
"account": "account:My Account",
"organizations": "general:Organizations",
"users": "general:Users",
"groups": "general:Groups",
"trees": "general:Groups",
"invitations": "general:Invitations",
"applications": "general:Applications",
"providers": "application:Providers",
"resources": "general:Resources",
"certs": "general:Certs",
"keys": "general:Keys",
"agents": "general:Agents",
"servers": "general:MCP Servers",
"server-store": "general:MCP Store",
"entries": "general:Entries",
"sites": "general:Sites",
"rules": "general:Rules",
"roles": "general:Roles",
"permissions": "general:Permissions",
"models": "general:Models",
"adapters": "general:Adapters",
"enforcers": "general:Enforcers",
"sessions": "general:Sessions",
"records": "general:Records",
"tokens": "general:Tokens",
"verifications": "general:Verifications",
"product-store": "general:Product Store",
"products": "general:Products",
"cart": "general:Cart",
"orders": "general:Orders",
"payments": "general:Payments",
"plans": "general:Plans",
"pricings": "general:Pricings",
"subscriptions": "general:Subscriptions",
"transactions": "general:Transactions",
"sysinfo": "general:System Info",
"forms": "general:Forms",
"syncers": "general:Syncers",
"webhooks": "general:Webhooks",
"webhook-events": "general:Webhook Events",
"tickets": "general:Tickets",
"ldap": "general:LDAP",
"mfa": "general:MFA",
};
function buildBreadcrumbItems(uri) {
const pathSegments = (uri || "").split("/").filter(Boolean);
const homeItem = {title: <Link to="/">{i18next.t("general:Home")}</Link>};
if (pathSegments.length === 0) {
return null;
}
const rootSegment = pathSegments[0];
const listLabelKey = RESOURCE_LABELS[rootSegment];
if (!listLabelKey) {
return null;
}
if (pathSegments.length === 1) {
return [
homeItem,
{title: i18next.t(listLabelKey)},
];
}
const lastSegment = pathSegments[pathSegments.length - 1];
const lastLabelKey = RESOURCE_LABELS[lastSegment];
const lastLabel = lastLabelKey ? i18next.t(lastLabelKey) : lastSegment;
return [
homeItem,
{title: <Link to={`/${rootSegment}`}>{i18next.t(listLabelKey)}</Link>},
{title: lastLabel},
];
}
const BreadcrumbBar = ({uri}) => {
const items = buildBreadcrumbItems(uri);
if (!items) {
return null;
}
return <Breadcrumb items={items} style={{marginLeft: 8}} />;
};
export default BreadcrumbBar;

View File

@@ -61,7 +61,7 @@ export const NavItemTree = ({disabled, checkedKeys, defaultExpandedKeys, onCheck
],
},
{
title: i18next.t("general:Logging & Auditing"),
title: i18next.t("general:Auditing"),
key: "/sessions-top",
children: [
{title: i18next.t("general:Sessions"), key: "/sessions"},
@@ -71,7 +71,7 @@ export const NavItemTree = ({disabled, checkedKeys, defaultExpandedKeys, onCheck
],
},
{
title: i18next.t("general:Business & Payments"),
title: i18next.t("general:Business"),
key: "/business-top",
children: [
{title: i18next.t("general:Products"), key: "/products"},

View File

@@ -15,6 +15,7 @@
import React from "react";
import {Tooltip} from "antd";
import {QuestionCircleOutlined} from "@ant-design/icons";
import i18next from "i18next";
import * as TourConfig from "../TourConfig";
import * as Setting from "../Setting";
@@ -34,7 +35,7 @@ class OpenTour extends React.Component {
render() {
return (
this.canTour() ?
<Tooltip title="Click to open tour">
<Tooltip title={i18next.t("general:Click to open tour")}>
<div className="select-box" style={{display: Setting.isMobile() ? "none" : null, ...this.props.style}} onClick={() => TourConfig.setIsTourVisible(true)} >
<QuestionCircleOutlined style={{fontSize: "24px"}} />
</div>

View File

@@ -16,6 +16,7 @@ import {Button, Input} from "antd";
import React from "react";
import i18next from "i18next";
import * as UserBackend from "../backend/UserBackend";
import * as AuthBackend from "../auth/AuthBackend";
import * as Setting from "../Setting";
import {SafetyOutlined} from "@ant-design/icons";
import {CaptchaModal} from "./modal/CaptchaModal";
@@ -71,17 +72,66 @@ export const SendCodeInput = ({value, disabled, captchaValue, useInlineCaptcha,
};
const handleSearch = () => {
if (!useInlineCaptcha) {
setVisible(true);
const sendCodeWithoutCaptcha = () => {
handleOk("none", "", "");
};
const sendCodeWithCaptcha = () => {
if (!useInlineCaptcha) {
setVisible(true);
return;
}
// client secret is validated in backend
if (!captchaValue?.captchaType || !captchaValue?.captchaToken) {
Setting.showMessage("error", i18next.t("general:Please complete the captcha correctly"));
return;
}
handleOk(captchaValue.captchaType, captchaValue.captchaToken, captchaValue.clientSecret);
};
const checkCaptchaStatusAndSend = () => {
if (!onButtonClickArgs?.[3]) {
return;
}
const values = {
organization: application?.organization,
username: onButtonClickArgs?.[3],
application: application?.name,
};
AuthBackend.getCaptchaStatus(values)
.then((res) => {
if (res.status === "ok" && res.data) {
sendCodeWithCaptcha();
return;
}
sendCodeWithoutCaptcha();
})
.catch(() => {
sendCodeWithoutCaptcha();
});
};
const captchaRule = Setting.getCaptchaRule(application);
if (captchaRule === Setting.CaptchaRule.Never) {
sendCodeWithoutCaptcha();
return;
}
// client secret is validated in backend
if (!captchaValue?.captchaType || !captchaValue?.captchaToken) {
Setting.showMessage("error", i18next.t("general:Please complete the captcha correctly"));
if (captchaRule === Setting.CaptchaRule.Always) {
sendCodeWithCaptcha();
return;
}
handleOk(captchaValue.captchaType, captchaValue.captchaToken, captchaValue.clientSecret);
if (captchaRule === Setting.CaptchaRule.Dynamic || captchaRule === Setting.CaptchaRule.InternetOnly) {
checkCaptchaStatusAndSend();
return;
}
sendCodeWithoutCaptcha();
};
return (

View File

@@ -1,6 +1,18 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
html {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
html::-webkit-scrollbar {
display: none; /* Chrome/Safari/WebKit */
}
body {
margin: 0;
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
@@ -32,13 +44,19 @@ code {
float: left;
}
.ant-table.ant-table-middle .ant-table-title,
.ant-table.ant-table-middle .ant-table-footer,
.ant-table.ant-table-middle thead > tr > th,
.ant-table.ant-table-middle tbody > tr > td {
.ant-table.ant-table-medium .ant-table-title {
padding: 5px 8px !important;
}
.ant-table.ant-table-medium .ant-table-footer,
.ant-table.ant-table-medium .ant-table-cell {
padding: 1px 8px !important;
}
.ant-table.ant-table-medium .ant-table-thead .ant-table-cell {
padding: 10px 8px !important;
}
.ant-list-sm .ant-list-item {
padding: 2px !important;
}
@@ -67,3 +85,83 @@ code {
.no-horizontal-scroll-editor [class*="CodeMirror-scroll"] {
overflow-x: hidden !important;
}
/* Bold overrides for key UI elements */
/* Sidebar menu: item labels */
.ant-menu .ant-menu-item,
.ant-menu .ant-menu-item a,
.ant-menu .ant-menu-submenu-title {
font-weight: 600 !important;
}
/* Sidebar menu: group titles */
.ant-menu .ant-menu-item-group-title {
font-weight: 700 !important;
letter-spacing: 0.04em;
}
/* Table: column headers (thead) */
.ant-table .ant-table-thead > tr > th,
.ant-table .ant-table-thead > tr > td {
font-weight: 600 !important;
}
/* Table: footer */
.ant-table .ant-table-footer {
font-weight: 600 !important;
}
/* Table: title bar */
.ant-table .ant-table-title {
font-weight: 700 !important;
}
/* Card: header title */
.ant-card .ant-card-head-title {
font-weight: 600 !important;
}
/* Descriptions: item labels */
.ant-descriptions .ant-descriptions-item-label {
font-weight: 600 !important;
}
/* Tabs: tab button labels */
.ant-tabs .ant-tabs-tab .ant-tabs-tab-btn {
font-weight: 600 !important;
}
/* Layout sider: menu item and submenu labels */
.ant-layout-sider .ant-menu-item,
.ant-layout-sider .ant-menu-submenu-title {
font-weight: 600 !important;
}
/* Table loading spinner - give the overlay enough height so spinner appears centered (antd v6) */
.ant-table-wrapper .ant-spin.ant-spin-spinning {
min-height: 420px;
}
/* Sidebar menu: selected item - darker background */
.ant-layout-sider .ant-menu-light .ant-menu-item-selected,
.ant-layout-sider .ant-menu-light>.ant-menu .ant-menu-item-selected {
background-color: rgba(0, 0, 0, 0.15) !important;
}
/* Sidebar menu container: hide scrollbar but keep scroll behavior */
.sider-menu-container {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.sider-menu-container::-webkit-scrollbar {
display: none; /* Chrome/Safari/WebKit */
}
/* Message popups: static API renders outside ConfigProvider, override font explicitly */
.ant-message,
.ant-message .ant-message-notice-content,
.ant-message .ant-message-custom-content,
.ant-message .ant-message-custom-content span {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif !important;
}

1515
web/src/locales/ru/data.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -284,8 +284,8 @@
"Cancel": "取消",
"Captcha": "人机验证码",
"Cart": "购物车",
"Category": "Category",
"Category - Tooltip": "Category - Tooltip",
"Category": "类别",
"Category - Tooltip": "所属的类别",
"Cert": "证书",
"Cert - Tooltip": "该应用所对应的客户端SDK需要验证的公钥证书",
"Certs": "证书",
@@ -347,14 +347,14 @@
"Failed to upload": "上传失败",
"Failed to verify": "验证失败",
"False": "假",
"Favicon": "组织Favicon",
"Favicon - Tooltip": "该组织所有Casdoor页面中所使用的Favicon图标URL",
"Favicon": "图标",
"Favicon - Tooltip": "该组织所有Casdoor页面中所使用的Favicon图标链接",
"Filter": "筛选",
"First name": "名字",
"First name - Tooltip": "用户的名字",
"Forced redirect origin - Tooltip": "强制重定向到指定的来源",
"Forget URL": "忘记密码URL",
"Forget URL - Tooltip": "自定义忘记密码页面的URL不设置时采用Casdoor默认的忘记密码页面设置后Casdoor各类页面的忘记密码链接会跳转到该URL",
"Forget URL": "忘记密码链接",
"Forget URL - Tooltip": "自定义忘记密码页面的链接不设置时采用Casdoor默认的忘记密码页面设置后Casdoor各类页面的忘记密码链接会跳转到该URL",
"Forms": "表单",
"Found some texts still not translated? Please help us translate at": "发现有些文字尚未翻译?请移步这里帮我们翻译:",
"Generate": "生成",

View File

@@ -13,24 +13,62 @@
// limitations under the License.
import React from "react";
import {Col, Input, Row} from "antd";
import {Col, Input, Row, Select} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
const {Option} = Select;
export function renderLogProviderFields(provider, updateProviderField) {
if (provider.type === "Agent" && provider.subType === "OpenClaw") {
return (
return (
<React.Fragment>
{provider.type === "Agent" && provider.subType === "OpenClaw" ? (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.host} onChange={e => {
updateProviderField("host", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Agent ID"), i18next.t("provider:Agent ID - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.title} onChange={e => {
updateProviderField("title", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Path")} :
</Col>
<Col span={22} >
<Input value={provider.endpoint} onChange={e => {
updateProviderField("endpoint", e.target.value);
}} />
</Col>
</Row>
</React.Fragment>
) : null}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
{Setting.getLabel(i18next.t("general:State"), i18next.t("general:State - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.host} placeholder="e.g. 192.168.1.100" onChange={e => {
updateProviderField("host", e.target.value);
}} />
<Select virtual={false} style={{width: "100%"}} value={provider.state || "Enabled"} onChange={value => {
updateProviderField("state", value);
}}>
<Option value="Enabled">{i18next.t("general:Enabled")}</Option>
<Option value="Disabled">{i18next.t("general:Disabled")}</Option>
</Select>
</Col>
</Row>
);
}
return null;
</React.Fragment>
);
}

View File

@@ -164,6 +164,16 @@ export function renderOAuthProviderFields(provider, updateProviderField, renderU
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Logout URL"), i18next.t("provider:Logout URL - Tooltip"))}
</Col>
<Col span={22} >
<Input value={provider.customLogoutUrl} onChange={e => {
updateProviderField("customLogoutUrl", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Enable PKCE"), i18next.t("provider:Enable PKCE - Tooltip"))} :

168
web/src/shadcnTheme.js Normal file
View File

@@ -0,0 +1,168 @@
// Copyright 2021 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Shadcn-style Ant Design theme configuration.
// Adapted from the "shadcn" preset on https://ant.design/
export const shadcnThemeToken = {
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
colorPrimary: "#262626",
colorSuccess: "#22c55e",
colorWarning: "#f97316",
colorError: "#ef4444",
colorInfo: "#262626",
colorTextBase: "#262626",
colorBgBase: "#ffffff",
colorPrimaryBg: "#f5f5f5",
colorPrimaryBgHover: "#e5e5e5",
colorPrimaryBorder: "#d4d4d4",
colorPrimaryBorderHover: "#a3a3a3",
colorPrimaryHover: "#404040",
colorPrimaryActive: "#171717",
colorPrimaryText: "#262626",
colorPrimaryTextHover: "#404040",
colorPrimaryTextActive: "#171717",
colorSuccessBg: "#f0fdf4",
colorSuccessBgHover: "#dcfce7",
colorSuccessBorder: "#bbf7d0",
colorSuccessBorderHover: "#86efac",
colorSuccessHover: "#16a34a",
colorSuccessActive: "#15803d",
colorSuccessText: "#16a34a",
colorSuccessTextHover: "#16a34a",
colorSuccessTextActive: "#15803d",
colorWarningBg: "#fff7ed",
colorWarningBgHover: "#fed7aa",
colorWarningBorder: "#fdba74",
colorWarningBorderHover: "#fb923c",
colorWarningHover: "#ea580c",
colorWarningActive: "#c2410c",
colorWarningText: "#ea580c",
colorWarningTextHover: "#ea580c",
colorWarningTextActive: "#c2410c",
colorErrorBg: "#fef2f2",
colorErrorBgHover: "#fecaca",
colorErrorBorder: "#fca5a5",
colorErrorBorderHover: "#f87171",
colorErrorHover: "#dc2626",
colorErrorActive: "#b91c1c",
colorErrorText: "#dc2626",
colorErrorTextHover: "#dc2626",
colorErrorTextActive: "#b91c1c",
colorInfoBg: "#f5f5f5",
colorInfoBgHover: "#e5e5e5",
colorInfoBorder: "#d4d4d4",
colorInfoBorderHover: "#a3a3a3",
colorInfoHover: "#404040",
colorInfoActive: "#171717",
colorInfoText: "#262626",
colorInfoTextHover: "#404040",
colorInfoTextActive: "#171717",
colorText: "#262626",
colorTextSecondary: "#525252",
colorTextTertiary: "#737373",
colorTextQuaternary: "#a3a3a3",
colorTextDisabled: "#a3a3a3",
colorBgContainer: "#ffffff",
colorBgElevated: "#ffffff",
colorBgLayout: "#fafafa",
colorBgSpotlight: "rgba(38, 38, 38, 0.85)",
colorBgMask: "rgba(38, 38, 38, 0.45)",
colorBorder: "#e5e5e5",
colorBorderSecondary: "#f5f5f5",
borderRadius: 10,
borderRadiusXS: 2,
borderRadiusSM: 6,
borderRadiusLG: 14,
padding: 16,
paddingSM: 12,
paddingLG: 24,
margin: 16,
marginSM: 12,
marginLG: 24,
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)",
boxShadowSecondary: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)",
};
export const shadcnThemeComponents = {
Button: {
primaryShadow: "none",
defaultShadow: "none",
dangerShadow: "none",
defaultBorderColor: "#e4e4e7",
defaultColor: "#18181b",
defaultBg: "#ffffff",
defaultHoverBg: "#f4f4f5",
defaultHoverBorderColor: "#d4d4d8",
defaultHoverColor: "#18181b",
defaultActiveBg: "#e4e4e7",
defaultActiveBorderColor: "#d4d4d8",
borderRadius: 6,
},
Input: {
activeShadow: "none",
hoverBorderColor: "#a1a1aa",
activeBorderColor: "#18181b",
borderRadius: 6,
},
Select: {
optionSelectedBg: "#f4f4f5",
optionActiveBg: "#fafafa",
optionSelectedFontWeight: 500,
borderRadius: 6,
},
Alert: {
borderRadiusLG: 8,
},
Modal: {
borderRadiusLG: 12,
},
Progress: {
defaultColor: "#18181b",
remainingColor: "#f4f4f5",
},
Steps: {
iconSize: 32,
},
Switch: {
trackHeight: 24,
trackMinWidth: 44,
innerMinMargin: 4,
innerMaxMargin: 24,
},
Checkbox: {
borderRadiusSM: 4,
},
Slider: {
trackBg: "#f4f4f5",
trackHoverBg: "#e4e4e7",
handleSize: 18,
handleSizeHover: 20,
railSize: 6,
},
ColorPicker: {
borderRadius: 6,
},
Menu: {
itemFontSize: 14,
groupTitleFontSize: 12,
itemHeight: 40,
fontWeightStrong: 600,
},
Table: {
headerBg: "#fafafa",
headerSplitColor: "#e5e5e5",
fontWeightStrong: 600,
},
};

File diff suppressed because it is too large Load Diff