commit 947b1a1475e2ca4fd6dc58c26773d8f65682df06 Author: CaoWangrenbo Date: Tue Sep 29 00:10:59 2026 +0800 chore: 初始化 idsync 项目 飞书通讯录 ↔ LDAP 同步工具的首个提交。 - internal/feishu: 飞书通讯录客户端、快照与重试 - internal/ldaplib: LDAP 读写与批量操作 - internal/mapping: 飞书部门/用户到 LDAP 条目的映射 - internal/syncer: 同步计划生成、执行与调度 - internal/store: SQLite 台账 - internal/server + internal/webui: 管理后台与内嵌前端产物 - internal/password: SSHA 口令处理 - config.example.yml / Dockerfile / Makefile / web 前端源码 真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..02b4e6f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +bin +data +web/node_modules +.git +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ee32ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# 构建产物 +bin/ +idsync + +# 运行时数据(台账、日志) +data/ + +# 运维导出的 LDIF 备份含真实用户条目、口令哈希与手机号,绝不入库 +backup/ + +# 真实配置含飞书与 LDAP 凭据,绝不入库 +config.yml + +# 前端依赖;构建产物 internal/webui/dist 需要提交,见 README +web/node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02346f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# 多阶段构建:编译 Go 二进制(内嵌 WebUI),运行在极小的基础镜像里。 +FROM docker.io/library/golang:1.26-trixie AS build + +WORKDIR /src + +# 先只拷贝依赖清单,让依赖下载层可以复用缓存。 +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# CGO_ENABLED=0 让产物是静态链接的,可直接跑在 scratch/alpine 上。 +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-s -w" -o /out/idsync . + +FROM docker.io/library/alpine:3.20 + +# ca-certificates 供访问飞书 HTTPS 接口使用;tzdata 让日志时间正确。 +RUN apk add --no-cache ca-certificates tzdata && \ + adduser -D -u 10001 idsync + +WORKDIR /app +COPY --from=build /out/idsync /app/idsync + +# 台账与配置放在挂载卷里,容器重建不丢数据。 +VOLUME ["/app/data"] +USER idsync + +EXPOSE 8899 + +# 默认以服务模式启动;定时同步由配置中的 sync.enable 控制。 +ENTRYPOINT ["/app/idsync"] +CMD ["-config", "/app/config.yml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e5c0e98 --- /dev/null +++ b/Makefile @@ -0,0 +1,88 @@ +# idsync — 飞书 → OpenLDAP 同步器 +# 宿主未安装 Go,所有 Go 操作都在 podman 容器内执行。 +# 注意:容器必须 --network=host,否则容器内的 127.0.0.1 指向容器自身,无法访问宿主代理。 + +GO_IMAGE := docker.io/library/golang:1.26-trixie +NODE_IMAGE := docker.io/library/node:20-alpine +PROXY := http://127.0.0.1:10808 + +PODMAN := podman run --rm --pull=never --network=host \ + -e http_proxy=$(PROXY) -e https_proxy=$(PROXY) \ + -v $(PWD):/app -w /app \ + -v idsync-gomod:/go/pkg/mod \ + -v idsync-gocache:/root/.cache/go-build + +.PHONY: default +default: help + +.PHONY: deps +deps: + $(PODMAN) $(GO_IMAGE) go mod download + $(PODMAN) $(GO_IMAGE) go mod tidy + +.PHONY: build +build: + $(PODMAN) -e CGO_ENABLED=0 $(GO_IMAGE) go build -trimpath -o bin/idsync . + +.PHONY: test +test: + $(PODMAN) $(GO_IMAGE) go test ./... + +.PHONY: vet +vet: + $(PODMAN) $(GO_IMAGE) go vet ./... + +# 集成测试对真实 OpenLDAP 运行;容器内需能访问宿主的 slapd, +# 因此用 host.containers.internal 并放行 no_proxy,避免流量被代理拦走。 +LDAP_TEST ?= ldap://host.containers.internal:3899 + +.PHONY: test-integration +test-integration: + podman run --rm --pull=never --network=host \ + --add-host host.containers.internal:host-gateway \ + -e no_proxy=127.0.0.1,localhost,host.containers.internal \ + -e NO_PROXY=127.0.0.1,localhost,host.containers.internal \ + -e LDAP_TEST_URL=$(LDAP_TEST) \ + -v $(PWD):/app -w /app \ + -v idsync-gomod:/go/pkg/mod \ + -v idsync-gocache:/root/.cache/go-build \ + $(GO_IMAGE) go test -tags=integration ./internal/... -v + +.PHONY: fmt +fmt: + $(PODMAN) $(GO_IMAGE) gofmt -l -w . + +.PHONY: css +css: + sh scripts/build-webui.sh + +.PHONY: run +run: + $(PODMAN) $(GO_IMAGE) go run . -config config.yml + +.PHONY: shell +shell: + podman run --rm -it --network=host \ + -e http_proxy=$(PROXY) -e https_proxy=$(PROXY) \ + -v $(PWD):/app -w /app \ + -v idsync-gomod:/go/pkg/mod \ + -v idsync-gocache:/root/.cache/go-build \ + $(GO_IMAGE) bash + +.PHONY: clean +clean: + rm -rf bin + +.PHONY: help +help: + @echo "目标:" + @echo " deps 下载并整理 Go 依赖" + @echo " build 构建 bin/idsync" + @echo " test 运行单元测试" + @echo " test-integration 对真实 OpenLDAP 运行集成测试" + @echo " vet go vet" + @echo " fmt 格式化" + @echo " css 构建 WebUI 样式(node 容器)" + @echo " run 本地运行" + @echo " shell 进入构建容器" + @echo " clean 清理产物" diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd92d9f --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# idsync + +把飞书通讯录同步到 OpenLDAP,作为一个可扩展的小工具,用来替代 `go-ldap-admin`。 + +## 它做什么,不做什么 + +**做**:读取飞书部门与成员,物化到 OpenLDAP 的用户与分组条目,并在成员离职或重新入职时维护其去处。提供一个内嵌的管理界面,用于查看现状、预览变更、手动触发同步。 + +**不做**:认证、会话、登录页、密码重置、发信、SSO。这些全部由 Authelia 负责。 + +这个边界是刻意的。在「Authelia + OpenLDAP + 各种业务系统」这套组合里,本工具**不在认证路径上**——它只往 LDAP 写属性,鉴权链仍然是「业务系统 → Authelia → OpenLDAP bind」。因此它不需要、也不应该重复实现任何认证逻辑。 + +## 工作方式 + +``` +飞书通讯录 ──读取──▶ 期望状态 ──diff──▶ 操作计划 ──(dry-run 预览 / 执行)──▶ OpenLDAP + ▲ │ + └─────────────── SQLite 台账(所有权记录)◀───────────┘ +``` + +三个关键设计: + +**1. 用官方 SDK,一次拉全量。** +使用 `github.com/larksuite/oapi-sdk-go/v3`。部门一次取回整棵树(`fetch_child=true`),用户按部门枚举后按 `union_id` 去重。 +注意 `BatchGetId` 的 `emails` 参数明确不支持企业邮箱,因此不能靠邮箱反查用户,只能从部门入口枚举。 + +**2. 离职由缺席推断,再逐个确认。** +`FindByDepartment` 不返回离职人员,所以「离职」只能先由「台账在职但本轮缺席」推断出来;随后对每个候选调用 `User.Get` 读取 `status.is_resigned` 确认。**只有确认离职才会迁移**——缺席也可能只是通讯录权限范围被收窄,直接迁移会误伤在职员工。 + +**3. SQLite 台账是删除权限的唯一来源。** +台账记录「哪些条目是本工具创建的」。任何不在台账中的条目(服务账号、手工账号、`admin`)在计划里永不出现,因此不可能被同步任务误改或误删。这在接管一个既有目录时尤其重要。 + +## 同步内容 + +### 登录名 + +`uid` 取自**企业邮箱**(`enterprise_email`)的 `@` 前缀。 + +刻意不使用个人邮箱兜底:企业邮箱是组织内唯一且受控的标识,回落到个人邮箱会产生不可预期、且在离职后仍然有效的登录名。缺少企业邮箱的用户会被**跳过并在界面上显著列出**,不会被静默丢弃。前缀冲突时同样跳过并报告,由人工修正邮箱。 + +### LDAP 布局 + +``` + +├── ou=people 在职用户(Authelia 的 base_dn 指向这里) +│ └── uid=<邮箱前缀>,ou=people, +├── ou=Inactive 离职用户(位于 Authelia base_dn 之外 → 自动无法登录) +│ └── uid=<邮箱前缀>,ou=Inactive, +└── ou= 部门树 + └── cn=<部门拼音>,cn=<父部门拼音>,...,ou=, +``` + +部门用 `groupOfUniqueNames` + `uniqueMember`,因此 Authelia 的 `Remote-Groups` 与基于组的 ACL 可直接使用。 +部门 CN 用拼音:中文作 RDN 需要转义成 `#hex` 形式,可读性差且部分工具链兼容不佳。 + +### 属性映射 + +| LDAP 属性 | 来源 | +|---|---| +| `uid` / `cn` | `enterprise_email` 的 `@` 前缀 | +| `sn` | `name` | +| `displayName` | `name` | +| `givenName` | `en_name`,为空回落 `name` | +| `mail` | **`enterprise_email`** | +| `mobile` | `mobile` | +| `employeeNumber` | `employee_no` | +| `departmentNumber` | `job_title` | +| `postalAddress` | `work_station` | +| `businessCategory` | 所属部门名(逗号拼接) | +| `employeeType` | `Active` / `Resigned` | +| `userPassword` | 仅创建时写入,**更新路径永不触碰** | + +### 与 Authelia 的衔接 + +- **`mail` 必须是企业邮箱**:Authelia 重置密码时从 LDAP 的 `mail` 属性取收件地址。若该属性为空,重置流程会直接失败并报 `user ... has no email address configured`。 +- **`ou=Inactive` 必须在 Authelia 的 `base_dn` 之外**:这是离职用户无法登录的机制。请确认你的 `base_dn` 是否正好是 `ou=people,`。 +- **更新路径绝不写 `userPassword`**:Authelia 重置密码时直接写 LDAP 的 `userPassword`。如果同步的更新路径也写这个属性,用户刚设好的密码会在下一轮同步时被打回初始值。代码中有结构性检查阻止这一点。 +- **密码必须自行哈希**:slapd 不会对普通 Modify 写入的 `userPassword` 自动加盐。本工具写入 `{SSHA}`,不依赖服务端 `password-hash` 配置。 + +新用户会得到一个**随机**初始密码(或用 `ldap.initial-password` 指定),用户不可知。首次登录走 Authelia 的「忘记密码」——该流程不需要旧密码。 + +## 安全护栏 + +默认开启,用于防住「最坏的那一次同步」(飞书应用权限被收窄、token 指向错误租户、部门过滤写错,都会让期望状态突然大幅缩小): + +1. **默认 dry-run**。命令行不加 `-apply`、界面不点确认,都不会写入任何内容。 +2. **停用阈值**。单次停用超过 `max-deactivate` 条或 `max-deactivate-percent` 比例时拒绝执行,需 `-force` 或界面强制确认。 +3. **所有权隔离**。非台账条目永不被修改或删除。 +4. **admin 保护**。`admin` 永不被移出分组;外部手工加入的成员只告警、不移除。 +5. **部门树建树有环保护**,分组按层级排序,保证父分组先于子分组创建。 + +## 构建 + +宿主无需安装 Go 或 Node,全部通过容器完成(本机已有 `golang:1.26-trixie` 与 `node:20-alpine` 镜像): + +```bash +make deps # 下载 Go 依赖 +make css # 构建 WebUI 样式(需要 Node 容器) +make build # 产出 bin/idsync +make test # 单元测试 +make vet +make run # 本地运行 +``` + +> **关于代理与网络**:若 `proxy.golang.org` 不可直连,`Makefile` 已在容器调用上带上 +> `http_proxy`/`https_proxy` 并强制 `--network=host`。默认网络下容器内的 `127.0.0.1` 指向容器自身, +> 无法访问宿主代理——这是必须用 `--network=host` 的原因。 +> +> 拉取基础镜像时同样需要代理(`podman pull` 不经由 Makefile): +> ```bash +> podman pull --http-proxy=http://127.0.0.1:10808 docker.io/library/golang:1.26-trixie +> # 或为本次会话导出: export http_proxy=http://127.0.0.1:10808 +> ``` +> 注意不要用 `containers.conf` 的 `[engine] env` 全局注入代理:那会让所有容器的 `http_proxy` +> 都指向宿主,导致容器内访问自身或本地服务时被代理拦截。 + +`internal/webui/dist/` 下的前端产物会被 `go:embed` 打进二进制,因此**部署时不需要 Node**。修改前端后需重新执行 `make css` 并重新编译。 + +### 集成测试 + +集成测试对真实的 OpenLDAP 运行,覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、modrdn 移动后密码是否保留、以及「更新绝不触碰 userPassword」: + +```bash +podman run -d --name idsync-ldap -p 3899:389 \ + -e LDAP_ORGANISATION="Example Inc" -e LDAP_DOMAIN="example.com" \ + -e LDAP_ADMIN_PASSWORD=adminpass \ + docker.io/osixia/openldap:1.5.0 + +make test-integration +``` + +端到端测试还会启动一个假的飞书 OpenAPI 服务(`internal/syncer/e2e_test.go`),在没有真实租户凭据的情况下验证完整链路:首次全量创建、幂等重跑、属性漂移、离职迁移、重新入职、以及护栏拦截。 + +## 运行 + +```bash +cp config.example.yml config.yml +# 生成会话密钥与管理员密码哈希 +./bin/idsync -gen-secret +./bin/idsync -hash-password '你的密码' + +# 只预览,不写入(推荐先跑这个) +./bin/idsync -config config.yml -dry-run + +# 确认无误后执行 +./bin/idsync -config config.yml -apply + +# 服务模式(含 WebUI 与定时同步) +./bin/idsync -config config.yml +``` + +建议的首次接管流程:先 `-dry-run` 看计划 → 核对后 `-apply` → 打开界面确认用户与分组 → 观察几轮 dry-run 的定时任务后再把 `sync.default-dry-run` 改为 `false`。 + +## 管理界面 + +六个页面:概览(连通性与计数)、用户(受管清单 + 一致性检查)、部门分组、**变更预览**(最有价值的一页:dry-run 计划表,确认后执行)、同步历史。 + +样式用 Tailwind v4 + FlyonUI。FlyonUI 官方不支持 CDN 方式(样式在构建期生成),因此走官方的构建路径,产物内嵌进二进制。 + +主题为白/暗底 + 绿色主色,取自 Gitea 的品牌视觉(`#609926`,暗色下 `#7ab63a`;注意 Gitea 自身的 `--color-primary` 其实是蓝色,品牌绿是它的 logo 色)。右上角按钮切换,偏好存在 `localStorage`。 + +## 部署 + +```bash +podman build -t idsync . +podman run -d --name idsync -p 8899:8899 \ + -v $PWD/config.yml:/app/config.yml:ro \ + -v idsync-data:/app/data \ + idsync +``` + +## 已知边界 + +- **不做密码同步下发**。Authelia 重置密码只写 LDAP,不发出任何事件或回调。因此对于「只认自己库里本地密码、既不吃 LDAP 也不读 HTTP 头」的服务,无法通过本工具同步密码。这类服务建议套一层 forward-auth(由 Authelia 注入 `Remote-User` / `Remote-Email` / `Remote-Groups`),或迁移到支持 LDAP 的同类方案。 +- 部门改名会导致分组 DN 变化。此时**不会自动改名**(自动改名会牵动成员的 `uniqueMember` 引用与依赖该 DN 的 ACL),而是在预览页告警,交由人工处理。 +- 飞书侧删除的部门不会被自动删除分组,同样只告警。 diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..92899f6 --- /dev/null +++ b/config.example.yml @@ -0,0 +1,74 @@ +# idsync 配置示例。复制为 config.yml 后按实际环境修改。 +# +# 注意:本工具不参与认证链路,用户登录、密码重置、发信全部由 Authelia 负责。 +# 它只把飞书通讯录物化到 OpenLDAP,因此这里没有也不需要任何认证相关配置。 + +system: + # debug 会输出更详细的日志;生产环境用 release + mode: release + # 台账数据库与运行数据目录 + data-dir: data + +server: + listen: 0.0.0.0:8899 + # 管理界面自身的登录凭据(仅用于保护这个界面,与 LDAP 用户无关) + admin-user: admin + # 明文密码只在首次启动时用于生成哈希;建议改用 hash-password 生成下面的哈希 + admin-password: change-me + # 用 `./idsync -hash-password '你的密码'` 生成,留空则使用上面的明文密码 + admin-password-hash: "" + # 会话签名密钥,用 `./idsync -gen-secret` 生成。 + # 留空会自动生成临时密钥,但重启后需要重新登录。 + session-secret: "" + # 经 HTTPS 反代访问时设为 true + secure-cookie: false + +feishu: + app-id: "cli_xxxxxxxxxxxx" + app-secret: "xxxxxxxxxxxxxxxxxxxxxxxx" + # 留空使用飞书开放平台;Lark 国际版填 https://open.larksuite.com + base-url: "" + # 留空表示同步全部部门;填部门 ID 则只同步这些部门, + # 前缀 ^ 表示排除该部门及其子部门(例如 ["^odxxxx"]) + dept-list: [] + # 调用飞书的速率上限(次/秒),避免触发频控 + qps: 5 + timeout-sec: 30 + +ldap: + # 生产环境务必使用 ldaps:// 或开启 start-tls: + # Authelia 重置密码时会把新密码经此连接发送。 + url: ldaps://ldap.example.com:636 + base-dn: "dc=example,dc=com" + admin-dn: "cn=admin,dc=example,dc=com" + admin-pass: "change-me" + # 在职用户所在 OU。必须落在 Authelia 的 base_dn 之内。 + user-ou: "ou=people" + # 离职用户迁往的 OU。必须落在 Authelia 的 base_dn 之外, + # 这样离职者会自动失去登录能力,无需在 Authelia 侧加过滤条件。 + inactive-ou: "ou=Inactive" + # 部门树根 OU 的名称(仅名称,不含 ou=) + group-root-name: "feishuroot" + timeout-sec: 15 + start-tls: false + # 仅自签证书的测试环境使用 + insecure-skip-verify: false + # 新建用户的初始密码。留空则每个用户随机生成, + # 用户首次登录应走 Authelia 的“忘记密码”自助重置。 + initial-password: "" + +database: + source: data/idsync.db + +sync: + # 是否启用定时同步 + enable: true + # 秒级 cron:秒 分 时 日 月 周。默认每天 00:40 执行。 + schedule: "0 40 0 * * *" + # true 表示定时任务只生成计划不写入。建议先观察几轮再改为 false。 + default-dry-run: true + # 单次同步允许停用的账号数上限,0 表示不限制 + max-deactivate: 0 + # 单次同步允许停用的账号比例上限(0-1)。 + # 用于防止飞书应用权限范围被误改导致大批账号被判定为离职。 + max-deactivate-percent: 0.2 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cd51c1c --- /dev/null +++ b/go.mod @@ -0,0 +1,42 @@ +module idsync + +go 1.26 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/go-ldap/ldap/v3 v3.4.14 + github.com/larksuite/oapi-sdk-go/v3 v3.12.0 + github.com/mozillazg/go-pinyin v0.21.0 + github.com/robfig/cron/v3 v3.0.1 + github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.54.0 + gorm.io/gorm v1.31.2 +) + +require ( + github.com/Azure/go-ntlmssp v0.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..024f961 --- /dev/null +++ b/go.sum @@ -0,0 +1,113 @@ +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= +github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= +github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= +github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/larksuite/oapi-sdk-go/v3 v3.12.0 h1:H8NP6YIgfEX0RBhKse25npdeZoiaDv8mrw9nCnCVFRc= +github.com/larksuite/oapi-sdk-go/v3 v3.12.0/go.mod h1:F1nwLfYBSKvD8mS/OFw90Gr+V4RIugPBR44m/ThA5jg= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao= +github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8e766c5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,248 @@ +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/viper" +) + +// Config 是 idsync 的完整配置。 +type Config struct { + System System `mapstructure:"system"` + Server Server `mapstructure:"server"` + Feishu Feishu `mapstructure:"feishu"` + Ldap Ldap `mapstructure:"ldap"` + Database Database `mapstructure:"database"` + Sync Sync `mapstructure:"sync"` +} + +type System struct { + // Mode: debug 或 release,影响 gin 与日志详细度 + Mode string `mapstructure:"mode"` + // DataDir 用于存放 sqlite 台账与日志 + DataDir string `mapstructure:"data-dir"` +} + +type Server struct { + // Listen 形如 0.0.0.0:8899 + Listen string `mapstructure:"listen"` + // AdminUser / AdminPassword 是 WebUI 的单一管理员凭据。 + // 首次启动时若 AdminPasswordHash 为空,会用 AdminPassword 生成 bcrypt 哈希并回写。 + AdminUser string `mapstructure:"admin-user"` + AdminPassword string `mapstructure:"admin-password"` + AdminPasswordHash string `mapstructure:"admin-password-hash"` + // SessionSecret 用于签名会话 cookie,留空则首次启动随机生成并回写。 + SessionSecret string `mapstructure:"session-secret"` + // SecureCookie 在 HTTPS 前置反代后应设为 true + SecureCookie bool `mapstructure:"secure-cookie"` +} + +type Feishu struct { + AppID string `mapstructure:"app-id"` + AppSecret string `mapstructure:"app-secret"` + // BaseURL 留空使用飞书(open.feishu.cn);Lark 国际版填 https://open.larksuite.com + BaseURL string `mapstructure:"base-url"` + // DeptList 留空表示同步全部部门;填值则只同步这些部门 ID,前缀 ^ 表示排除该部门 + DeptList []string `mapstructure:"dept-list"` + // QPS 限制调用飞书的速率,默认 5 + QPS int `mapstructure:"qps"` + // TimeoutSec 单次 API 调用超时,默认 30 + TimeoutSec int `mapstructure:"timeout-sec"` +} + +type Ldap struct { + URL string `mapstructure:"url"` + BaseDN string `mapstructure:"base-dn"` + AdminDN string `mapstructure:"admin-dn"` + AdminPass string `mapstructure:"admin-pass"` + // UserOU 在职用户所在 OU,必须是 Authelia base_dn 指向的子树 + UserOU string `mapstructure:"user-ou"` + // InactiveOU 离职用户迁往的 OU,应位于 Authelia base_dn 之外 + InactiveOU string `mapstructure:"inactive-ou"` + // GroupRootOU 部门树的根 OU 名称(仅名称,不含 ou=) + GroupRootName string `mapstructure:"group-root-name"` + // GroupObjectClass 部门分组使用的 objectClass。 + // 必须与 Authelia 的 groups_filter 一致,否则分组对 Authelia 不可见, + // Remote-Groups 会是空的,基于组的 ACL 全部失效。 + // 常见取值:groupOfUniqueNames(配 uniqueMember)或 groupOfNames(配 member)。 + GroupObjectClass string `mapstructure:"group-object-class"` + // GroupMemberAttr 分组成员属性名。留空则按 GroupObjectClass 推导。 + GroupMemberAttr string `mapstructure:"group-member-attr"` + // TimeoutSec 连接与操作超时,默认 15 + TimeoutSec int `mapstructure:"timeout-sec"` + // StartTLS 对 ldap:// 启用 StartTLS + StartTLS bool `mapstructure:"start-tls"` + // InsecureSkipVerify 仅用于自签证书的测试环境 + InsecureSkipVerify bool `mapstructure:"insecure-skip-verify"` + // InitialPassword 非空时用作新建用户的固定初始密码;留空则每个用户随机生成 + InitialPassword string `mapstructure:"initial-password"` +} + +type Database struct { + // Source 是 sqlite 文件路径 + Source string `mapstructure:"source"` +} + +type Sync struct { + // Enable 是否启用定时同步 + Enable bool `mapstructure:"enable"` + // Schedule 秒级 cron,例如 "0 40 0 * * *" + Schedule string `mapstructure:"schedule"` + // DefaultDryRun 为 true 时,定时任务只生成计划不执行 + DefaultDryRun bool `mapstructure:"default-dry-run"` + // MaxDeactivate 单次同步允许停用的最大账号数,0 表示不限制 + MaxDeactivate int `mapstructure:"max-deactivate"` + // MaxDeactivatePercent 单次同步允许停用的账号比例上限(0-1),0 表示不限制 + MaxDeactivatePercent float64 `mapstructure:"max-deactivate-percent"` +} + +// Load 读取配置文件并应用默认值与校验。 +func Load(path string) (*Config, error) { + v := viper.New() + setDefaults(v) + + if path != "" { + v.SetConfigFile(path) + } else { + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath(".") + v.AddConfigPath("/etc/idsync") + } + + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("读取配置失败: %w", err) + } + + cfg := &Config{} + if err := v.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("解析配置失败: %w", err) + } + + if err := cfg.normalize(); err != nil { + return nil, err + } + return cfg, nil +} + +func setDefaults(v *viper.Viper) { + v.SetDefault("system.mode", "release") + v.SetDefault("system.data-dir", "data") + + v.SetDefault("server.listen", "0.0.0.0:8899") + v.SetDefault("server.admin-user", "admin") + v.SetDefault("server.secure-cookie", false) + + v.SetDefault("feishu.qps", 5) + v.SetDefault("feishu.timeout-sec", 30) + + v.SetDefault("ldap.url", "ldap://localhost:389") + v.SetDefault("ldap.user-ou", "ou=people") + v.SetDefault("ldap.inactive-ou", "ou=Inactive") + v.SetDefault("ldap.group-root-name", "feishuroot") + v.SetDefault("ldap.group-object-class", "groupOfUniqueNames") + v.SetDefault("ldap.group-member-attr", "") + v.SetDefault("ldap.timeout-sec", 15) + v.SetDefault("ldap.start-tls", false) + v.SetDefault("ldap.insecure-skip-verify", false) + + v.SetDefault("database.source", "data/idsync.db") + + v.SetDefault("sync.enable", false) + v.SetDefault("sync.schedule", "0 40 0 * * *") + v.SetDefault("sync.default-dry-run", true) + v.SetDefault("sync.max-deactivate", 0) + v.SetDefault("sync.max-deactivate-percent", 0.2) +} + +// NormalizeForTest 暴露配置规范化,供测试从结构体直接构造配置时使用。 +// 生产路径由 Load 内部调用同一份逻辑,避免两边行为分叉。 +func (c *Config) NormalizeForTest() error { return c.normalize() } + +// normalize 补全并校验配置,把用户写的相对名称拼成完整 DN。 +func (c *Config) normalize() error { + if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" { + return fmt.Errorf("feishu.app-id 与 feishu.app-secret 不能为空") + } + if c.Ldap.BaseDN == "" { + return fmt.Errorf("ldap.base-dn 不能为空") + } + if c.Ldap.AdminDN == "" || c.Ldap.AdminPass == "" { + return fmt.Errorf("ldap.admin-dn 与 ldap.admin-pass 不能为空") + } + if c.Server.AdminPassword == "" && c.Server.AdminPasswordHash == "" { + return fmt.Errorf("server.admin-password 或 server.admin-password-hash 必须提供其中之一") + } + if c.Sync.Enable && c.Sync.Schedule == "" { + return fmt.Errorf("sync.schedule 不能为空") + } + + if err := c.Ldap.normalizeGroupSchema(); err != nil { + return err + } + return nil +} + +// normalizeGroupSchema 补全分组模式,并校验 objectClass 与成员属性的搭配。 +// +// 这两项必须与 Authelia 的 groups_filter 匹配:objectClass 不匹配则 Authelia +// 完全看不到我们建的分组;成员属性不匹配则 membership 查询返回空。二者都不会 +// 报错,只会表现为「分组 ACL 静默失效」,所以这里主动校验而不是默默容忍。 +func (l *Ldap) normalizeGroupSchema() error { + oc := strings.TrimSpace(l.GroupObjectClass) + if oc == "" { + oc = "groupOfUniqueNames" + } + l.GroupObjectClass = oc + + attr := strings.TrimSpace(l.GroupMemberAttr) + if attr == "" { + // 按 objectClass 推导出配套的成员属性 + switch strings.ToLower(oc) { + case "groupofnames": + attr = "member" + default: + attr = "uniqueMember" + } + } + l.GroupMemberAttr = attr + + // 校验搭配是否正确:groupOfNames 只允许 member,groupOfUniqueNames 只允许 uniqueMember。 + // 写错会产生 LDAP objectClassViolation,但报错信息晦涩,提前拦下更友好。 + switch strings.ToLower(oc) { + case "groupofnames": + if !strings.EqualFold(attr, "member") { + return fmt.Errorf("ldap.group-object-class 为 groupOfNames 时,ldap.group-member-attr 必须为 member(当前 %q)", attr) + } + case "groupofuniquenames": + if !strings.EqualFold(attr, "uniqueMember") { + return fmt.Errorf("ldap.group-object-class 为 groupOfUniqueNames 时,ldap.group-member-attr 必须为 uniqueMember(当前 %q)", attr) + } + } + return nil +} + +// UserOUDN 返回在职用户的完整 OU DN。 +func (c *Config) UserOUDN() string { + return joinDN(c.Ldap.UserOU, c.Ldap.BaseDN) +} + +// InactiveOUDN 返回离职用户的完整 OU DN。 +func (c *Config) InactiveOUDN() string { + return joinDN(c.Ldap.InactiveOU, c.Ldap.BaseDN) +} + +// GroupRootDN 返回部门树根 OU 的完整 DN。 +func (c *Config) GroupRootDN() string { + name := strings.TrimSpace(c.Ldap.GroupRootName) + if name == "" { + name = "feishuroot" + } + return "ou=" + name + "," + c.Ldap.BaseDN +} + +// joinDN 把相对 RDN 与后缀拼成完整 DN,容忍两端多余逗号。 +func joinDN(rdn, suffix string) string { + return strings.TrimSuffix(strings.TrimSpace(rdn), ",") + "," + strings.TrimSpace(suffix) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..bec939a --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,210 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// writeConfig 写一份最小可用配置并加载它。 +func writeConfig(t *testing.T, extra string) *Config { + t.Helper() + + body := ` +system: + data-dir: /tmp/idsync-test +server: + admin-user: admin + admin-password: pw +feishu: + app-id: a + app-secret: b +ldap: + url: ldap://localhost:389 + base-dn: "dc=example,dc=org" + admin-dn: "cn=admin,dc=example,dc=org" + admin-pass: pw +` + extra + + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("写配置失败: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("加载配置失败: %v", err) + } + return cfg +} + +// TestGroupSchemaDefaultsToUniqueMember 验证默认形态保持兼容。 +func TestGroupSchemaDefaultsToUniqueMember(t *testing.T) { + cfg := writeConfig(t, "") + + if cfg.Ldap.GroupObjectClass != "groupOfUniqueNames" { + t.Errorf("默认 objectClass = %q", cfg.Ldap.GroupObjectClass) + } + if cfg.Ldap.GroupMemberAttr != "uniqueMember" { + t.Errorf("默认成员属性 = %q", cfg.Ldap.GroupMemberAttr) + } +} + +// TestGroupSchemaDerivesMemberAttr 验证只写 objectClass 时能自动推导出成员属性。 +// +// 这是实际使用中最容易出错的地方:groupOfNames 必须配 member、 +// groupOfUniqueNames 必须配 uniqueMember,写错会被 LDAP 以 +// objectClassViolation 拒绝,而错误信息很不直观。 +func TestGroupSchemaDerivesMemberAttr(t *testing.T) { + cases := []struct { + objectClass string + wantAttr string + }{ + {"groupOfNames", "member"}, + {"groupOfUniqueNames", "uniqueMember"}, + // 大小写不敏感 + {"GroupOfNames", "member"}, + {"GROUPOFUNIQUENAMES", "uniqueMember"}, + } + + for _, c := range cases { + t.Run(c.objectClass, func(t *testing.T) { + cfg := writeConfig(t, " group-object-class: \""+c.objectClass+"\"\n") + if cfg.Ldap.GroupMemberAttr != c.wantAttr { + t.Errorf("objectClass=%s 推导出的成员属性 = %q, 期望 %q", + c.objectClass, cfg.Ldap.GroupMemberAttr, c.wantAttr) + } + }) + } +} + +// TestGroupSchemaRejectsMismatch 验证 objectClass 与成员属性不匹配时直接报错。 +// +// 静默容忍会让问题推迟到运行时,表现为分组建好了但成员查不到, +// 排查成本远高于启动即失败。 +func TestGroupSchemaRejectsMismatch(t *testing.T) { + cases := []struct { + name string + body string + }{ + { + name: "groupOfNames 配 uniqueMember", + body: " group-object-class: \"groupOfNames\"\n group-member-attr: \"uniqueMember\"\n", + }, + { + name: "groupOfUniqueNames 配 member", + body: " group-object-class: \"groupOfUniqueNames\"\n group-member-attr: \"member\"\n", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + body := ` +system: + data-dir: /tmp/idsync-test +server: + admin-user: admin + admin-password: pw +feishu: + app-id: a + app-secret: b +ldap: + url: ldap://localhost:389 + base-dn: "dc=example,dc=org" + admin-dn: "cn=admin,dc=example,dc=org" + admin-pass: pw +` + c.body + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("写配置失败: %v", err) + } + if _, err := Load(path); err == nil { + t.Errorf("不匹配的组合应被拒绝,却加载成功") + } + }) + } +} + +// TestDerivedDNs 验证各 OU 拼接结果正确。 +func TestDerivedDNs(t *testing.T) { + cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n group-root-name: \"feishuroot\"\n") + + if got := cfg.UserOUDN(); got != "ou=users,dc=example,dc=org" { + t.Errorf("UserOUDN = %q", got) + } + if got := cfg.InactiveOUDN(); got != "ou=Inactive,dc=example,dc=org" { + t.Errorf("InactiveOUDN = %q", got) + } + if got := cfg.GroupRootDN(); got != "ou=feishuroot,dc=example,dc=org" { + t.Errorf("GroupRootDN = %q", got) + } +} + +// TestInactiveOUOutsideUserOU 是一个架构约束测试。 +// +// ou=Inactive 必须位于用户 OU 之外,否则 Authelia(其 base 为 additional_users_dn) +// 依然能搜到离职用户,停用机制就失效了。 +func TestInactiveOUOutsideUserOU(t *testing.T) { + cfg := writeConfig(t, " user-ou: \"ou=users\"\n inactive-ou: \"ou=Inactive\"\n") + + userDN := cfg.UserOUDN() + inactiveDN := cfg.InactiveOUDN() + + if inactiveDN == userDN { + t.Fatalf("离职 OU 与用户 OU 相同: %s", inactiveDN) + } + // 离职 OU 不应位于用户 OU 之下 + if len(inactiveDN) > len(userDN) && inactiveDN[len(inactiveDN)-len(userDN):] == userDN { + t.Errorf("离职 OU (%s) 位于用户 OU (%s) 之下,Authelia 仍能搜到离职用户", inactiveDN, userDN) + } +} + +// TestNormalizeRejectsMissingCredentials 验证关键字段缺失时启动即失败。 +func TestNormalizeRejectsMissingCredentials(t *testing.T) { + cases := []struct { + name string + over string + }{ + {"缺少 app-id", " app-id: \"\"\n"}, + {"缺少 base-dn", "BASE_DN_MARKER"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yml") + + feishuID, baseDN := "app", `"dc=example,dc=org"` + if c.name == "缺少 app-id" { + feishuID = "" + } + if c.name == "缺少 base-dn" { + baseDN = `""` + } + + body := ` +system: + data-dir: /tmp/idsync-test +server: + admin-user: admin + admin-password: pw +feishu: + app-id: "` + feishuID + `" + app-secret: b +ldap: + url: ldap://localhost:389 + base-dn: ` + baseDN + ` + admin-dn: "cn=admin,dc=example,dc=org" + admin-pass: pw +` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("写配置失败: %v", err) + } + if _, err := Load(path); err == nil { + t.Errorf("缺少必要配置项时应加载失败") + } + }) + } +} diff --git a/internal/feishu/client.go b/internal/feishu/client.go new file mode 100644 index 0000000..02b0634 --- /dev/null +++ b/internal/feishu/client.go @@ -0,0 +1,147 @@ +// Package feishu 封装飞书通讯录的读取,产出与 LDAP 无关的领域模型。 +// +// 这里刻意只做“读”与规范化,不做任何映射或写入决策:把飞书的原始事实 +// 转成干净的结构,diff 引擎再拿它与 LDAP 现状比较。 +package feishu + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3" +) + +// 部门 ID 类型与用户 ID 类型固定使用 open_department_id / union_id: +// 前者在租户内全局稳定且不随自定义 ID 变化,后者跨应用一致,适合做去重键。 +const ( + deptIDType = "open_department_id" + userIDType = "union_id" +) + +// Client 是对官方 SDK 的薄封装,负责限速、超时与重试。 +type Client struct { + lark *lark.Client + log *slog.Logger + timeout time.Duration + + // ticker 实现一个简单的令牌节奏限速,避免触发飞书侧频控。 + mu sync.Mutex + lastAt time.Time + period time.Duration +} + +// Options 是构造 Client 所需的参数。 +type Options struct { + AppID string + AppSecret string + BaseURL string + QPS int + Timeout time.Duration +} + +// New 构造飞书客户端。 +func New(opt Options, log *slog.Logger) (*Client, error) { + if opt.AppID == "" || opt.AppSecret == "" { + return nil, fmt.Errorf("飞书 app-id / app-secret 未配置") + } + if opt.QPS <= 0 { + opt.QPS = 5 + } + if opt.Timeout <= 0 { + opt.Timeout = 30 * time.Second + } + + opts := []lark.ClientOptionFunc{ + lark.WithReqTimeout(opt.Timeout), + lark.WithLogLevel(larkcore.LogLevelError), + } + if opt.BaseURL != "" { + opts = append(opts, lark.WithOpenBaseUrl(opt.BaseURL)) + } + + return &Client{ + lark: lark.NewClient(opt.AppID, opt.AppSecret, opts...), + log: log, + timeout: opt.Timeout, + period: time.Second / time.Duration(opt.QPS), + }, nil +} + +// wait 在两次外部调用之间维持最小间隔,实现全局限速。 +func (c *Client) wait(ctx context.Context) { + c.mu.Lock() + now := time.Now() + gap := c.period - now.Sub(c.lastAt) + if gap > 0 { + c.lastAt = now.Add(gap) + } else { + c.lastAt = now + } + c.mu.Unlock() + + if gap > 0 { + select { + case <-time.After(gap): + case <-ctx.Done(): + } + } +} + +// call 统一处理限速、超时与有限次数的指数退避重试。 +// +// 只重试看起来是暂时性的错误(超时、连接问题、5xx);权限或参数错误立即返回, +// 重试它们只会放大问题并拖慢整轮同步。 +func (c *Client) call(ctx context.Context, what string, fn func(ctx context.Context) error) error { + const maxAttempts = 3 + + var err error + for attempt := 1; attempt <= maxAttempts; attempt++ { + c.wait(ctx) + + callCtx, cancel := context.WithTimeout(ctx, c.timeout) + err = fn(callCtx) + cancel() + + if err == nil { + return nil + } + if !isRetryable(err) || attempt == maxAttempts { + return fmt.Errorf("%s 失败: %w", what, err) + } + + backoff := time.Duration(attempt) * 500 * time.Millisecond + c.log.Warn("飞书调用失败,准备重试", + "op", what, "attempt", attempt, "backoff", backoff, "err", err) + + select { + case <-time.After(backoff): + case <-ctx.Done(): + return ctx.Err() + } + } + return fmt.Errorf("%s 失败: %w", what, err) +} + +// Test 做一次轻量的连通性检查:拉取根部门下的一页部门。 +func (c *Client) Test(ctx context.Context) error { + return c.call(ctx, "连通性测试", func(ctx context.Context) error { + resp, err := c.lark.Contact.V3.Department.List(ctx, + larkcontact.NewListDepartmentReqBuilder(). + ParentDepartmentId("0"). + DepartmentIdType(deptIDType). + PageSize(1). + Build()) + if err != nil { + return err + } + if !resp.Success() { + return fmt.Errorf("飞书返回错误: code=%d msg=%s", resp.Code, resp.Msg) + } + return nil + }) +} diff --git a/internal/feishu/retry.go b/internal/feishu/retry.go new file mode 100644 index 0000000..93ff806 --- /dev/null +++ b/internal/feishu/retry.go @@ -0,0 +1,45 @@ +package feishu + +import ( + "context" + "errors" + "net" + "strings" +) + +// isRetryable 判断错误是否值得重试。 +// +// 只重试可能自愈的传输层问题与飞书的频控/服务端错误;权限、参数错误 +// 立即失败——重试它们既无意义,又会拖慢整轮同步并掩盖真正的问题。 +func isRetryable(err error) bool { + if err == nil { + return false + } + + // 上下文取消是调用方的决定,不要重试。 + if errors.Is(err, context.Canceled) { + return false + } + // 单次调用的超时值得再试一次;整体超时由外层 ctx 决定。 + if errors.Is(err, context.DeadlineExceeded) { + return true + } + + var netErr net.Error + if errors.As(err, &netErr) { + return true + } + + msg := strings.ToLower(err.Error()) + for _, s := range []string{ + "timeout", "timed out", "connection reset", "connection refused", + "broken pipe", "eof", "temporary failure", "too many request", + "rate limit", "internal server error", "bad gateway", + "service unavailable", "gateway timeout", + } { + if strings.Contains(msg, s) { + return true + } + } + return false +} diff --git a/internal/feishu/snapshot.go b/internal/feishu/snapshot.go new file mode 100644 index 0000000..fe3c6d0 --- /dev/null +++ b/internal/feishu/snapshot.go @@ -0,0 +1,451 @@ +package feishu + +import ( + "context" + "fmt" + "sort" + "strings" + + larkcontact "github.com/larksuite/oapi-sdk-go/v3/service/contact/v3" +) + +// maxItems 是单次读取允许的最大条目数,用于防御分页异常。 +// 一个租户的部门或成员数量远超此值时必然意味着接口行为异常 +// (例如服务端在 has_more=false 时仍返回非空游标导致原地打转), +// 宁可直接失败也不要无限翻页拖垮同步。 +const maxItems = 200000 + +// Department 是从飞书读取的部门。 +type Department struct { + ID string // open_department_id + ParentID string + Name string + // Order 是同级排序,用于稳定输出 + Order int +} + +// User 是从飞书读取的用户,字段已规范化为可空字符串。 +type User struct { + UnionID string + UserID string + OpenID string + Name string + EnName string + EnterpriseEmail string + Email string + Mobile string + EmployeeNo string + JobTitle string + WorkStation string + DepartmentIDs []string // open_department_id 列表 +} + +// Resigned 表示飞书确认已离职的用户。 +type Resigned struct { + UnionID string + UserID string + Name string +} + +// Snapshot 是一次完整读取的结果。 +type Snapshot struct { + Departments []*Department + Users []*User + // ResignedUsers 仅在显式检查时填充(见 CheckResigned)。 + ResignedUsers []*Resigned + // SkippedNoEmail 记录因缺少企业邮箱而无法生成登录名的用户。 + // 这些用户不会被同步,但必须在界面上显式暴露,避免“静默丢失”。 + SkippedNoEmail []*User + // SkippedDuplicate 记录企业邮箱前缀与他人冲突的用户(uid, 姓名)。 + SkippedDuplicate []DuplicateConflict +} + +// DuplicateConflict 描述一次登录名冲突。 +type DuplicateConflict struct { + UID string + Names []string +} + +// Fetch 拉取部门与用户,构建期望状态快照。 +// +// 如果 onlyDepts 非空,则只同步列出的部门;前缀 ^ 表示排除该部门。 +func (c *Client) Fetch(ctx context.Context, onlyDepts []string) (*Snapshot, error) { + depts, err := c.fetchDepartments(ctx) + if err != nil { + return nil, err + } + + depts = filterDepartments(depts, onlyDepts) + + users, skippedNoEmail, dups, err := c.fetchUsers(ctx, depts) + if err != nil { + return nil, err + } + + return &Snapshot{ + Departments: depts, + Users: users, + SkippedNoEmail: skippedNoEmail, + SkippedDuplicate: dups, + }, nil +} + +// fetchDepartments 拉取全量部门(含子部门)。 +// +// 使用 ParentDepartmentId("0") + FetchChild(true) 一次性取回整棵树, +// 避免像旧实现那样逐部门递归调用造成 O(部门数) 次请求。 +func (c *Client) fetchDepartments(ctx context.Context) ([]*Department, error) { + var out []*Department + + err := c.call(ctx, "获取部门列表", func(ctx context.Context) error { + iter, err := c.lark.Contact.V3.Department.ListByIterator(ctx, + larkcontact.NewListDepartmentReqBuilder(). + ParentDepartmentId("0"). + FetchChild(true). + DepartmentIdType(deptIDType). + PageSize(50). + Build()) + if err != nil { + return err + } + + out = out[:0] + for { + ok, dept, err := iter.Next() + if err != nil { + return err + } + if !ok { + return nil + } + if dept == nil || dept.OpenDepartmentId == nil { + continue + } + if len(out) >= maxItems { + return fmt.Errorf("部门数量超过上限 %d,疑似分页异常", maxItems) + } + d := &Department{ + ID: deref(dept.OpenDepartmentId), + ParentID: deref(dept.ParentDepartmentId), + Name: deref(dept.Name), + } + if dept.Order != nil { + if n, err := parseInt(dept.Order); err == nil { + d.Order = n + } + } + out = append(out, d) + } + }) + if err != nil { + return nil, err + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].Order != out[j].Order { + return out[i].Order < out[j].Order + } + return out[i].ID < out[j].ID + }) + return out, nil +} + +// fetchUsers 遍历部门拉取用户并按 union_id 去重。 +// +// 为什么按部门遍历而不是用 Email 批量查询:官方 SDK 明确说明 +// BatchGetId 的 emails 参数不支持企业邮箱,而我们的登录名正是企业邮箱前缀, +// 因此只能以部门为入口枚举,再按 union_id 去重(同一用户可属于多个部门)。 +func (c *Client) fetchUsers(ctx context.Context, depts []*Department) ( + users []*User, skippedNoEmail []*User, dups []DuplicateConflict, err error, +) { + byUnionID := make(map[string]*User) + seenUID := make(map[string][]string) // uid -> 姓名列表,用于检测冲突 + + for _, d := range depts { + err := c.call(ctx, "获取部门 "+d.ID+" 的用户", func(ctx context.Context) error { + iter, err := c.lark.Contact.V3.User.FindByDepartmentByIterator(ctx, + larkcontact.NewFindByDepartmentUserReqBuilder(). + DepartmentId(d.ID). + DepartmentIdType(deptIDType). + UserIdType(userIDType). + PageSize(50). + Build()) + if err != nil { + return err + } + + for { + ok, u, err := iter.Next() + if err != nil { + return err + } + if !ok { + return nil + } + if u == nil || u.UnionId == nil { + continue + } + if len(byUnionID) >= maxItems { + return fmt.Errorf("用户数量超过上限 %d,疑似分页异常", maxItems) + } + // FindByDepartment 只返回在职人员,但仍做一次防御性过滤。 + if u.Status != nil && u.Status.IsResigned != nil && *u.Status.IsResigned { + continue + } + + uid := deref(u.UnionId) + if existing, ok := byUnionID[uid]; ok { + // 同一用户在多部门出现:合并部门列表,而不是覆盖。 + existing.DepartmentIDs = mergeUnique(existing.DepartmentIDs, derefDepts(u.DepartmentIds)) + continue + } + + nu := &User{ + UnionID: uid, + UserID: deref(u.UserId), + OpenID: deref(u.OpenId), + Name: deref(u.Name), + EnName: deref(u.EnName), + EnterpriseEmail: deref(u.EnterpriseEmail), + Email: deref(u.Email), + Mobile: deref(u.Mobile), + EmployeeNo: deref(u.EmployeeNo), + JobTitle: deref(u.JobTitle), + WorkStation: deref(u.WorkStation), + DepartmentIDs: derefDepts(u.DepartmentIds), + } + byUnionID[uid] = nu + } + }) + if err != nil { + return nil, nil, nil, err + } + } + + // 校验登录名可用性:必须有企业邮箱,且前缀不与他人冲突。 + for _, u := range byUnionID { + uid, ok := loginName(u) + if !ok { + skippedNoEmail = append(skippedNoEmail, u) + continue + } + seenUID[uid] = append(seenUID[uid], displayName(u)) + } + + conflicted := make(map[string]bool) + for uid, names := range seenUID { + if len(names) > 1 { + conflicted[uid] = true + dups = append(dups, DuplicateConflict{UID: uid, Names: names}) + } + } + sort.SliceStable(dups, func(i, j int) bool { return dups[i].UID < dups[j].UID }) + + for _, u := range byUnionID { + uid, ok := loginName(u) + if !ok || conflicted[uid] { + continue // 已在 skippedNoEmail / dups 中报告 + } + users = append(users, u) + } + + sort.SliceStable(users, func(i, j int) bool { + return loginName2(users[i]) < loginName2(users[j]) + }) + return users, skippedNoEmail, dups, nil +} + +// CheckResigned 对给定的 union_id 列表确认其是否确实离职。 +// +// 背景:FindByDepartment 不返回离职人员,所以“离职”只能通过缺席推断。 +// 但缺席也可能是权限范围变更或部门调整导致的假阳性,因此对候选中的人 +// 逐个调用 Get 读取真实的 status.is_resigned,确认后才停用。 +func (c *Client) CheckResigned(ctx context.Context, unionIDs []string) ([]*Resigned, error) { + var out []*Resigned + + for _, uid := range unionIDs { + var got *Resigned + err := c.call(ctx, "确认用户状态 "+uid, func(ctx context.Context) error { + resp, err := c.lark.Contact.V3.User.Get(ctx, + larkcontact.NewGetUserReqBuilder(). + UserId(uid). + UserIdType(userIDType). + Build()) + if err != nil { + return err + } + if !resp.Success() { + return fmt.Errorf("飞书返回错误: code=%d msg=%s", resp.Code, resp.Msg) + } + if resp.Data == nil || resp.Data.User == nil { + return nil + } + u := resp.Data.User + if u.Status != nil && u.Status.IsResigned != nil && *u.Status.IsResigned { + got = &Resigned{UnionID: uid, UserID: deref(u.UserId), Name: deref(u.Name)} + } + return nil + }) + if err != nil { + return nil, err + } + if got != nil { + out = append(out, got) + } + } + return out, nil +} + +// LoginName 导出登录名(企业邮箱 @ 前缀)。 +func LoginName(u *User) (string, bool) { return loginName(u) } + +// DisplayName 导出用于展示的名字,找不到时回落到登录名。 +func DisplayName(u *User) string { return displayName(u) } + +// loginName 从企业邮箱推导登录名。 +// +// 刻意不使用个人邮箱兜底:企业邮箱是组织内唯一且受控的标识, +// 回落到个人邮箱会产生不可预期、且在离职后仍可用的登录名。 +func loginName(u *User) (string, bool) { + email := strings.TrimSpace(u.EnterpriseEmail) + if email == "" { + return "", false + } + at := strings.Index(email, "@") + if at <= 0 { + return "", false + } + name := strings.TrimSpace(email[:at]) + if name == "" { + return "", false + } + return strings.ToLower(name), true +} + +func displayName(u *User) string { + if strings.TrimSpace(u.Name) != "" { + return u.Name + } + return u.EnName +} + +func loginName2(u *User) string { + n, _ := loginName(u) + return n +} + +// filterDepartments 按配置裁剪部门集合。 +// +// 规则与旧实现保持一致:留空表示全量;列出的部门及其子部门被包含; +// 前缀 ^ 表示排除该部门及其子部门。 +func filterDepartments(all []*Department, want []string) []*Department { + if len(want) == 0 { + return all + } + + byID := make(map[string]*Department, len(all)) + children := make(map[string][]*Department) + for _, d := range all { + byID[d.ID] = d + children[d.ParentID] = append(children[d.ParentID], d) + } + + var include, exclude []string + for _, w := range want { + w = strings.TrimSpace(w) + if w == "" { + continue + } + if strings.HasPrefix(w, "^") { + exclude = append(exclude, strings.TrimPrefix(w, "^")) + } else { + include = append(include, w) + } + } + + keep := make(map[string]bool) + + var mark func(id string) + mark = func(id string) { + if keep[id] { + return + } + if d, ok := byID[id]; ok { + keep[d.ID] = true + } + for _, ch := range children[id] { + mark(ch.ID) + } + } + + var unmark func(id string) + unmark = func(id string) { + delete(keep, id) + for _, ch := range children[id] { + unmark(ch.ID) + } + } + + if len(include) > 0 { + for _, id := range include { + mark(id) + } + } else { + for _, d := range all { + keep[d.ID] = true + } + } + for _, id := range exclude { + unmark(id) + } + + out := make([]*Department, 0, len(all)) + for _, d := range all { + if keep[d.ID] { + out = append(out, d) + } + } + return out +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func derefDepts(in []string) []string { + if len(in) == 0 { + return nil + } + out := make([]string, 0, len(in)) + for _, v := range in { + if v != "" { + out = append(out, v) + } + } + return out +} + +// mergeUnique 合并两个字符串切片并去重,保持原有顺序。 +func mergeUnique(a, b []string) []string { + seen := make(map[string]bool, len(a)+len(b)) + out := make([]string, 0, len(a)+len(b)) + for _, list := range [][]string{a, b} { + for _, v := range list { + if v == "" || seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + } + return out +} + +func parseInt(s *string) (int, error) { + var n int + _, err := fmt.Sscanf(deref(s), "%d", &n) + return n, err +} diff --git a/internal/ldaplib/client.go b/internal/ldaplib/client.go new file mode 100644 index 0000000..df9116c --- /dev/null +++ b/internal/ldaplib/client.go @@ -0,0 +1,135 @@ +package ldaplib + +import ( + "crypto/tls" + "fmt" + "log/slog" + "net" + "time" + + "github.com/go-ldap/ldap/v3" + + "idsync/internal/config" +) + +// Client 封装与 OpenLDAP 的连接。它刻意不使用连接池:同步任务的并发很低, +// 且池化连接在长时间空闲后容易变成半开状态,反而制造难以诊断的失败。 +// 每次操作新建连接、用完即关,代价是一个 TCP 往返,换取确定性。 +type Client struct { + cfg config.Ldap + log *slog.Logger + tlsConf *tls.Config + dialOpts []ldap.DialOpt + // group 是分组条目的形态,取自配置,必须与 Authelia 的 groups_filter 匹配。 + group GroupSchema +} + +// NewClient 构造 LDAP 客户端,并解析出拨号参数。 +func NewClient(cfg config.Ldap, log *slog.Logger) (*Client, error) { + if cfg.URL == "" { + return nil, fmt.Errorf("ldap.url 未配置") + } + + dialOpts := []ldap.DialOpt{ + ldap.DialWithDialer(&net.Dialer{Timeout: time.Duration(cfg.TimeoutSec) * time.Second}), + } + + var tlsConf *tls.Config + if cfg.InsecureSkipVerify || hasLDAPS(cfg.URL) || cfg.StartTLS { + tlsConf = &tls.Config{ + ServerName: serverName(cfg.URL), + InsecureSkipVerify: cfg.InsecureSkipVerify, //nolint:gosec // 由配置显式开启,仅供自签证书环境 + } + dialOpts = append(dialOpts, ldap.DialWithTLSConfig(tlsConf)) + } + + schema := GroupSchema{ + ObjectClass: cfg.GroupObjectClass, + MemberAttr: cfg.GroupMemberAttr, + } + if schema.ObjectClass == "" { + schema.ObjectClass = "groupOfUniqueNames" + } + if schema.MemberAttr == "" { + schema.MemberAttr = "uniqueMember" + } + + return &Client{cfg: cfg, log: log, tlsConf: tlsConf, dialOpts: dialOpts, group: schema}, nil +} + +// Conn 是一次性的 LDAP 会话。务必用 defer c.Close()。 +type Conn struct { + conn *ldap.Conn + cfg config.Ldap + group GroupSchema +} + +// Close 释放连接。 +func (c *Conn) Close() { + if c.conn != nil { + _ = c.conn.Close() + } +} + +// Dial 建立并绑定一个新的 LDAP 连接。 +func (c *Client) Dial() (*Conn, error) { + conn, err := ldap.DialURL(c.cfg.URL, c.dialOpts...) + if err != nil { + return nil, fmt.Errorf("连接 LDAP 失败 (%s): %w", c.cfg.URL, err) + } + + if c.cfg.StartTLS && !hasLDAPS(c.cfg.URL) { + if err := conn.StartTLS(c.tlsConf); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("StartTLS 失败: %w", err) + } + } + + if err := conn.Bind(c.cfg.AdminDN, c.cfg.AdminPass); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("绑定 LDAP 管理员失败 (%s): %w", c.cfg.AdminDN, err) + } + + return &Conn{conn: conn, cfg: c.cfg, group: c.group}, nil +} + +// Ping 验证连通性与凭据,用于健康检查。 +func (c *Client) Ping() error { + conn, err := c.Dial() + if err != nil { + return err + } + defer conn.Close() + return nil +} + +func hasLDAPS(url string) bool { + return len(url) >= 8 && url[:8] == "ldaps://" +} + +// serverName 从 URL 中提取主机名,供 TLS SNI 与证书校验使用。 +func serverName(rawURL string) string { + host := rawURL + for _, prefix := range []string{"ldaps://", "ldap://"} { + if len(host) >= len(prefix) && host[:len(prefix)] == prefix { + host = host[len(prefix):] + break + } + } + if i := lastIndexByte(host, '/'); i >= 0 { + host = host[:i] + } + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + return host +} + +func lastIndexByte(s string, b byte) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == b { + return i + } + } + return -1 +} diff --git a/internal/ldaplib/integration_test.go b/internal/ldaplib/integration_test.go new file mode 100644 index 0000000..836da14 --- /dev/null +++ b/internal/ldaplib/integration_test.go @@ -0,0 +1,377 @@ +//go:build integration + +// 集成测试:对真实的 OpenLDAP 实例验证写入路径。 +// +// 默认不参与 `go test ./...`,需要显式启用: +// +// LDAP_TEST_URL=ldap://127.0.0.1:3899 \ +// go test -tags=integration ./internal/ldaplib/ -v +// +// 这些测试覆盖单元测试无法保证的部分:DN 转义、objectClass 约束、 +// modrdn 移动后密码是否保留、以及“更新绝不触碰 userPassword”这一硬约束。 +package ldaplib_test + +import ( + "io" + "log/slog" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/go-ldap/ldap/v3" + + "idsync/internal/config" + "idsync/internal/ldaplib" + "idsync/internal/password" +) + +// discardLogger 返回一个丢弃全部输出的 logger,让集成测试保持安静。 +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func testClient(t *testing.T) *ldaplib.Client { + t.Helper() + + url := os.Getenv("LDAP_TEST_URL") + if url == "" { + url = "ldap://127.0.0.1:3899" + } + baseDN := os.Getenv("LDAP_TEST_BASE_DN") + if baseDN == "" { + baseDN = "dc=example,dc=com" + } + + cfg := config.Ldap{ + URL: url, + BaseDN: baseDN, + AdminDN: "cn=admin," + baseDN, + AdminPass: "adminpass", + GroupObjectClass: os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS"), + TimeoutSec: 10, + } + if err := cfg.NormalizeForTest(); err != nil { + t.Fatalf("配置规范化失败: %v", err) + } + + c, err := ldaplib.NewClient(cfg, discardLogger()) + if err != nil { + t.Fatalf("构造客户端失败: %v", err) + } + if err := c.Ping(); err != nil { + t.Skipf("LDAP 不可用,跳过集成测试: %v", err) + } + return c +} + +func baseDN() string { + if v := os.Getenv("LDAP_TEST_BASE_DN"); v != "" { + return v + } + return "dc=example,dc=com" +} + +// TestIntegrationUserLifecycle 覆盖用户创建 → 更新 → 移动 → 删除的完整生命周期, +// 并验证密码在属性更新与 modrdn 移动后依然有效。 +func TestIntegrationUserLifecycle(t *testing.T) { + c := testClient(t) + base := baseDN() + peopleOU := "ou=people," + base + inactiveOU := "ou=Inactive," + base + + conn, err := c.Dial() + if err != nil { + t.Fatalf("连接失败: %v", err) + } + defer conn.Close() + + if err := conn.EnsureOU(peopleOU, "people"); err != nil { + t.Fatalf("创建 ou=people 失败: %v", err) + } + if err := conn.EnsureOU(inactiveOU, "Inactive"); err != nil { + t.Fatalf("创建 ou=Inactive 失败: %v", err) + } + + const uid = "idsync-test-user" + userDN := "uid=" + uid + "," + peopleOU + initialPass := "InitialPass123!" + + // 清理可能残留的条目,让测试可重复执行。 + _ = conn.DeleteUser(userDN) + _ = conn.DeleteUser("uid=" + uid + "," + inactiveOU) + + u := &ldaplib.User{ + UID: uid, CN: uid, SN: "测试", GivenName: "Test", + DisplayName: "测试用户", Mail: "test@example.com", + Mobile: "13800000000", EmployeeNumber: "E001", + DepartmentNumber: "工程师", PostalAddress: "北京", + BusinessCategory: "研发部", EmployeeType: "Active", + Description: "测试用户", + } + + // 断言密码以哈希形式存储,而不是明文。 + // slapd 不会自动哈希 Modify 写入的 userPassword,若不自行编码, + // 密码会以明文形式留在目录中。 + encoded, err := password.EncodeSSHA(initialPass) + if err != nil { + t.Fatalf("编码密码失败: %v", err) + } + if err := conn.AddUser(u, peopleOU, encoded); err != nil { + t.Fatalf("创建用户失败: %v", err) + } + + raw, err := conn.GetEntry(userDN, []string{"userPassword"}) + if err != nil { + t.Fatalf("读取 userPassword 失败: %v", err) + } + stored := raw.Get("userPassword") + if stored == initialPass { + t.Errorf("userPassword 以明文存储,存在严重安全隐患") + } + if !strings.HasPrefix(stored, "{SSHA}") { + t.Errorf("userPassword 应为 {SSHA} 形式,实际: %q", stored) + } + + // 创建后应当能读到,且属性正确。 + users, err := conn.ListUsers(peopleOU) + if err != nil { + t.Fatalf("读取用户列表失败: %v", err) + } + got, ok := users[uid] + if !ok { + t.Fatalf("创建后未在 %s 中找到 %s", peopleOU, uid) + } + if got.Get(ldaplib.AttrMail) != "test@example.com" { + t.Errorf("mail 属性 = %q, 期望 test@example.com", got.Get(ldaplib.AttrMail)) + } + + // 用用户凭据 bind,确认密码确实生效。 + if err := bindAs(t, c, userDN, initialPass); err != nil { + t.Fatalf("初始密码无法通过 bind 验证: %v", err) + } + + // 更新属性:改邮箱与手机号。 + u.Mail = "test.new@example.com" + u.Mobile = "13900000000" + existing, err := conn.GetEntry(userDN, ldaplib.UserReadAttrs()) + if err != nil { + t.Fatalf("读取现状失败: %v", err) + } + if err := conn.UpdateUser(userDN, u, existing); err != nil { + t.Fatalf("更新用户失败: %v", err) + } + + // 关键回归:把若干可选字段置空后更新,不应失败。 + // 缺失的空值走的是 Delete 分支,而对不存在的属性做 Delete 会让 + // 整个 Modify 请求失败——这是真实踩到过的 bug。 + u.PostalAddress = "" + u.EmployeeNumber = "" + existing, _ = conn.GetEntry(userDN, ldaplib.UserReadAttrs()) + if err := conn.UpdateUser(userDN, u, existing); err != nil { + t.Fatalf("清空可选字段后更新失败(Delete 不存在的属性会中断整个请求): %v", err) + } + + users, err = conn.ListUsers(peopleOU) + if err != nil { + t.Fatalf("更新后读取失败: %v", err) + } + got = users[uid] + if got.Get(ldaplib.AttrMail) != "test.new@example.com" { + t.Errorf("更新后 mail = %q, 期望 test.new@example.com", got.Get(ldaplib.AttrMail)) + } + + // 关键约束:属性更新绝不能改写密码。 + if err := bindAs(t, c, userDN, initialPass); err != nil { + t.Fatalf("属性更新后密码失效(说明更新路径误写了 userPassword): %v", err) + } + + // 移到离职 OU:密码必须随条目保留。 + if err := conn.MoveUser(userDN, "uid="+uid, inactiveOU); err != nil { + t.Fatalf("移动用户失败: %v", err) + } + movedDN := "uid=" + uid + "," + inactiveOU + if err := bindAs(t, c, movedDN, initialPass); err != nil { + t.Fatalf("移动后密码失效(modrdn 未保留属性): %v", err) + } + + inactive, err := conn.ListUsers(inactiveOU) + if err != nil { + t.Fatalf("读取离职 OU 失败: %v", err) + } + if _, ok := inactive[uid]; !ok { + t.Errorf("移动后未在 %s 中找到 %s", inactiveOU, uid) + } + + // 标记离职状态 + if err := conn.SetEmployeeType(movedDN, "Resigned"); err != nil { + t.Fatalf("设置 employeeType 失败: %v", err) + } + + // 移回在职 OU + if err := conn.MoveUser(movedDN, "uid="+uid, peopleOU); err != nil { + t.Fatalf("移回失败: %v", err) + } + if err := bindAs(t, c, userDN, initialPass); err != nil { + t.Fatalf("移回后密码失效: %v", err) + } + + // 清理 + if err := conn.DeleteUser(userDN); err != nil { + t.Fatalf("删除用户失败: %v", err) + } +} + +// TestIntegrationGroupMembership 验证分组的创建与成员增删。 +func TestIntegrationGroupMembership(t *testing.T) { + c := testClient(t) + base := baseDN() + peopleOU := "ou=people," + base + groupRoot := "ou=feishuroot," + base + + conn, err := c.Dial() + if err != nil { + t.Fatalf("连接失败: %v", err) + } + defer conn.Close() + + if err := conn.EnsureOU(peopleOU, "people"); err != nil { + t.Fatalf("创建 ou=people 失败: %v", err) + } + if err := conn.EnsureOU(groupRoot, "feishuroot"); err != nil { + t.Fatalf("创建分组根 OU 失败: %v", err) + } + + const uid = "idsync-test-member" + userDN := "uid=" + uid + "," + peopleOU + groupDN := "cn=yanfa," + groupRoot + adminDN := "cn=admin," + base + + _ = conn.DeleteUser(userDN) + _ = conn.DeleteGroup(groupDN) + + u := &ldaplib.User{ + UID: uid, CN: uid, SN: "成员", DisplayName: "成员", + Mail: "member@example.com", EmployeeType: "Active", + } + if err := conn.AddUser(u, peopleOU, "MemberPass123!"); err != nil { + t.Fatalf("创建用户失败: %v", err) + } + + if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil { + t.Fatalf("创建分组失败: %v", err) + } + + // 幂等性:重复创建同一个组不应报错。 + if err := conn.AddGroup(groupDN, "yanfa", "研发部", adminDN); err != nil { + t.Errorf("重复创建分组应当幂等,却返回错误: %v", err) + } + + groups, err := conn.ListGroups(groupRoot) + if err != nil { + t.Fatalf("读取分组失败: %v", err) + } + g, ok := groups[groupDN] + if !ok { + t.Fatalf("未找到刚创建的分组 %s", groupDN) + } + // 创建分组时应自动包含 admin,否则 groupOfUniqueNames 无法创建。 + if _, has := g.Members()[adminDN]; !has { + t.Errorf("分组缺少 admin 成员: %v", g.Members()) + } + + // 加入成员,并验证幂等。 + if err := conn.AddMember(groupDN, userDN); err != nil { + t.Fatalf("加入成员失败: %v", err) + } + if err := conn.AddMember(groupDN, userDN); err != nil { + t.Errorf("重复加入成员应当幂等,却返回错误: %v", err) + } + + groups, err = conn.ListGroups(groupRoot) + if err != nil { + t.Fatalf("重新读取分组失败: %v", err) + } + if _, has := groups[groupDN].Members()[userDN]; !has { + t.Errorf("成员未被加入: %v", groups[groupDN].Members()) + } + + // 移出成员,并验证幂等。 + if err := conn.RemoveMember(groupDN, userDN); err != nil { + t.Fatalf("移出成员失败: %v", err) + } + if err := conn.RemoveMember(groupDN, userDN); err != nil { + t.Errorf("重复移出成员应当幂等,却返回错误: %v", err) + } + + // 清理 + _ = conn.DeleteUser(userDN) + _ = conn.DeleteGroup(groupDN) +} + +// TestIntegrationEscaping 验证 uid 含特殊字符时 DN 与过滤器构造正确。 +func TestIntegrationEscaping(t *testing.T) { + c := testClient(t) + base := baseDN() + peopleOU := "ou=people," + base + + conn, err := c.Dial() + if err != nil { + t.Fatalf("连接失败: %v", err) + } + defer conn.Close() + + if err := conn.EnsureOU(peopleOU, "people"); err != nil { + t.Fatalf("创建 ou=people 失败: %v", err) + } + + // 这些字符在 DN 中具有特殊含义,必须转义后才能安全使用。 + cases := []string{ + "zhang.san", + "li_si", + "wang+wu", + "zhao,liu", + } + + for _, uid := range cases { + t.Run(uid, func(t *testing.T) { + dn := ldaplib.UserDN(uid, peopleOU) + _ = conn.DeleteUser(dn) + defer func() { _ = conn.DeleteUser(dn) }() + + u := &ldaplib.User{ + UID: uid, CN: uid, SN: uid, DisplayName: uid, + Mail: uid + "@example.com", EmployeeType: "Active", + } + if err := conn.AddUser(u, peopleOU, "Pass123!x"); err != nil { + t.Fatalf("创建含特殊字符的 uid 失败: %v", err) + } + + users, err := conn.ListUsers(peopleOU) + if err != nil { + t.Fatalf("读取失败: %v", err) + } + if _, ok := users[uid]; !ok { + t.Errorf("未找到 uid=%s(DN 转义可能有问题)", uid) + } + }) + } +} + +// bindAs 用给定 DN 与密码建立一次连接,用于验证密码有效性。 +func bindAs(t *testing.T, c *ldaplib.Client, dn, password string) error { + t.Helper() + + cfgURL := os.Getenv("LDAP_TEST_URL") + if cfgURL == "" { + cfgURL = "ldap://127.0.0.1:3899" + } + + conn, err := ldap.DialURL(cfgURL, ldap.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second})) + if err != nil { + return err + } + defer conn.Close() + return conn.Bind(dn, password) +} diff --git a/internal/ldaplib/ops.go b/internal/ldaplib/ops.go new file mode 100644 index 0000000..dcad366 --- /dev/null +++ b/internal/ldaplib/ops.go @@ -0,0 +1,342 @@ +package ldaplib + +import ( + "fmt" + "strings" + + "github.com/go-ldap/ldap/v3" +) + +// 用户与分组的属性清单集中在此,避免散落各处导致不一致。 +const ( + AttrObjectClass = "objectClass" + AttrUID = "uid" + AttrCN = "cn" + AttrSN = "sn" + AttrGivenName = "givenName" + AttrDisplayName = "displayName" + AttrMail = "mail" + AttrMobile = "mobile" + AttrEmployeeNumber = "employeeNumber" + AttrDepartmentNumber = "departmentNumber" + AttrPostalAddress = "postalAddress" + AttrBusinessCategory = "businessCategory" + AttrEmployeeType = "employeeType" + AttrDescription = "description" + AttrUserPassword = "userPassword" + AttrUniqueMember = "uniqueMember" + AttrMember = "member" +) + +// memberAttrs 列出可能承载组成员关系的属性名。 +// +// 读取时两者都查:用哪一种取决于目录的既有约定(groupOfNames 用 member, +// groupOfUniqueNames 用 uniqueMember)。只认一种会漏掉另一种的成员关系, +// 且不会报错,只会表现为「成员比对结果不对」。 +var memberAttrs = []string{AttrMember, AttrUniqueMember} + +// ObjectClassUser 等是写入条目时使用的 objectClass。 +// 分组的 objectClass 由 GroupSchema 在运行期决定,见下。 +var ( + ObjectClassUser = []string{"inetOrgPerson"} + ObjectClassOU = []string{"organizationalUnit", "top"} +) + +// GroupSchema 描述分组条目的形态,必须与 Authelia 的 groups_filter 一致。 +type GroupSchema struct { + // ObjectClass 形如 groupOfUniqueNames 或 groupOfNames + ObjectClass string + // MemberAttr 形如 uniqueMember 或 member + MemberAttr string +} + +// ObjectClasses 返回写入时使用的 objectClass 列表。 +func (g GroupSchema) ObjectClasses() []string { + return []string{g.ObjectClass, "top"} +} + +// mutableUserAttrs 是更新用户时允许改写的属性。 +// +// 这里刻意不包含 userPassword:Authelia 重置密码时直接写 LDAP 的 userPassword, +// 若同步的更新路径也写该属性,就会把用户刚设好的密码打回初始值。 +// 密码只在创建时写入一次,此后由 Authelia 全权负责。 +var mutableUserAttrs = []string{ + AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail, AttrMobile, + AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress, + AttrBusinessCategory, AttrEmployeeType, AttrDescription, +} + +// escapeDN 转义 DN 中的特殊字符,避免 uid 含 . 、+ 、_ 等字符时构造出非法 DN。 +func escapeDN(s string) string { return ldap.EscapeDN(s) } + +// EscapeDN 导出 DN 转义,供调用方拼装 RDN 时使用。 +func EscapeDN(s string) string { return ldap.EscapeDN(s) } + +// EscapeFilter 导出过滤器转义。 +func EscapeFilter(s string) string { return ldap.EscapeFilter(s) } + +// UserDN 按 uid 与所属 OU 拼接用户 DN。 +func UserDN(uid, ouDN string) string { + return "uid=" + escapeDN(uid) + "," + ouDN +} + +// GroupDN 拼接部门组 DN。 +func GroupDN(cn, parentDN string) string { + return "cn=" + escapeDN(cn) + "," + parentDN +} + +// User 是写入 LDAP 的用户条目。 +type User struct { + UID string + CN string + SN string + GivenName string + DisplayName string + Mail string + Mobile string + EmployeeNumber string + DepartmentNumber string + PostalAddress string + BusinessCategory string + EmployeeType string + Description string +} + +// Attributes 返回该用户对应的 LDAP 属性集合(不含 userPassword)。 +func (u *User) Attributes() map[string][]string { + return map[string][]string{ + AttrCN: {u.CN}, + AttrSN: {u.SN}, + AttrGivenName: {u.GivenName}, + AttrDisplayName: {u.DisplayName}, + AttrMail: {u.Mail}, + AttrMobile: {u.Mobile}, + AttrEmployeeNumber: {u.EmployeeNumber}, + AttrDepartmentNumber: {u.DepartmentNumber}, + AttrPostalAddress: {u.PostalAddress}, + AttrBusinessCategory: {u.BusinessCategory}, + AttrEmployeeType: {u.EmployeeType}, + AttrDescription: {u.Description}, + } +} + +// AddUser 创建用户条目。password 仅在此时写入。 +func (c *Conn) AddUser(u *User, ouDN, password string) error { + add := ldap.NewAddRequest(UserDN(u.UID, ouDN), nil) + add.Attribute(AttrObjectClass, ObjectClassUser) + add.Attribute(AttrUID, []string{u.UID}) + + for attr, values := range u.Attributes() { + if len(values) == 1 && values[0] == "" { + continue // 空值不写,避免产生空属性 + } + add.Attribute(attr, values) + } + + add.Attribute(AttrUserPassword, []string{password}) + + if err := c.conn.Add(add); err != nil { + return fmt.Errorf("创建用户 %s 失败: %w", u.UID, err) + } + return nil +} + +// UpdateUser 更新用户属性。绝不触碰 userPassword。 +// +// existing 是条目当前的内容,用于判断哪些属性需要删除。 +// 这个参数不是可有可无的:对**不存在**的属性执行 Delete 会让整个 Modify +// 请求以 LDAPResultNoSuchAttribute 失败,从而使该用户的全部属性更新回滚。 +// 早期版本没有传 existing,任何缺少可空字段(如工位为空)的用户都会更新失败。 +func (c *Conn) UpdateUser(dn string, u *User, existing *Entry) error { + modify := ldap.NewModifyRequest(dn, nil) + + attrs := u.Attributes() + for _, attr := range mutableUserAttrs { + values := attrs[attr] + if len(values) == 0 || values[0] == "" { + // 上游无此值:仅当条目当前确实存在该属性时才删除, + // 否则跳过,避免整个请求失败。 + if existing != nil && existing.Get(attr) != "" { + modify.Delete(attr, nil) + } + continue + } + if existing != nil && existing.Get(attr) == values[0] { + continue // 值未变化,不产生多余的 LDAP 写操作 + } + modify.Replace(attr, values) + } + + if len(modify.Changes) == 0 { + return nil // 没有实际变化,避免发出空 Modify + } + + // 结构上的保险:即使将来有人误把 userPassword 加进 mutableUserAttrs,这里也会拦下。 + for _, ch := range modify.Changes { + if strings.EqualFold(ch.Modification.Type, AttrUserPassword) { + return fmt.Errorf("内部错误: 更新路径不允许修改 %s", AttrUserPassword) + } + } + + if err := c.conn.Modify(modify); err != nil { + return fmt.Errorf("更新用户 %s 失败: %w", dn, err) + } + return nil +} + +// GetEntry 读取单个条目的属性,供更新前的差异判断使用。 +func (c *Conn) GetEntry(dn string, attrs []string) (*Entry, error) { + sr, err := c.conn.Search(ldap.NewSearchRequest( + dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, + "(objectClass=*)", attrs, nil, + )) + if err != nil { + return nil, fmt.Errorf("读取条目 %s 失败: %w", dn, err) + } + if len(sr.Entries) == 0 { + return nil, fmt.Errorf("条目不存在: %s", dn) + } + return entryFrom(sr.Entries[0]), nil +} + +// UserReadAttrs 返回读取用户条目所需的属性列表。 +func UserReadAttrs() []string { return append([]string{}, userReadAttrs...) } + +// DeleteUser 删除用户条目。 +func (c *Conn) DeleteUser(dn string) error { + if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil { + return fmt.Errorf("删除用户 %s 失败: %w", dn, err) + } + return nil +} + +// MoveUser 把用户条目移动到新的父 OU,保留其全部属性(含 userPassword)。 +// deleteOldRDN 置 true 表示移除旧 RDN 属性值。 +func (c *Conn) MoveUser(oldDN, newRDN, newParentDN string) error { + modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, newParentDN) + if err := c.conn.ModifyDN(modDN); err != nil { + return fmt.Errorf("移动用户 %s → %s,%s 失败: %w", oldDN, newRDN, newParentDN, err) + } + return nil +} + +// AddGroup 创建部门分组。 +// +// 无论哪种 objectClass,成员属性都是必填(groupOfNames 的 member 与 +// groupOfUniqueNames 的 uniqueMember 都是 MUST),因此这里默认把 admin +// 加入其中,否则创建会因缺少必填属性而失败。 +func (c *Conn) AddGroup(dn, name, description, adminDN string) error { + if _, err := c.conn.Search(ldap.NewSearchRequest( + dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, + "(objectClass=*)", []string{"dn"}, nil, + )); err == nil { + return nil // 已存在,幂等 + } else if !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return fmt.Errorf("检查分组 %s 是否存在失败: %w", dn, err) + } + + add := ldap.NewAddRequest(dn, nil) + add.Attribute(AttrObjectClass, c.group.ObjectClasses()) + add.Attribute(AttrCN, []string{name}) + add.Attribute(AttrDescription, []string{description}) + add.Attribute(c.group.MemberAttr, []string{adminDN}) + + if err := c.conn.Add(add); err != nil { + return fmt.Errorf("创建分组 %s 失败: %w", dn, err) + } + return nil +} + +// UpdateGroup 更新分组的描述。 +func (c *Conn) UpdateGroup(dn, description string) error { + modify := ldap.NewModifyRequest(dn, nil) + modify.Replace(AttrDescription, []string{description}) + if err := c.conn.Modify(modify); err != nil { + return fmt.Errorf("更新分组 %s 失败: %w", dn, err) + } + return nil +} + +// RenameGroup 重命名分组,用于部门改名。 +func (c *Conn) RenameGroup(oldDN, newRDN string) error { + modDN := ldap.NewModifyDNRequest(oldDN, newRDN, true, "") + if err := c.conn.ModifyDN(modDN); err != nil { + return fmt.Errorf("重命名分组 %s → %s 失败: %w", oldDN, newRDN, err) + } + return nil +} + +// DeleteGroup 删除分组条目。 +func (c *Conn) DeleteGroup(dn string) error { + if err := c.conn.Del(ldap.NewDelRequest(dn, nil)); err != nil { + return fmt.Errorf("删除分组 %s 失败: %w", dn, err) + } + return nil +} + +// SetEmployeeType 更新条目的在职状态标记(Active / Resigned)。 +func (c *Conn) SetEmployeeType(dn, employeeType string) error { + modify := ldap.NewModifyRequest(dn, nil) + modify.Replace(AttrEmployeeType, []string{employeeType}) + if err := c.conn.Modify(modify); err != nil { + return fmt.Errorf("更新 %s 的 employeeType 失败: %w", dn, err) + } + return nil +} + +// memberExists 判断用户是否已是分组成员。 +func (c *Conn) memberExists(groupDN, userDN string) (bool, error) { + filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))", + c.group.ObjectClass, c.group.MemberAttr, EscapeFilter(userDN)) + + sr, err := c.conn.Search(ldap.NewSearchRequest( + groupDN, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, + filter, []string{"dn"}, nil, + )) + if err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return false, fmt.Errorf("分组不存在: %s", groupDN) + } + return false, fmt.Errorf("查询分组 %s 成员失败: %w", groupDN, err) + } + return len(sr.Entries) > 0, nil +} + +// AddMember 把用户加入分组。已存在时是幂等的。 +func (c *Conn) AddMember(groupDN, userDN string) error { + found, err := c.memberExists(groupDN, userDN) + if err != nil { + return err + } + if found { + return nil + } + + modify := ldap.NewModifyRequest(groupDN, nil) + modify.Add(c.group.MemberAttr, []string{userDN}) + if err := c.conn.Modify(modify); err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultAttributeOrValueExists) { + return nil // 并发下可能已被加入 + } + return fmt.Errorf("将 %s 加入分组 %s 失败: %w", userDN, groupDN, err) + } + return nil +} + +// RemoveMember 把用户移出分组。不存在时是幂等的。 +func (c *Conn) RemoveMember(groupDN, userDN string) error { + found, err := c.memberExists(groupDN, userDN) + if err != nil { + return err + } + if !found { + return nil + } + + modify := ldap.NewModifyRequest(groupDN, nil) + modify.Delete(c.group.MemberAttr, []string{userDN}) + if err := c.conn.Modify(modify); err != nil { + return fmt.Errorf("将 %s 移出分组 %s 失败: %w", userDN, groupDN, err) + } + return nil +} diff --git a/internal/ldaplib/read.go b/internal/ldaplib/read.go new file mode 100644 index 0000000..eb61ba7 --- /dev/null +++ b/internal/ldaplib/read.go @@ -0,0 +1,184 @@ +package ldaplib + +import ( + "fmt" + "strings" + + "github.com/go-ldap/ldap/v3" +) + +// Entry 是 LDAP 中一个条目的精简表示,用于与期望状态比对。 +type Entry struct { + DN string + Attributes map[string]string + // multi 保留多值属性的完整值列表(如 uniqueMember),键为小写属性名。 + multi map[string][]string +} + +// Get 返回指定属性的第一个值。 +func (e *Entry) Get(attr string) string { + return e.Attributes[strings.ToLower(attr)] +} + +// NewEntry 构造一个 Entry。 +// +// Entry 的字段对调用方是只读的(多值属性不导出),但同步逻辑与测试都需要 +// 在内存中构造它,因此这里提供一个显式构造函数,而不是让调用方猜测内部结构。 +func NewEntry(dn string, attrs map[string]string, members []string) *Entry { + e := &Entry{ + DN: dn, + Attributes: make(map[string]string, len(attrs)), + multi: make(map[string][]string), + } + for k, v := range attrs { + e.Attributes[strings.ToLower(k)] = v + } + if len(members) > 0 { + e.multi[strings.ToLower(AttrUniqueMember)] = append([]string{}, members...) + e.Attributes[strings.ToLower(AttrUniqueMember)] = members[0] + } + return e +} + +// userReadAttrs 是读取用户条目时需要取回的属性。 +// 刻意不读 userPassword:同步永远不需要比对或改写密码。 +var userReadAttrs = []string{ + AttrUID, AttrCN, AttrSN, AttrGivenName, AttrDisplayName, AttrMail, + AttrMobile, AttrEmployeeNumber, AttrDepartmentNumber, AttrPostalAddress, + AttrBusinessCategory, AttrEmployeeType, AttrDescription, +} + +// groupReadAttrs 读取分组时额外取回成员列表,以便检测成员漂移。 +// groupReadAttrs 读取分组时同时请求两种成员属性与 objectClass。 +// 缺省的属性不会报错,只是不返回,因此同时请求是安全的。 +var groupReadAttrs = []string{AttrCN, AttrDescription, AttrObjectClass, AttrMember, AttrUniqueMember} + +// ListUsers 读取某个 OU 下的全部用户条目(不递归)。 +func (c *Conn) ListUsers(ouDN string) (map[string]*Entry, error) { + sr, err := c.conn.Search(ldap.NewSearchRequest( + ouDN, ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false, + "(objectClass=inetOrgPerson)", userReadAttrs, nil, + )) + if err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return map[string]*Entry{}, nil // OU 尚未创建,视为空 + } + return nil, fmt.Errorf("读取用户列表失败 (%s): %w", ouDN, err) + } + + out := make(map[string]*Entry, len(sr.Entries)) + for _, e := range sr.Entries { + out[strings.ToLower(e.GetAttributeValue(AttrUID))] = entryFrom(e) + } + return out, nil +} + +// ListGroups 读取指定子树下的全部分组条目,按 DN 小写为键。 +func (c *Conn) ListGroups(rootDN string) (map[string]*Entry, error) { + sr, err := c.conn.Search(ldap.NewSearchRequest( + rootDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(objectClass=%s)", c.group.ObjectClass), groupReadAttrs, nil, + )) + if err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return map[string]*Entry{}, nil + } + return nil, fmt.Errorf("读取分组列表失败 (%s): %w", rootDN, err) + } + + out := make(map[string]*Entry, len(sr.Entries)) + for _, e := range sr.Entries { + out[strings.ToLower(e.DN)] = entryFrom(e) + } + return out, nil +} + +// Members 返回分组条目的成员 DN 集合(全小写,便于比较)。 +// +// 两种成员属性都识别:目录可能用 groupOfNames/member,也可能用 +// groupOfUniqueNames/uniqueMember,取决于既有约定。写操作会使用配置指定的那一种, +// 但读取必须宽松,否则会漏掉另一类成员关系。 +func (e *Entry) Members() map[string]struct{} { + out := make(map[string]struct{}) + for _, attr := range memberAttrs { + for _, m := range e.multi[strings.ToLower(attr)] { + out[strings.ToLower(m)] = struct{}{} + } + } + return out +} + +// ExistsDN 判断某个 DN 是否存在,用于幂等前置检查。 +func (c *Conn) ExistsDN(dn string) (bool, error) { + sr, err := c.conn.Search(ldap.NewSearchRequest( + dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 1, 0, false, + "(objectClass=*)", []string{"dn"}, nil, + )) + if err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return false, nil + } + return false, fmt.Errorf("检查 DN 是否存在失败 (%s): %w", dn, err) + } + return len(sr.Entries) > 0, nil +} + +// EnsureOU 确保某个 OU 存在,不存在则创建。用于首次同步时准备 ou=Inactive 等。 +func (c *Conn) EnsureOU(dn, name string) error { + exists, err := c.ExistsDN(dn) + if err != nil { + return err + } + if exists { + return nil + } + + add := ldap.NewAddRequest(dn, nil) + add.Attribute(AttrObjectClass, ObjectClassOU) + add.Attribute("ou", []string{name}) + if err := c.conn.Add(add); err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultEntryAlreadyExists) { + return nil + } + return fmt.Errorf("创建 OU %s 失败: %w", dn, err) + } + return nil +} + +// entryFrom 把 LDAP 条目转成精简表示。 +// 单值属性存进 Attributes;多值属性原样保留在 multi 中。 +func entryFrom(e *ldap.Entry) *Entry { + ent := &Entry{ + DN: e.DN, + Attributes: make(map[string]string, len(e.Attributes)), + multi: make(map[string][]string), + } + // multiValuedAttrs 记录必须按多值处理的属性。 + // 不能只凭「值个数 > 1」判断:一个只含 admin 的分组其 uniqueMember + // 只有一个值,若按单值属性存放,Members() 就会返回空集合,导致 + // 成员比对完全失效(集成测试捕获过这个问题)。 + multiValued := map[string]bool{ + strings.ToLower(AttrMember): true, + strings.ToLower(AttrUniqueMember): true, + } + + for _, a := range e.Attributes { + key := strings.ToLower(a.Name) + if multiValued[key] { + ent.multi[key] = append([]string{}, a.Values...) + if len(a.Values) > 0 { + ent.Attributes[key] = a.Values[0] + } + continue + } + if len(a.Values) == 1 { + ent.Attributes[key] = a.Values[0] + continue + } + ent.multi[key] = a.Values + if len(a.Values) > 0 { + ent.Attributes[key] = a.Values[0] + } + } + return ent +} diff --git a/internal/mapping/mapping.go b/internal/mapping/mapping.go new file mode 100644 index 0000000..0c169c2 --- /dev/null +++ b/internal/mapping/mapping.go @@ -0,0 +1,354 @@ +// Package mapping 把飞书快照转换为期望的 LDAP 状态。 +// +// 这一层是纯函数:不触碰 IO,不依赖配置以外的全局状态。DN 的构造规则 +// 集中在此,便于测试与审查——DN 出错会造成最难排查的故障。 +package mapping + +import ( + "fmt" + "sort" + "strings" + + "github.com/mozillazg/go-pinyin" + + "idsync/internal/feishu" + "idsync/internal/ldaplib" +) + +// 员工在职状态标记。 +// +// 定义在 mapping 而非 syncer:它是「期望状态」的一部分,需要参与差异比对, +// 因此必须与期望状态放在一起,否则会出现「写入了但预览里看不到」的不一致。 +const ( + EmployeeTypeActive = "Active" + EmployeeTypeResigned = "Resigned" +) + +// State 是期望写入 LDAP 的完整状态。 +type State struct { + Groups []*Group + Users []*User + // GroupByDeptID 便于按部门查组 + GroupByDeptID map[string]*Group + // Warnings 是映射过程中发现的问题,需要人工留意。 + Warnings []string +} + +// Group 是期望的分组。 +type Group struct { + DeptID string + DN string + Name string + ParentID string + // Depth 是部门在树中的层级,根为 0。用于保证父分组先于子分组创建。 + Depth int + // Members 是期望成员的用户 uid 集合 + Members map[string]struct{} +} + +// User 是期望的用户。 +type User struct { + UID string + DN string + InactiveDN string + CN string + SN string + GivenName string + DisplayName string + Mail string + Mobile string + EmployeeNumber string + DepartmentNumber string + PostalAddress string + BusinessCategory string + Description string + // EmployeeType 为 Active;离职条目由执行器单独标记为 Resigned。 + EmployeeType string + DeptIDs []string +} + +// BuildOptions 是构造期望状态所需的配置。 +type BuildOptions struct { + UserOUDN string + InactiveOUDN string + // GroupRootName 是部门树根 OU 的名称(不含 ou=) + GroupRootName string + GroupRootDN string + // CollisionSuffix 在登录名冲突时用于消歧,默认取员工工号或 union_id 片段 + Disambiguate func(u *feishu.User) string +} + +// Build 由飞书快照构造期望状态。 +func Build(snap *feishu.Snapshot, opt BuildOptions) *State { + st := &State{ + GroupByDeptID: make(map[string]*Group), + Warnings: make([]string, 0), + } + + // 部门 → 分组的 DN 需要自顶向下构造,先建索引再按层级解析。 + deptByID := make(map[string]*feishu.Department, len(snap.Departments)) + for _, d := range snap.Departments { + deptByID[d.ID] = d + } + + // 按层级排序,确保父部门先于子部门处理,从而能复用父的 DN。 + ordered := orderByDepth(snap.Departments, deptByID) + + for _, d := range ordered { + parentDN := opt.GroupRootDN + if d.ParentID != "" && d.ParentID != "0" { + switch pg, ok := st.GroupByDeptID[d.ParentID]; { + case ok: + parentDN = pg.DN + case deptByID[d.ParentID] != nil: + // 父部门在范围内但尚未处理:正常情况下不会发生, + // 因为 ordered 已保证父先于子。出现即说明建树有缺陷。 + st.Warnings = append(st.Warnings, + fmt.Sprintf("部门 %s(%s) 的父部门 %s 尚未处理,已跳过该部门", + d.Name, d.ID, d.ParentID)) + continue + default: + // 父部门完全不在本次同步范围内(例如 dept-list 配置不当, + // 或通讯录权限被收窄)。此时挂到分组根下,让它至少能被同步, + // 但必须显式告警——静默地把部门挂到错误层级比失败更难察觉。 + st.Warnings = append(st.Warnings, + fmt.Sprintf("部门 %s(%s) 的父部门 %s 不在同步范围内,已挂到 %s 下,请确认层级是否正确", + d.Name, d.ID, d.ParentID, opt.GroupRootDN)) + } + } + + name := cnName(d.Name, d.ID) + g := &Group{ + DeptID: d.ID, + DN: ldaplib.GroupDN(name, parentDN), + Name: d.Name, + ParentID: d.ParentID, + Depth: depthOf(d.ID, deptByID), + Members: make(map[string]struct{}), + } + st.Groups = append(st.Groups, g) + st.GroupByDeptID[d.ID] = g + } + + // 用户 + for _, u := range snap.Users { + uid, ok := feishu.LoginName(u) + if !ok { + continue // 已在快照的 SkippedNoEmail 中报告 + } + + du := &User{ + UID: uid, + DN: ldaplib.UserDN(uid, opt.UserOUDN), + InactiveDN: ldaplib.UserDN(uid, opt.InactiveOUDN), + CN: uid, + SN: firstName(u), + GivenName: firstNonEmpty(u.EnName, u.Name, uid), + DisplayName: firstNonEmpty(u.Name, u.EnName, uid), + Mail: u.EnterpriseEmail, + Mobile: u.Mobile, + EmployeeNumber: u.EmployeeNo, + DepartmentNumber: u.JobTitle, + PostalAddress: u.WorkStation, + BusinessCategory: strings.Join(deptNames(u.DepartmentIDs, deptByID), ","), + Description: firstNonEmpty(u.Name, u.EnName, uid), + EmployeeType: EmployeeTypeActive, + DeptIDs: u.DepartmentIDs, + } + st.Users = append(st.Users, du) + + // 维护成员关系:只把用户加入其实际所属且存在于本次同步范围的部门组。 + for _, deptID := range u.DepartmentIDs { + if g, ok := st.GroupByDeptID[deptID]; ok { + g.Members[du.DN] = struct{}{} + } + } + } + + // 关键:按层级排序,父分组必须排在子分组之前。 + // + // 不能用 DeptID 字母序:部门 ID 与层级无关,字母序会把子部门排到父部门 + // 前面,导致创建子分组时父分组尚不存在(LDAP 报 No Such Object)。 + // 这个问题在部门名恰好有序时才不会暴露,因此必须显式按深度排序。 + sort.SliceStable(st.Groups, func(i, j int) bool { + if st.Groups[i].Depth != st.Groups[j].Depth { + return st.Groups[i].Depth < st.Groups[j].Depth + } + return st.Groups[i].DeptID < st.Groups[j].DeptID + }) + sort.SliceStable(st.Users, func(i, j int) bool { + return st.Users[i].UID < st.Users[j].UID + }) + return st +} + +// depthOf 计算部门在树中的深度,根部门为 0。 +// +// 带有步数上限,防止数据异常(如父子互指形成环)时无限循环。 +func depthOf(id string, byID map[string]*feishu.Department) int { + const maxDepth = 64 + + depth := 0 + cur := id + for i := 0; i < maxDepth; i++ { + d, ok := byID[cur] + if !ok || d.ParentID == "" || d.ParentID == "0" { + return depth + } + if _, ok := byID[d.ParentID]; !ok { + return depth // 父不在同步范围内,视作根 + } + depth++ + cur = d.ParentID + } + return depth +} + +// cnName 为部门生成稳定的 CN。 +// +// 用拼音而非中文:中文作 RDN 在 DN 中需要转义为 #hex 形式,可读性差且 +// 在部分工具链下有兼容问题;拼音与现有目录的部门命名也保持一致。 +// 拼音为空(部门名为纯符号)时回落到部门 ID,保证 CN 非空且稳定。 +func cnName(deptName, deptID string) string { + name := toPinyin(deptName) + if name != "" { + return name + } + return "dept-" + deptID +} + +// toPinyin 把中文转为无音调拼音,保留 ASCII 与数字。 +func toPinyin(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + + // 已全是 ASCII 时直接使用,避免拼音库对英文做多余处理。 + if isASCII(s) { + return sanitizeCN(s) + } + + args := pinyin.NewArgs() + args.Style = pinyin.Normal + parts := pinyin.Pinyin(s, args) + + var b strings.Builder + for _, p := range parts { + if len(p) == 0 { + continue + } + b.WriteString(p[0]) + } + return sanitizeCN(b.String()) +} + +// sanitizeCN 清理 CN 中会导致 DN 书写困难的字符。 +func sanitizeCN(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_': + b.WriteRune(r) + case r == ' ' || r == '.': + // 空格与点号在 CN 中会造成歧义,转为下划线 + b.WriteRune('_') + case r > 127: + // 剩余的非 ASCII(拼音库未能转换的字符)丢弃 + default: + b.WriteRune(r) + } + } + return b.String() +} + +func isASCII(s string) bool { + for _, r := range s { + if r > 127 { + return false + } + } + return true +} + +// orderByDepth 返回按层级(父在子前)排序的部门列表。 +// +// 使用迭代式的层级展开,并对异常数据(环、悬挂父引用)做保护: +// 无法归入任何层级的部门会被追加到末尾,绝不死循环。 +func orderByDepth(all []*feishu.Department, byID map[string]*feishu.Department) []*feishu.Department { + children := make(map[string][]*feishu.Department) + roots := make([]*feishu.Department, 0) + for _, d := range all { + if d.ParentID == "" || d.ParentID == "0" { + roots = append(roots, d) + continue + } + if _, ok := byID[d.ParentID]; !ok { + roots = append(roots, d) // 父不在范围内,当根处理 + continue + } + children[d.ParentID] = append(children[d.ParentID], d) + } + + out := make([]*feishu.Department, 0, len(all)) + visited := make(map[string]bool, len(all)) + + var walk func(list []*feishu.Department) + walk = func(list []*feishu.Department) { + for _, d := range list { + if visited[d.ID] { + continue // 环保护 + } + visited[d.ID] = true + out = append(out, d) + walk(children[d.ID]) + } + } + walk(roots) + + // 未被访问到的(处在环中)按 ID 顺序补齐,避免静默丢部门。 + var rest []*feishu.Department + for _, d := range all { + if !visited[d.ID] { + rest = append(rest, d) + } + } + sort.SliceStable(rest, func(i, j int) bool { return rest[i].ID < rest[j].ID }) + out = append(out, rest...) + + return out +} + +func deptNames(ids []string, byID map[string]*feishu.Department) []string { + out := make([]string, 0, len(ids)) + for _, id := range ids { + if d, ok := byID[id]; ok && d.Name != "" { + out = append(out, d.Name) + } + } + sort.Strings(out) + return out +} + +// firstName 为 inetOrgPerson 的必填属性 sn 提供值。 +// 拆不出姓氏时用全名,保证非空。 +func firstName(u *feishu.User) string { + if n := strings.TrimSpace(u.Name); n != "" { + return n + } + if n := strings.TrimSpace(u.EnName); n != "" { + return n + } + return u.UnionID +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} diff --git a/internal/mapping/mapping_test.go b/internal/mapping/mapping_test.go new file mode 100644 index 0000000..db89cc9 --- /dev/null +++ b/internal/mapping/mapping_test.go @@ -0,0 +1,341 @@ +package mapping + +import ( + "strings" + "testing" + "time" + + "idsync/internal/feishu" +) + +func dept(id, parent, name string) *feishu.Department { + return &feishu.Department{ID: id, ParentID: parent, Name: name} +} + +func user(uid, name, email string, depts ...string) *feishu.User { + return &feishu.User{ + UnionID: "u-" + uid, + Name: name, + EnterpriseEmail: email, + DepartmentIDs: depts, + } +} + +func options() BuildOptions { + return BuildOptions{ + UserOUDN: "ou=people,dc=example,dc=com", + InactiveOUDN: "ou=Inactive,dc=example,dc=com", + GroupRootDN: "ou=feishuroot,dc=example,dc=com", + } +} + +// TestBuildGroupOrderingIsParentFirst 是一个关键回归测试。 +// +// 分组必须按层级排序,父分组排在子分组之前。早期实现按部门 ID 字母序排序, +// 当子部门的 ID 字母序恰好排在父部门之前时,创建子分组会因父分组尚不存在 +// 而失败(LDAP No Such Object)。 +func TestBuildGroupOrderingIsParentFirst(t *testing.T) { + snap := &feishu.Snapshot{ + Departments: []*feishu.Department{ + // 子部门的 ID 字母序刻意排在父部门之前 + dept("od-aaa-child", "od-root", "子部门"), + dept("od-root", "0", "根部门"), + dept("od-zzz-grandchild", "od-aaa-child", "孙部门"), + }, + } + + st := Build(snap, options()) + + seen := make(map[string]int) + for i, g := range st.Groups { + seen[g.DeptID] = i + } + + for _, g := range st.Groups { + if g.ParentID == "" || g.ParentID == "0" { + continue + } + pi, ok := seen[g.ParentID] + if !ok { + continue // 父不在范围内 + } + ci := seen[g.DeptID] + if pi > ci { + t.Errorf("子分组 %s 排在父分组 %s 之前(%d > %d)", + g.DeptID, g.ParentID, ci, pi) + } + } + + // 深度应正确反映层级 + for _, g := range st.Groups { + switch g.DeptID { + case "od-root": + if g.Depth != 0 { + t.Errorf("根部门深度 = %d, 期望 0", g.Depth) + } + case "od-aaa-child": + if g.Depth != 1 { + t.Errorf("子部门深度 = %d, 期望 1", g.Depth) + } + case "od-zzz-grandchild": + if g.Depth != 2 { + t.Errorf("孙部门深度 = %d, 期望 2", g.Depth) + } + } + } +} + +// TestBuildGroupDNReflectsNesting 验证 DN 正确表达部门层级。 +func TestBuildGroupDNReflectsNesting(t *testing.T) { + snap := &feishu.Snapshot{ + Departments: []*feishu.Department{ + dept("od-root", "0", "Acme"), + dept("od-rd", "od-root", "研发部"), + dept("od-backend", "od-rd", "后端组"), + }, + } + + st := Build(snap, options()) + + rootDN := st.GroupByDeptID["od-root"].DN + if rootDN != "cn=Acme,ou=feishuroot,dc=example,dc=com" { + t.Errorf("根部门 DN = %q", rootDN) + } + + rdDN := st.GroupByDeptID["od-rd"].DN + if rdDN != "cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" { + t.Errorf("研发部 DN = %q(中文名应转为拼音)", rdDN) + } + + bdDN := st.GroupByDeptID["od-backend"].DN + if bdDN != "cn=houduanzu,cn=yanfabu,cn=Acme,ou=feishuroot,dc=example,dc=com" { + t.Errorf("后端组 DN = %q", bdDN) + } +} + +// TestBuildUsernameFromEnterpriseEmail 验证登录名来源。 +func TestBuildUsernameFromEnterpriseEmail(t *testing.T) { + snap := &feishu.Snapshot{ + Departments: []*feishu.Department{dept("od-root", "0", "Acme")}, + Users: []*feishu.User{ + user("1", "张三", "ZhangSan@Example.com", "od-root"), + }, + } + + st := Build(snap, options()) + if len(st.Users) != 1 { + t.Fatalf("用户数 = %d, 期望 1", len(st.Users)) + } + + u := st.Users[0] + // 登录名应小写且不含域名 + if u.UID != "zhangsan" { + t.Errorf("uid = %q, 期望 zhangsan(应取企业邮箱前缀并转小写)", u.UID) + } + if u.DN != "uid=zhangsan,ou=people,dc=example,dc=com" { + t.Errorf("DN = %q", u.DN) + } + if u.InactiveDN != "uid=zhangsan,ou=Inactive,dc=example,dc=com" { + t.Errorf("离职 DN = %q", u.InactiveDN) + } + // mail 必须是企业邮箱本身(保留原始大小写) + if u.Mail != "ZhangSan@Example.com" { + t.Errorf("mail = %q, 期望企业邮箱原值", u.Mail) + } + if u.DisplayName != "张三" { + t.Errorf("displayName = %q, 期望 张三", u.DisplayName) + } +} + +// TestBuildSkipsUserWithoutEnterpriseEmail 验证缺少企业邮箱的用户不会被臆造登录名。 +// +// 这里刻意不断言「回落到个人邮箱」——那样的行为会产生不可预期、 +// 且离职后仍然有效的登录名,因此必须跳过。 +func TestBuildSkipsUserWithoutEnterpriseEmail(t *testing.T) { + snap := &feishu.Snapshot{ + Departments: []*feishu.Department{dept("od-root", "0", "Acme")}, + Users: []*feishu.User{ + // 有个人邮箱但没有企业邮箱 + { + UnionID: "u-nomail", + Name: "孙七", + Email: "sq@personal.com", + DepartmentIDs: []string{"od-root"}, + }, + }, + } + + st := Build(snap, options()) + if len(st.Users) != 0 { + t.Errorf("缺少企业邮箱的用户不应被映射为登录名,实际生成 %d 个", len(st.Users)) + } +} + +// TestBuildMembership 验证成员关系按部门正确归集。 +func TestBuildMembership(t *testing.T) { + snap := &feishu.Snapshot{ + Departments: []*feishu.Department{ + dept("od-root", "0", "Acme"), + dept("od-rd", "od-root", "研发部"), + }, + Users: []*feishu.User{ + user("1", "张三", "zhangsan@example.com", "od-rd"), + // 一人属多部门 + user("2", "李四", "lisi@example.com", "od-root", "od-rd"), + }, + } + + st := Build(snap, options()) + + rd := st.GroupByDeptID["od-rd"] + if _, ok := rd.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; !ok { + t.Errorf("张三 未加入研发部") + } + if _, ok := rd.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok { + t.Errorf("李四 未加入研发部") + } + + root := st.GroupByDeptID["od-root"] + if _, ok := root.Members["uid=lisi,ou=people,dc=example,dc=com"]; !ok { + t.Errorf("李四 未加入根部门") + } + if _, ok := root.Members["uid=zhangsan,ou=people,dc=example,dc=com"]; ok { + t.Errorf("张三 只属研发部,不应出现在根部门") + } +} + +// TestBuildOrphanDepartmentWarnsAndAttachesToRoot 验证父部门不在范围内时的处理。 +// +// 行为是「挂到分组根下并告警」,而不是静默跳过或静默挂错层级: +// 静默跳过会让部门凭空消失,静默挂错层级会让人以为结构正确。 +func TestBuildOrphanDepartmentWarnsAndAttachesToRoot(t *testing.T) { + snap := &feishu.Snapshot{ + Departments: []*feishu.Department{ + // 父部门 od-missing 不在快照中 + dept("od-orphan", "od-missing", "孤儿部门"), + }, + } + + st := Build(snap, options()) + + g, ok := st.GroupByDeptID["od-orphan"] + if !ok { + t.Fatalf("父部门不在范围内的部门仍应被同步(挂到分组根下)") + } + if want := "cn=guerbumen,ou=feishuroot,dc=example,dc=com"; g.DN != want { + t.Errorf("孤儿部门 DN = %q, 期望 %q", g.DN, want) + } + + found := false + for _, w := range st.Warnings { + if strings.Contains(w, "od-missing") && strings.Contains(w, "不在同步范围内") { + found = true + break + } + } + if !found { + t.Errorf("父部门不在范围内时应产生告警,实际告警: %v", st.Warnings) + } +} + +// TestBuildCycleProtection 验证部门树出现环时不会死循环。 +func TestBuildCycleProtection(t *testing.T) { + snap := &feishu.Snapshot{ + Departments: []*feishu.Department{ + // a 的父是 b,b 的父是 a:构造一个环 + dept("od-a", "od-b", "A"), + dept("od-b", "od-a", "B"), + }, + } + + done := make(chan *State, 1) + go func() { done <- Build(snap, options()) }() + + select { + case st := <-done: + // 关键是能返回而不是卡死 + if st == nil { + t.Fatalf("Build 返回 nil") + } + case <-timeAfterSeconds(5): + t.Fatalf("部门数据成环时 Build 未在 5 秒内返回,可能存在死循环") + } +} + +// TestToPinyin 验证部门名到 CN 的转换与清理。 +func TestToPinyin(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"研发部", "yanfabu"}, + {"后端组", "houduanzu"}, + {"Acme", "Acme"}, + {"IT Dept", "IT_Dept"}, + {"", ""}, + {" ", ""}, + {"Finance-2", "Finance-2"}, + } + for _, c := range cases { + if got := toPinyin(c.in); got != c.want { + t.Errorf("toPinyin(%q) = %q, 期望 %q", c.in, got, c.want) + } + } +} + +// TestCnNameFallsBackToDeptID 验证纯符号部门名不会产生空 CN。 +func TestCnNameFallsBackToDeptID(t *testing.T) { + got := cnName("★☆", "od-xyz") + if got == "" { + t.Errorf("cnName 不应返回空字符串") + } + if !strings.Contains(got, "od-xyz") { + t.Errorf("无法转为拼音时应回落到部门 ID,实际: %q", got) + } +} + +// TestAttrsPreserveEmptyOptionalFields 验证可选字段为空时不会填充臆造值。 +// +// 这些属性在 LDAP 侧会走删除分支,而不是写入 "Default: xxx" 之类的占位值。 +func TestBuildKeepsOptionalFieldsEmpty(t *testing.T) { + snap := &feishu.Snapshot{ + Departments: []*feishu.Department{dept("od-root", "0", "Acme")}, + Users: []*feishu.User{ + { + UnionID: "u-1", Name: "张三", EnterpriseEmail: "zhangsan@example.com", + // Mobile/EmployeeNo/JobTitle/WorkStation 全部留空 + DepartmentIDs: []string{"od-root"}, + }, + }, + } + + st := Build(snap, options()) + u := st.Users[0] + + if u.Mobile != "" { + t.Errorf("mobile 应为空,实际 %q", u.Mobile) + } + if u.EmployeeNumber != "" { + t.Errorf("employeeNumber 应为空,实际 %q", u.EmployeeNumber) + } + if u.DepartmentNumber != "" { + t.Errorf("departmentNumber 应为空,实际 %q", u.DepartmentNumber) + } + if u.PostalAddress != "" { + t.Errorf("postalAddress 应为空,实际 %q", u.PostalAddress) + } + // sn 是 inetOrgPerson 的必填项,必须有值 + if u.SN == "" { + t.Errorf("sn 不可为空(inetOrgPerson 必填)") + } +} + +// timeAfterSeconds 返回一个等待指定秒数后触发的通道,用于检验死循环。 +func timeAfterSeconds(n int) <-chan struct{} { + ch := make(chan struct{}) + go func() { + time.Sleep(time.Duration(n) * time.Second) + close(ch) + }() + return ch +} diff --git a/internal/password/password.go b/internal/password/password.go new file mode 100644 index 0000000..1cd2dd2 --- /dev/null +++ b/internal/password/password.go @@ -0,0 +1,80 @@ +// Package password 提供 OpenLDAP userPassword 的哈希编码。 +// +// 为什么需要它:slapd 对客户端通过普通 Modify 写入的 userPassword **不做** +// 自动哈希(除非启用 ppolicy 的 hash_cleartext),因此如果直接写明文, +// 目录里就会以明文形式保存密码。这里在写入前自行编码成 {SSHA}, +// 不依赖服务端配置。 +package password + +import ( + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "fmt" +) + +// EncodeSSHA 把明文密码编码成 OpenLDAP 的 {SSHA} 形式。 +// +// SSHA = base64( SHA1(密码 + 盐) + 盐 )。附带随机盐可以避免彩虹表, +// 并让相同密码产生不同哈希。 +func EncodeSSHA(plain string) (string, error) { + salt := make([]byte, 8) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("生成盐失败: %w", err) + } + + h := sha1.New() + h.Write([]byte(plain)) + h.Write(salt) + sum := h.Sum(nil) + + // 哈希后拼接盐,一并 base64 + out := append(sum, salt...) + return "{SSHA}" + base64.StdEncoding.EncodeToString(out), nil +} + +// VerifySSHA 校验明文密码与 {SSHA} 哈希是否匹配。 +// +// 主要用于测试,也可供将来的密码校验功能复用。 +func VerifySSHA(encoded, plain string) bool { + const prefix = "{SSHA}" + if len(encoded) <= len(prefix) || encoded[:len(prefix)] != prefix { + return false + } + + raw, err := base64.StdEncoding.DecodeString(encoded[len(prefix):]) + if err != nil || len(raw) < sha1.Size { + return false + } + + sum := raw[:sha1.Size] + salt := raw[sha1.Size:] + + h := sha1.New() + h.Write([]byte(plain)) + h.Write(salt) + want := h.Sum(nil) + + if len(want) != len(sum) { + return false + } + // 恒定时间比较,避免时序侧信道(虽然此处主要用于测试, + // 保持与生产代码一致的比较方式更不易被误用)。 + var diff byte + for i := range want { + diff |= want[i] ^ sum[i] + } + return diff == 0 +} + +// Random 生成一个用户不可知的随机初始密码。 +// +// 用户不需要知道它:Authelia 的密码重置流程不需要旧密码, +// 因此新账号首次登录应走“忘记密码”。这比为所有人设同一个初始密码安全得多。 +func Random() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("生成随机密码失败: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/password/password_test.go b/internal/password/password_test.go new file mode 100644 index 0000000..389f5b7 --- /dev/null +++ b/internal/password/password_test.go @@ -0,0 +1,90 @@ +package password + +import ( + "strings" + "testing" +) + +func TestEncodeSSHA(t *testing.T) { + const plain = "correct horse battery staple" + + encoded, err := EncodeSSHA(plain) + if err != nil { + t.Fatalf("编码失败: %v", err) + } + + if !strings.HasPrefix(encoded, "{SSHA}") { + t.Errorf("结果应以 {SSHA} 开头,实际: %q", encoded) + } + + // 关键性质:明文绝不能出现在结果里。 + // slapd 不会自动哈希 Modify 写入的 userPassword,因此这一步是 + // 密码不落明文存储的唯一保障。 + if strings.Contains(encoded, plain) { + t.Errorf("编码结果中出现了明文密码: %q", encoded) + } + + if !VerifySSHA(encoded, plain) { + t.Errorf("正确密码校验失败: %q", encoded) + } + if VerifySSHA(encoded, plain+"x") { + t.Errorf("错误密码不应通过校验") + } +} + +func TestEncodeSSHASaltIsRandom(t *testing.T) { + const plain = "same-password" + + a, err := EncodeSSHA(plain) + if err != nil { + t.Fatalf("编码失败: %v", err) + } + b, err := EncodeSSHA(plain) + if err != nil { + t.Fatalf("编码失败: %v", err) + } + + // 随机盐应让同一密码产生不同哈希,避免相同密码在目录中可被关联识别。 + if a == b { + t.Errorf("同一密码两次编码结果相同,说明盐未随机化: %q", a) + } + if !VerifySSHA(a, plain) || !VerifySSHA(b, plain) { + t.Errorf("带随机盐的两个哈希都应能校验通过") + } +} + +func TestVerifySSHARejectsMalformed(t *testing.T) { + cases := []struct { + name string + encoded string + }{ + {"空字符串", ""}, + {"缺少前缀", "abcdef"}, + {"前缀但非法 base64", "{SSHA}!!!not-base64!!!"}, + {"长度不足以容纳哈希", "{SSHA}YWJj"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if VerifySSHA(c.encoded, "whatever") { + t.Errorf("畸形输入 %q 不应校验通过", c.encoded) + } + }) + } +} + +func TestRandom(t *testing.T) { + a, err := Random() + if err != nil { + t.Fatalf("生成失败: %v", err) + } + b, err := Random() + if err != nil { + t.Fatalf("生成失败: %v", err) + } + if a == b { + t.Errorf("两次生成的随机密码相同: %q", a) + } + if len(a) < 20 { + t.Errorf("随机密码过短(%d 字符),强度不足", len(a)) + } +} diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..332c99e --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,188 @@ +// Package server 提供 WebUI 所需的 HTTP 接口与静态资源。 +// +// 认证只做一件事:保护这个管理界面本身。用户认证完全由 Authelia 承担, +// 本工具不参与任何登录链路,因此这里就是一个单管理员的会话,不做角色体系。 +package server + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// SessionTTL 是会话有效期。 +const SessionTTL = 12 * time.Hour + +// cookieName 是会话 cookie 的名称。 +const cookieName = "idsync_session" + +// Auth 负责单管理员认证与会话签名。 +type Auth struct { + username string + passwordHash []byte + secret []byte + secureCookie bool +} + +// NewAuth 构造认证器。passwordHash 为空时用明文密码生成 bcrypt 哈希。 +func NewAuth(username string, passwordHash []byte, plainPassword string, secret []byte, secureCookie bool) (*Auth, error) { + if len(passwordHash) == 0 { + if plainPassword == "" { + return nil, fmt.Errorf("未配置管理员密码") + } + h, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("生成密码哈希失败: %w", err) + } + passwordHash = h + } + if len(secret) < 32 { + return nil, fmt.Errorf("会话密钥长度不足(至少 32 字节)") + } + return &Auth{ + username: username, + passwordHash: passwordHash, + secret: secret, + secureCookie: secureCookie, + }, nil +} + +// PasswordHashB64 返回可写回配置文件的哈希(base64)。 +func (a *Auth) PasswordHashB64() string { + return base64.StdEncoding.EncodeToString(a.passwordHash) +} + +// Login 校验凭据;成功则下发签名 cookie。 +func (a *Auth) Login(w http.ResponseWriter, r *http.Request) error { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return fmt.Errorf("请求格式错误") + } + + // 用户名与密码都要做恒定时间比较,避免通过响应时间探测用户名是否存在。 + userOK := subtle.ConstantTimeCompare([]byte(req.Username), []byte(a.username)) == 1 + passErr := bcrypt.CompareHashAndPassword(a.passwordHash, []byte(req.Password)) + if !userOK || passErr != nil { + return fmt.Errorf("用户名或密码错误") + } + + token := a.sign(time.Now().Add(SessionTTL)) + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: a.secureCookie, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(SessionTTL), + }) + return nil +} + +// Logout 清除会话 cookie。 +func (a *Auth) Logout(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: a.secureCookie, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// Authenticated 判断请求是否带有有效会话。 +func (a *Auth) Authenticated(r *http.Request) bool { + c, err := r.Cookie(cookieName) + if err != nil || c.Value == "" { + return false + } + payload, sig, ok := strings.Cut(c.Value, ".") + if !ok { + return false + } + if !hmac.Equal([]byte(sig), []byte(a.signPayload(payload))) { + return false + } + // 载荷即过期时间的 Unix 秒 + exp, err := time.Parse(time.RFC3339, decodeB64(payload)) + if err != nil { + return false + } + return time.Now().Before(exp) +} + +// Middleware 保护需要登录的接口。 +func (a *Auth) Middleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !a.Authenticated(r) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "未登录"}) + return + } + next(w, r) + } +} + +// sign 生成 "payload.signature" 形式的令牌。 +func (a *Auth) sign(exp time.Time) string { + payload := encodeB64(exp.UTC().Format(time.RFC3339)) + return payload + "." + a.signPayload(payload) +} + +// signPayload 用 HMAC-SHA256 对载荷签名。 +func (a *Auth) signPayload(payload string) string { + mac := hmac.New(sha256.New, a.secret) + mac.Write([]byte(payload)) + return encodeB64Bytes(mac.Sum(nil)) +} + +// GenerateSecret 生成会话密钥。 +func GenerateSecret() (string, error) { + b := make([]byte, 48) + if _, err := rand.Read(b); err != nil { + return "", err + } + return encodeB64Bytes(b), nil +} + +func encodeB64(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) } +func encodeB64Bytes(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } +func decodeB64(s string) string { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return "" + } + return string(b) +} + +// WithTimeout 给 handler 提供一个带超时的 context。 +func WithTimeout(d time.Duration, fn func(ctx context.Context, w http.ResponseWriter, r *http.Request)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), d) + defer cancel() + fn(ctx, w, r) + } +} + +// HashPassword 为给定明文密码生成 bcrypt 哈希(base64 编码),供 -hash-password 使用。 +func HashPassword(plain string) (string, error) { + h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(h), nil +} diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go new file mode 100644 index 0000000..57e772e --- /dev/null +++ b/internal/server/auth_test.go @@ -0,0 +1,255 @@ +package server + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func newTestAuth(t *testing.T) *Auth { + t.Helper() + a, err := NewAuth("admin", nil, "correct-password", + []byte("0123456789abcdef0123456789abcdef0123456789abcdef"), false) + if err != nil { + t.Fatalf("构造认证器失败: %v", err) + } + return a +} + +func login(t *testing.T, a *Auth, user, pass string) *http.Response { + t.Helper() + body := strings.NewReader(`{"username":"` + user + `","password":"` + pass + `"}`) + req := httptest.NewRequest(http.MethodPost, "/api/login", body) + rec := httptest.NewRecorder() + err := a.Login(rec, req) + if err != nil { + return &http.Response{StatusCode: http.StatusUnauthorized} + } + return rec.Result() +} + +func TestLoginAcceptsCorrectCredentials(t *testing.T) { + a := newTestAuth(t) + res := login(t, a, "admin", "correct-password") + if res.StatusCode != http.StatusOK { + t.Fatalf("登录失败,状态码 %d", res.StatusCode) + } + + cookies := res.Cookies() + if len(cookies) == 0 { + t.Fatalf("登录成功但未下发 cookie") + } + + var session *http.Cookie + for _, c := range cookies { + if c.Name == cookieName { + session = c + } + } + if session == nil { + t.Fatalf("未找到会话 cookie") + } + + // 关键安全属性 + if !session.HttpOnly { + t.Errorf("会话 cookie 必须设置 HttpOnly,否则可被 JS 读取") + } + if session.SameSite != http.SameSiteLaxMode { + t.Errorf("会话 cookie 应设置 SameSite=Lax,实际 %v", session.SameSite) + } +} + +func TestLoginRejectsWrongCredentials(t *testing.T) { + a := newTestAuth(t) + cases := []struct { + name string + user string + pass string + }{ + {"密码错误", "admin", "wrong-password"}, + {"用户名错误", "root", "correct-password"}, + {"均为空", "", ""}, + {"密码为空", "admin", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res := login(t, a, c.user, c.pass) + if res.StatusCode == http.StatusOK { + t.Errorf("错误凭据不应登录成功") + } + for _, ck := range res.Cookies() { + if ck.Name == cookieName && ck.Value != "" { + t.Errorf("登录失败时不应下发有效会话 cookie") + } + } + }) + } +} + +func TestSessionValidation(t *testing.T) { + a := newTestAuth(t) + res := login(t, a, "admin", "correct-password") + + var token string + for _, c := range res.Cookies() { + if c.Name == cookieName { + token = c.Value + } + } + + cases := []struct { + name string + value string + want bool + }{ + {"有效会话", token, true}, + {"空值", "", false}, + {"无签名部分", "abc", false}, + {"签名被篡改", token[:len(token)-4] + "AAAA", false}, + {"伪造载荷", "ZmFrZQ.AAAA", false}, + {"随机字符串", "not-a-token-at-all", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + if c.value != "" { + req.AddCookie(&http.Cookie{Name: cookieName, Value: c.value}) + } + if got := a.Authenticated(req); got != c.want { + t.Errorf("Authenticated() = %v, 期望 %v", got, c.want) + } + }) + } +} + +// TestSessionRejectsForgedSignature 验证签名密钥不同则无法通过校验。 +// +// 这是会话机制的核心安全性:没有正确的密钥,就无法构造出有效的会话令牌。 +func TestSessionRejectsForgedSignature(t *testing.T) { + a := newTestAuth(t) + res := login(t, a, "admin", "correct-password") + var token string + for _, c := range res.Cookies() { + if c.Name == cookieName { + token = c.Value + } + } + + // 用不同密钥的认证器校验同一个令牌 + other, err := NewAuth("admin", nil, "correct-password", + []byte("ffffffffffffffffffffffffffffffffffffffffffffffff"), false) + if err != nil { + t.Fatalf("构造失败: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + req.AddCookie(&http.Cookie{Name: cookieName, Value: token}) + if other.Authenticated(req) { + t.Errorf("换了会话密钥后旧令牌不应通过校验") + } +} + +// TestSessionExpiry 验证过期会话被拒。 +// +// 这里直接构造一个已过期的载荷并签名,模拟「昨天登录、今天再用」的情形。 +func TestSessionExpiry(t *testing.T) { + a := newTestAuth(t) + expired := a.sign(time.Now().Add(-time.Minute)) + + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + req.AddCookie(&http.Cookie{Name: cookieName, Value: expired}) + if a.Authenticated(req) { + t.Errorf("已过期的会话不应通过校验") + } +} + +func TestMiddlewareBlocksAnonymous(t *testing.T) { + a := newTestAuth(t) + + called := false + h := a.Middleware(func(w http.ResponseWriter, r *http.Request) { called = true }) + + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("未登录访问受保护接口应返回 401,实际 %d", rec.Code) + } + if called { + t.Errorf("未登录时不应执行受保护的处理逻辑") + } +} + +func TestLogoutClearsCookie(t *testing.T) { + a := newTestAuth(t) + rec := httptest.NewRecorder() + a.Logout(rec) + + var cleared bool + for _, c := range rec.Result().Cookies() { + if c.Name == cookieName && c.MaxAge < 0 { + cleared = true + } + } + if !cleared { + t.Errorf("登出应清除会话 cookie") + } +} + +func TestNewAuthRequiresSecret(t *testing.T) { + if _, err := NewAuth("admin", nil, "pw", []byte("short"), false); err == nil { + t.Errorf("过短的会话密钥应被拒绝") + } + if _, err := NewAuth("admin", nil, "", []byte("0123456789abcdef0123456789abcdef0123456789abcdef"), false); err == nil { + t.Errorf("缺少密码时应被拒绝") + } +} + +func TestHashPasswordRoundTrip(t *testing.T) { + h, err := HashPassword("my-secret") + if err != nil { + t.Fatalf("生成哈希失败: %v", err) + } + // 生成的哈希应能通过 NewAuth 校验 + a, err := NewAuth("admin", mustDecodeB64(t, h), "", + []byte("0123456789abcdef0123456789abcdef0123456789abcdef"), false) + if err != nil { + t.Fatalf("用哈希构造认证器失败: %v", err) + } + if res := login(t, a, "admin", "my-secret"); res.StatusCode != http.StatusOK { + t.Errorf("用生成的哈希登录失败") + } + if res := login(t, a, "admin", "wrong"); res.StatusCode == http.StatusOK { + t.Errorf("错误密码不应通过") + } +} + +func TestGenerateSecret(t *testing.T) { + a, err := GenerateSecret() + if err != nil { + t.Fatalf("生成失败: %v", err) + } + b, err := GenerateSecret() + if err != nil { + t.Fatalf("生成失败: %v", err) + } + if a == b { + t.Errorf("两次生成的密钥相同") + } + if len(a) < 32 { + t.Errorf("密钥过短: %d", len(a)) + } +} + +func mustDecodeB64(t *testing.T, s string) []byte { + t.Helper() + dec, err := base64.StdEncoding.DecodeString(s) + if err != nil { + t.Fatalf("base64 解码失败: %v", err) + } + return dec +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..ce6a727 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,326 @@ +package server + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" + + "idsync/internal/config" + "idsync/internal/ldaplib" + "idsync/internal/store" + "idsync/internal/syncer" + "idsync/internal/webui" +) + +// Deps 是服务器依赖。 +type Deps struct { + Cfg *config.Config + Auth *Auth + Store *store.Store + Runner *syncer.Runner + LDAP *ldaplib.Client + // FeishuTest 检查飞书连通性 + FeishuTest func(ctx context.Context) error + Log *slog.Logger +} + +// Server 装配路由。 +type Server struct { + deps *Deps + mux *http.ServeMux + // syncMu 保证同一时刻只跑一次同步,避免两次点击互相踩踏。 + syncMu chan struct{} +} + +// New 构造服务器。 +func New(d *Deps) *Server { + s := &Server{ + deps: d, + mux: http.NewServeMux(), + syncMu: make(chan struct{}, 1), + } + s.routes() + return s +} + +// Handler 返回可挂载到 http.Server 的处理器。 +func (s *Server) Handler() http.Handler { return s.mux } + +func (s *Server) routes() { + // 静态资源与登录页不需要认证。 + s.mux.HandleFunc("/", s.handleIndex) + s.mux.HandleFunc("/static/", webui.AssetHandler()) + + s.mux.HandleFunc("/api/login", s.handleLogin) + s.mux.HandleFunc("/api/logout", s.handleLogout) + + // 以下接口需要登录。 + s.mux.HandleFunc("/api/status", s.deps.Auth.Middleware(s.handleStatus)) + s.mux.HandleFunc("/api/users", s.deps.Auth.Middleware(s.handleUsers)) + s.mux.HandleFunc("/api/groups", s.deps.Auth.Middleware(s.handleGroups)) + s.mux.HandleFunc("/api/plan", s.deps.Auth.Middleware(s.handlePlan)) + s.mux.HandleFunc("/api/sync", s.deps.Auth.Middleware(s.handleSync)) + s.mux.HandleFunc("/api/runs", s.deps.Auth.Middleware(s.handleRuns)) +} + +// handleIndex 返回内嵌的前端页面;未知的 /api/ 路径返回 404 而非首页。 +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "接口不存在"}) + return + } + webui.IndexHandler()(w, r) +} + +// handleLogin 处理登录。 +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) + return + } + if err := s.deps.Auth.Login(w, r); err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// handleLogout 处理登出。 +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + s.deps.Auth.Logout(w) + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// handleStatus 返回概览信息:连通性、计数、上次同步。 +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + cfg := s.deps.Cfg + + ldapOK := true + ldapErr := "" + if err := s.deps.LDAP.Ping(); err != nil { + ldapOK = false + ldapErr = err.Error() + } + + feishuOK := true + feishuErr := "" + if s.deps.FeishuTest != nil { + if err := s.deps.FeishuTest(ctx); err != nil { + feishuOK = false + feishuErr = err.Error() + } + } + + accounts, err := s.deps.Store.AllAccounts() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + groups, err := s.deps.Store.AllGroups() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + active, inactive := 0, 0 + for _, a := range accounts { + if a.State == "inactive" { + inactive++ + } else { + active++ + } + } + + lastRun, err := s.deps.Store.LastRun() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ldap": map[string]any{ + "ok": ldapOK, "error": ldapErr, + "url": cfg.Ldap.URL, "baseDn": cfg.Ldap.BaseDN, + "userOu": cfg.UserOUDN(), "inactiveOu": cfg.InactiveOUDN(), + }, + "feishu": map[string]any{ + "ok": feishuOK, "error": feishuErr, + "appId": maskSecret(cfg.Feishu.AppID), + "deptList": cfg.Feishu.DeptList, + }, + "counts": map[string]any{ + "activeUsers": active, "inactiveUsers": inactive, "groups": len(groups), + }, + "lastRun": lastRun, + "sync": map[string]any{ + "enabled": cfg.Sync.Enable, "schedule": cfg.Sync.Schedule, + "defaultDryRun": cfg.Sync.DefaultDryRun, + }, + }) +} + +// handleUsers 返回受管用户清单(来自台账),可选与 LDAP 现状核对。 +func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { + accounts, err := s.deps.Store.AllAccounts() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + // 同时读一次 LDAP,标出“台账有但 LDAP 没有”的不一致项, + // 这类问题在界面上一眼可见比事后翻日志有用得多。 + drift := map[string]string{} + if conn, err := s.deps.LDAP.Dial(); err == nil { + defer conn.Close() + active, errA := conn.ListUsers(s.deps.Cfg.UserOUDN()) + inactive, errI := conn.ListUsers(s.deps.Cfg.InactiveOUDN()) + if errA == nil && errI == nil { + for _, a := range accounts { + switch a.State { + case "inactive": + if _, ok := inactive[a.UID]; !ok { + drift[a.UID] = "台账标记离职,但 LDAP 中不存在" + } + default: + if _, ok := active[a.UID]; !ok { + if _, inInactive := inactive[a.UID]; inInactive { + drift[a.UID] = "台账标记在职,实际位于离职 OU" + } else { + drift[a.UID] = "台账存在,但 LDAP 中不存在" + } + } + } + } + } + } + + type row struct { + UID string `json:"uid"` + Name string `json:"name"` + Email string `json:"email"` + State string `json:"state"` + DN string `json:"dn"` + DeptIDs string `json:"deptIds"` + LastSync string `json:"lastSyncedAt"` + Drift string `json:"drift,omitempty"` + } + + rows := make([]row, 0, len(accounts)) + for _, a := range accounts { + rows = append(rows, row{ + UID: a.UID, + Name: a.Name, + Email: a.Email, + State: a.State, + DN: a.DN, + DeptIDs: a.DeptIDs, + LastSync: a.LastSyncedAt.Format(time.RFC3339), + Drift: drift[a.UID], + }) + } + + writeJSON(w, http.StatusOK, map[string]any{"users": rows}) +} + +// handleGroups 返回受管分组清单。 +func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) { + groups, err := s.deps.Store.AllGroups() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"groups": groups}) +} + +// handlePlan 计算并返回 dry-run 计划,不产生任何写入。 +func (s *Server) handlePlan(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + + plan, err := s.deps.Runner.Preview(ctx) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, plan) +} + +// handleSync 执行同步。默认 dry-run,只有显式传 dryRun=false 才真正写入。 +func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "方法不允许"}) + return + } + + var req struct { + DryRun *bool `json:"dryRun"` + Force bool `json:"force"` + } + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&req) + } + + // 默认 dry-run:忘记传参数时宁可什么都不做。 + dryRun := true + if req.DryRun != nil { + dryRun = *req.DryRun + } + + // 同一时刻只允许一次同步。 + select { + case s.syncMu <- struct{}{}: + defer func() { <-s.syncMu }() + default: + writeJSON(w, http.StatusConflict, map[string]string{"error": "已有同步任务在执行"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute) + defer cancel() + + plan, res, err := s.deps.Runner.Run(ctx, dryRun, req.Force, "manual") + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{ + "error": err.Error(), "plan": plan, + }) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "dryRun": dryRun, "plan": plan, "result": res, + }) +} + +// handleRuns 返回同步历史。 +func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) { + runs, err := s.deps.Store.RecentRuns(100) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"runs": runs}) +} + +// writeJSON 输出 JSON 响应。 +func writeJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(body) +} + +// maskSecret 只保留标识的前后几位,避免在界面上暴露完整凭据。 +func maskSecret(s string) string { + if len(s) <= 8 { + if len(s) == 0 { + return "未配置" + } + return "****" + } + return s[:4] + "****" + s[len(s)-4:] +} diff --git a/internal/store/repo.go b/internal/store/repo.go new file mode 100644 index 0000000..98f6a2e --- /dev/null +++ b/internal/store/repo.go @@ -0,0 +1,153 @@ +package store + +import ( + "errors" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// --- 账号台账 --- + +// AllAccounts 返回全部受管账号。 +func (s *Store) AllAccounts() ([]*Account, error) { + var out []*Account + if err := s.db.Order("uid").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// AccountsByUID 返回 uid → 账号 的映射,供 diff 引擎使用。 +func (s *Store) AccountsByUID() (map[string]*Account, error) { + list, err := s.AllAccounts() + if err != nil { + return nil, err + } + out := make(map[string]*Account, len(list)) + for _, a := range list { + out[a.UID] = a + } + return out, nil +} + +// UpsertAccount 写入或更新一条账号台账。 +func (s *Store) UpsertAccount(a *Account) error { + a.LastSyncedAt = time.Now() + return s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "uid"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "dn", "union_id", "user_id", "email", "name", + "state", "attrs_hash", "dept_ids", "last_synced_at", + }), + }).Create(a).Error +} + +// SetAccountState 更新账号状态与 DN(用于在职 ↔ 离职迁移)。 +func (s *Store) SetAccountState(uid, state, dn string) error { + return s.db.Model(&Account{}).Where("uid = ?", uid). + Updates(map[string]any{"state": state, "dn": dn}).Error +} + +// DeleteAccount 从台账删除一条账号。 +func (s *Store) DeleteAccount(uid string) error { + return s.db.Where("uid = ?", uid).Delete(&Account{}).Error +} + +// --- 分组台账 --- + +// AllGroups 返回全部受管分组。 +func (s *Store) AllGroups() ([]*Group, error) { + var out []*Group + if err := s.db.Order("dept_id").Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// GroupsByDeptID 返回 dept_id → 分组 的映射。 +func (s *Store) GroupsByDeptID() (map[string]*Group, error) { + list, err := s.AllGroups() + if err != nil { + return nil, err + } + out := make(map[string]*Group, len(list)) + for _, g := range list { + out[g.DeptID] = g + } + return out, nil +} + +// UpsertGroup 写入或更新一条分组台账。 +func (s *Store) UpsertGroup(g *Group) error { + g.LastSyncedAt = time.Now() + return s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "dept_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "dn", "name", "parent_id", "state", "last_synced_at", + }), + }).Create(g).Error +} + +// SetGroupState 更新分组状态。 +func (s *Store) SetGroupState(deptID, state string) error { + return s.db.Model(&Group{}).Where("dept_id = ?", deptID). + Update("state", state).Error +} + +// DeleteGroup 从台账删除一条分组。 +func (s *Store) DeleteGroup(deptID string) error { + return s.db.Where("dept_id = ?", deptID).Delete(&Group{}).Error +} + +// --- 同步记录 --- + +// StartRun 创建一条同步记录并返回该记录。 +// +// 返回完整记录而非仅 ID:调用方若自行构造一个只含 ID 的结构体再保存, +// GORM 的 Save 会把未赋值的 StartedAt/Mode/Trigger 一并写成零值, +// 导致历史页面显示 0001-01-01 与空白模式。 +func (s *Store) StartRun(mode, trigger string) (*SyncRun, error) { + run := &SyncRun{ + StartedAt: time.Now(), + Mode: mode, + Trigger: trigger, + Status: "running", + } + if err := s.db.Create(run).Error; err != nil { + return nil, err + } + return run, nil +} + +// FinishRun 完成一条同步记录。 +func (s *Store) FinishRun(run *SyncRun) error { + run.FinishedAt = time.Now() + return s.db.Save(run).Error +} + +// RecentRuns 返回最近的同步记录,最新在前。 +func (s *Store) RecentRuns(limit int) ([]*SyncRun, error) { + if limit <= 0 { + limit = 50 + } + var out []*SyncRun + if err := s.db.Order("id desc").Limit(limit).Find(&out).Error; err != nil { + return nil, err + } + return out, nil +} + +// LastRun 返回最近一次成功执行的记录,没有则返回 nil。 +func (s *Store) LastRun() (*SyncRun, error) { + var out SyncRun + err := s.db.Where("status = ?", "success").Order("id desc").First(&out).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &out, nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..a0b463d --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,106 @@ +// Package store 维护本地 SQLite 台账。 +// +// 台账的职责只有一个:记录“哪些 LDAP 条目是我们创建的”。 +// 这是安全删除的唯一依据——没有台账就没有删除权,因此 +// 服务账号、手工创建的账号、admin 永远不会被同步任务误伤。 +package store + +import ( + "fmt" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Account 是一个由同步器管理的用户账号。 +type Account struct { + // UID 即登录名(企业邮箱前缀),同时是主键。 + UID string `gorm:"primaryKey;size:190"` + DN string `gorm:"size:512;not null"` + UnionID string `gorm:"size:190;index"` + UserID string `gorm:"size:190"` + Email string `gorm:"size:255"` + Name string `gorm:"size:255"` + // State: active(在 ou=people)或 inactive(在 ou=Inactive) + State string `gorm:"size:16;not null;default:active"` + // AttrsHash 是上一次写入的属性指纹,用于快速判断是否漂移 + AttrsHash string `gorm:"size:64"` + // DeptIDs 是所属部门,逗号分隔 + DeptIDs string `gorm:"size:1024"` + LastSyncedAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// Group 是一个由同步器管理的部门分组。 +type Group struct { + // DeptID 是飞书的 open_department_id,作为主键。 + DeptID string `gorm:"primaryKey;size:190" json:"deptId"` + DN string `gorm:"size:512;not null;uniqueIndex" json:"dn"` + Name string `gorm:"size:255" json:"name"` + ParentID string `gorm:"size:190;index" json:"parentId"` + State string `gorm:"size:16;not null;default=active" json:"state"` + LastSyncedAt time.Time `json:"lastSyncedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// SyncRun 记录一次同步执行的结果,供历史页面展示。 +type SyncRun struct { + ID uint `gorm:"primaryKey" json:"id"` + StartedAt time.Time `json:"startedAt"` + FinishedAt time.Time `json:"finishedAt"` + // Mode: dry-run 或 apply + Mode string `gorm:"size:16" json:"mode"` + Trigger string `gorm:"size:32" json:"trigger"` // manual 或 schedule + Status string `gorm:"size:16" json:"status"` // success 或 failed + Created int `json:"created"` + Updated int `json:"updated"` + Moved int `json:"moved"` + Added int `json:"added"` // 加入分组次数 + Removed int `json:"removed"` // 移出分组次数 + Skipped int `json:"skipped"` + Failed int `json:"failed"` + ErrorMsg string `gorm:"size:2048" json:"errorMsg"` +} + +// Store 是台账的访问入口。 +type Store struct { + db *gorm.DB +} + +// Open 打开(必要时创建)SQLite 台账并完成建表。 +func Open(path string) (*Store, error) { + db, err := gorm.Open(sqlite.Open(path), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return nil, fmt.Errorf("打开台账数据库失败 (%s): %w", path, err) + } + + // SQLite 在并发写时容易报 database is locked;限制为单连接可彻底避免。 + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("获取数据库句柄失败: %w", err) + } + sqlDB.SetMaxOpenConns(1) + + if err := db.AutoMigrate(&Account{}, &Group{}, &SyncRun{}); err != nil { + return nil, fmt.Errorf("建表失败: %w", err) + } + return &Store{db: db}, nil +} + +// Close 关闭底层连接。 +func (s *Store) Close() error { + sqlDB, err := s.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +// DB 暴露底层句柄,供需要自定义查询的调用方使用。 +func (s *Store) DB() *gorm.DB { return s.db } diff --git a/internal/syncer/e2e_test.go b/internal/syncer/e2e_test.go new file mode 100644 index 0000000..d3cc59b --- /dev/null +++ b/internal/syncer/e2e_test.go @@ -0,0 +1,624 @@ +//go:build integration + +// 本文件提供一个假的飞书 OpenAPI 服务与端到端集成测试。 +// +// 为什么需要它:真实的同步链路横跨飞书分页读取、字段映射、DN 构造、 +// LDAP 写入与台账回写,任何一环出错都可能表现为“看起来同步成功但数据不对”。 +// 用一个受控的假飞书服务,就能在没有真实租户凭据的情况下把整条链路跑通, +// 并断言最终落在 OpenLDAP 里的内容。 +// +// 启用方式(需要真实 LDAP 实例): +// +// LDAP_TEST_URL=ldap://127.0.0.1:3899 \ +// go test -tags=integration ./internal/syncer/ -v -run TestEndToEnd +package syncer_test + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "testing" + "time" + + "idsync/internal/config" + "idsync/internal/feishu" + "idsync/internal/ldaplib" + "idsync/internal/store" + "idsync/internal/syncer" +) + +// fakeDept 是假服务返回的部门。 +type fakeDept struct { + openID string + parentID string + name string +} + +// fakeUser 是假服务返回的用户。 +type fakeUser struct { + unionID string + userID string + name string + enName string + enterpriseEm string + email string + mobile string + employeeNo string + jobTitle string + deptIDs []string + resigned bool +} + +// fakeFeishu 是一个只实现本工具所需接口的假飞书服务。 +type fakeFeishu struct { + depts []fakeDept + users []fakeUser + // served 记录各接口被调用的次数,用于验证分页与去重行为。 + calls map[string]int +} + +func (f *fakeFeishu) handler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/open-apis/auth/v3/tenant_access_token/internal", func(w http.ResponseWriter, r *http.Request) { + f.calls["token"]++ + writeFakeJSON(w, map[string]any{ + "code": 0, "msg": "ok", + "tenant_access_token": "fake-token", + "expire": 7200, + }) + }) + + mux.HandleFunc("/open-apis/contact/v3/departments", func(w http.ResponseWriter, r *http.Request) { + f.calls["departments"]++ + + parent := r.URL.Query().Get("parent_department_id") + if parent == "" { + parent = "0" + } + // 真实接口在 fetch_child=true 时返回整棵子树,而不只是直接子节点。 + // 假服务必须照此实现,否则部门树的构造与后续用户遍历都覆盖不到。 + fetchChild := r.URL.Query().Get("fetch_child") == "true" + + want := map[string]bool{parent: true} + if fetchChild { + for changed := true; changed; { + changed = false + for _, d := range f.depts { + if !want[d.openID] && want[d.parentID] { + want[d.openID] = true + changed = true + } + } + } + } + + items := make([]map[string]any, 0) + for _, d := range f.depts { + if !want[d.openID] || d.openID == parent { + continue + } + items = append(items, map[string]any{ + "name": d.name, + "parent_department_id": d.parentID, + "open_department_id": d.openID, + }) + } + writeFakeJSON(w, map[string]any{ + "code": 0, "msg": "ok", + "data": map[string]any{ + "has_more": false, "items": items, + }, + }) + }) + + mux.HandleFunc("/open-apis/contact/v3/users/find_by_department", func(w http.ResponseWriter, r *http.Request) { + f.calls["find_by_department"]++ + + deptID := r.URL.Query().Get("department_id") + items := make([]map[string]any, 0) + for _, u := range f.users { + if u.resigned { + continue // 与真实接口一致:不返回离职人员 + } + if !containsStr(u.deptIDs, deptID) { + continue + } + items = append(items, map[string]any{ + "union_id": u.unionID, + "user_id": u.userID, + "name": u.name, + "en_name": u.enName, + "enterprise_email": u.enterpriseEm, + "email": u.email, + "mobile": u.mobile, + "employee_no": u.employeeNo, + "job_title": u.jobTitle, + "department_ids": u.deptIDs, + }) + } + writeFakeJSON(w, map[string]any{ + "code": 0, "msg": "ok", + "data": map[string]any{ + "has_more": false, "items": items, + }, + }) + }) + + mux.HandleFunc("/open-apis/contact/v3/users/", func(w http.ResponseWriter, r *http.Request) { + f.calls["user_get"]++ + + // 从路径末尾取出 union_id + parts := strings.Split(strings.TrimRight(r.URL.Path, "/"), "/") + unionID := parts[len(parts)-1] + + for _, u := range f.users { + if u.unionID != unionID { + continue + } + writeFakeJSON(w, map[string]any{ + "code": 0, "msg": "ok", + "data": map[string]any{ + "user": map[string]any{ + "union_id": unionID, + "user_id": u.userID, + "name": u.name, + "status": map[string]any{"is_resigned": u.resigned}, + }, + }, + }) + return + } + writeFakeJSON(w, map[string]any{"code": 10001, "msg": "user not found"}) + }) + + return mux +} + +func writeFakeJSON(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(body) +} + +func containsStr(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} + +// setup 准备一套完整的测试环境:真实 LDAP + 假飞书 + 临时台账。 +func setup(t *testing.T) (*syncer.Runner, *ldaplib.Client, *store.Store, *fakeFeishu, func()) { + t.Helper() + + ldapURL := os.Getenv("LDAP_TEST_URL") + if ldapURL == "" { + ldapURL = "ldap://127.0.0.1:3899" + } + base := os.Getenv("LDAP_TEST_BASE_DN") + if base == "" { + base = "dc=example,dc=com" + } + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + + // 分组模式可由环境变量切换,以便在两种目录约定下都跑一遍: + // groupOfUniqueNames+uniqueMember(传统 OpenLDAP 做法)与 + // groupOfNames+member(Authelia 文档中的常见做法,也是本仓库用户的实际配置)。 + groupOC := os.Getenv("LDAP_TEST_GROUP_OBJECT_CLASS") + if groupOC == "" { + groupOC = "groupOfUniqueNames" + } + + ldapCfg := config.Ldap{ + URL: ldapURL, BaseDN: base, + AdminDN: "cn=admin," + base, AdminPass: "adminpass", + GroupObjectClass: groupOC, + TimeoutSec: 10, + } + if err := ldapCfg.NormalizeForTest(); err != nil { + t.Fatalf("LDAP 配置规范化失败: %v", err) + } + + ldapClient, err := ldaplib.NewClient(ldapCfg, log) + if err != nil { + t.Fatalf("构造 LDAP 客户端失败: %v", err) + } + if err := ldapClient.Ping(); err != nil { + t.Skipf("LDAP 不可用,跳过端到端测试: %v", err) + } + + fake := &fakeFeishu{calls: map[string]int{}} + ts := httptest.NewServer(fake.handler()) + + feishuClient, err := feishu.New(feishu.Options{ + AppID: "fake", AppSecret: "fake", + BaseURL: ts.URL, QPS: 100, Timeout: 10 * time.Second, + }, log) + if err != nil { + t.Fatalf("构造飞书客户端失败: %v", err) + } + + dbPath := t.TempDir() + "/idsync.db" + st, err := store.Open(dbPath) + if err != nil { + t.Fatalf("打开台账失败: %v", err) + } + + ldapCfg.UserOU = "ou=people" + ldapCfg.InactiveOU = "ou=Inactive" + ldapCfg.GroupRootName = "feishuroot" + + cfg := &config.Config{ + System: config.System{Mode: "release", DataDir: t.TempDir()}, + Feishu: config.Feishu{AppID: "fake", AppSecret: "fake", QPS: 100, TimeoutSec: 10}, + Ldap: ldapCfg, + Database: config.Database{Source: dbPath}, + Sync: config.Sync{MaxDeactivatePercent: 0.5}, + } + + runner := syncer.NewRunner(cfg, feishuClient, ldapClient, st, log) + + cleanup := func() { + ts.Close() + st.Close() + } + return runner, ldapClient, st, fake, cleanup +} + +// TestEndToEndSync 验证完整的同步流程:首次全量创建、幂等重跑、 +// 属性变更、部门调整、离职迁移与重新入职。 +func TestEndToEndSync(t *testing.T) { + runner, ldapClient, st, fake, cleanup := setup(t) + defer cleanup() + + const base = "dc=example,dc=com" + peopleOU := "ou=people," + base + inactiveOU := "ou=Inactive," + base + groupRoot := "ou=feishuroot," + base + + // 准备目录结构并清理历史残留。 + conn, err := ldapClient.Dial() + if err != nil { + t.Fatalf("连接失败: %v", err) + } + for _, ou := range []struct{ dn, name string }{ + {peopleOU, "people"}, {inactiveOU, "Inactive"}, {groupRoot, "feishuroot"}, + } { + if err := conn.EnsureOU(ou.dn, ou.name); err != nil { + t.Fatalf("创建 %s 失败: %v", ou.dn, err) + } + } + + defer func() { + wipeTestOUs(t, conn, base) + conn.Close() + }() + + // 两个部门,其中“研发部”是“后端组”的父部门。 + // 部门 ID 刻意让子部门的字母序排在父部门之前(od-child-* < od-root), + // 这样任何“按 ID 排序后再创建”的实现都会因父分组尚不存在而失败。 + fake.depts = []fakeDept{ + {openID: "od-root", parentID: "0", name: "Acme"}, + {openID: "od-aaa-child", parentID: "od-root", name: "yanfa"}, + } + fake.users = []fakeUser{ + {unionID: "u1", userID: "1", name: "张三", enName: "Zhang San", + enterpriseEm: "zhangsan@example.com", email: "personal1@x.com", + mobile: "13800000001", employeeNo: "E001", jobTitle: "工程师", + deptIDs: []string{"od-aaa-child"}}, + {unionID: "u2", userID: "2", name: "李四", enName: "Li Si", + enterpriseEm: "lisi@example.com", email: "personal2@x.com", + mobile: "13800000002", employeeNo: "E002", jobTitle: "经理", + deptIDs: []string{"od-aaa-child"}}, + // 没有企业邮箱:应当被跳过而不是回落到个人邮箱。 + {unionID: "u3", userID: "3", name: "王五", + enterpriseEm: "", email: "wangwu@personal.com", + deptIDs: []string{"od-aaa-child"}}, + } + wipeTestOUs(t, conn, base) + + // ---- 第一步:dry-run 应当给出创建计划,但不写入任何内容 ---- + plan, res, err := runner.Run(t.Context(), true, false, "test") + if err != nil { + t.Fatalf("dry-run 失败: %v", err) + } + if res != nil { + t.Fatalf("dry-run 不应返回执行结果") + } + if plan.Stats.CreateUser != 2 { + t.Errorf("dry-run 计划新建用户 = %d, 期望 2(王五应因缺少企业邮箱被跳过)", + plan.Stats.CreateUser) + } + foundEmailWarning := false + for _, wr := range plan.Warnings { + if strings.Contains(wr, "企业邮箱") { + foundEmailWarning = true + break + } + } + if !foundEmailWarning { + t.Errorf("缺少企业邮箱的跳过用户应产生告警,实际告警: %v", plan.Warnings) + } + + // dry-run 后 LDAP 中不应有任何用户。 + users, err := conn.ListUsers(peopleOU) + if err != nil { + t.Fatalf("读取用户失败: %v", err) + } + if len(users) != 0 { + t.Fatalf("dry-run 不应写入数据,却发现 %d 个用户", len(users)) + } + + // ---- 第二步:真正执行 ---- + _, res, err = runner.Run(t.Context(), false, false, "test") + if err != nil { + t.Fatalf("执行同步失败: %v", err) + } + if res.Failed != 0 { + t.Fatalf("执行过程中有 %d 个失败: %v", res.Failed, res.Errors) + } + + users, err = conn.ListUsers(peopleOU) + if err != nil { + t.Fatalf("读取用户失败: %v", err) + } + if _, ok := users["zhangsan"]; !ok { + t.Fatalf("未创建 zhangsan;现有用户: %v", keysOf(users)) + } + if _, ok := users["lisi"]; !ok { + t.Fatalf("未创建 lisi;现有用户: %v", keysOf(users)) + } + if _, ok := users["wangwu"]; ok { + t.Errorf("wangwu 缺少企业邮箱,不应被创建") + } + + // 断言属性映射:mail 必须取自企业邮箱,而非个人邮箱。 + zs := users["zhangsan"] + if got := zs.Get(ldaplib.AttrMail); got != "zhangsan@example.com" { + t.Errorf("zhangsan 的 mail = %q, 期望企业邮箱 zhangsan@example.com", got) + } + if got := zs.Get(ldaplib.AttrDisplayName); got != "张三" { + t.Errorf("zhangsan 的 displayName = %q, 期望 张三", got) + } + if got := zs.Get(ldaplib.AttrEmployeeNumber); got != "E001" { + t.Errorf("zhangsan 的 employeeNumber = %q, 期望 E001", got) + } + + // 断言分组与成员关系。 + groups, err := conn.ListGroups(groupRoot) + if err != nil { + t.Fatalf("读取分组失败: %v", err) + } + // 部门树有三层:ou=feishuroot → cn=acme → cn=yanfa(Acme 自身也是部门)。 + acmeDN := "cn=acme," + groupRoot + yanfaDN := "cn=yanfa," + acmeDN + g, ok := groups[yanfaDN] + if !ok { + t.Fatalf("未创建部门分组 %s;现有分组: %v", yanfaDN, keysOf(groups)) + } + members := g.Members() + if _, has := members["uid=zhangsan,"+peopleOU]; !has { + t.Errorf("zhangsan 未被加入 %s;成员: %v", yanfaDN, members) + } + + // 父部门分组也应存在,验证树形嵌套正确。 + if _, ok := groups[acmeDN]; !ok { + t.Errorf("未创建父部门分组 %s;现有分组: %v", acmeDN, keysOf(groups)) + } + + // 台账应记录两个账号。 + accounts, err := st.AccountsByUID() + if err != nil { + t.Fatalf("读取台账失败: %v", err) + } + if len(accounts) != 2 { + t.Errorf("台账记录数 = %d, 期望 2", len(accounts)) + } + + // ---- 第三步:幂等性:紧接着再跑一次,应当没有任何变更 ---- + plan, _, err = runner.Run(t.Context(), true, false, "test") + if err != nil { + t.Fatalf("第二次 dry-run 失败: %v", err) + } + total := plan.Stats.CreateUser + plan.Stats.UpdateUser + plan.Stats.CreateGroup + + plan.Stats.UpdateGroup + plan.Stats.AddMember + plan.Stats.RemoveMember + + plan.Stats.Deactivate + plan.Stats.Reactivate + if total != 0 { + t.Errorf("重复同步应当是幂等的,却产生 %d 个操作:\n%s", total, dumpOps(plan)) + } + + // ---- 第四步:属性变更应被检测并同步 ---- + fake.users[0].jobTitle = "高级工程师" + fake.users[0].mobile = "13900000001" + + plan, _, err = runner.Run(t.Context(), true, false, "test") + if err != nil { + t.Fatalf("变更检测失败: %v", err) + } + if plan.Stats.UpdateUser != 1 { + t.Errorf("职位变更应产生 1 个更新操作,实际 %d", plan.Stats.UpdateUser) + } + + if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil { + t.Fatalf("应用变更失败: %v", err) + } + users, _ = conn.ListUsers(peopleOU) + if got := users["zhangsan"].Get(ldaplib.AttrDepartmentNumber); got != "高级工程师" { + t.Errorf("职位未更新: %q", got) + } + + // ---- 第五步:离职应迁入 ou=Inactive ---- + fake.users[1].resigned = true + + plan, _, err = runner.Run(t.Context(), true, false, "test") + if err != nil { + t.Fatalf("离职检测失败: %v", err) + } + if plan.Stats.Deactivate != 1 { + t.Fatalf("应检测到 1 个离职账号,实际 %d\n%s", plan.Stats.Deactivate, dumpOps(plan)) + } + + if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil { + t.Fatalf("执行离职迁移失败: %v", err) + } + + users, _ = conn.ListUsers(peopleOU) + if _, still := users["lisi"]; still { + t.Errorf("lisi 已离职,不应仍在 %s", peopleOU) + } + inactive, _ := conn.ListUsers(inactiveOU) + if _, moved := inactive["lisi"]; !moved { + t.Errorf("lisi 未迁入 %s", inactiveOU) + } + + // 离职用户也应从部门分组中移除。 + groups, _ = conn.ListGroups(groupRoot) + if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; has { + t.Errorf("离职后 lisi 仍留在分组 %s 中", yanfaDN) + } + + // ---- 第六步:重新入职应迁回 ---- + fake.users[1].resigned = false + + plan, _, err = runner.Run(t.Context(), true, false, "test") + if err != nil { + t.Fatalf("复职检测失败: %v", err) + } + if plan.Stats.Reactivate != 1 { + t.Fatalf("应检测到 1 个复职账号,实际 %d\n%s", plan.Stats.Reactivate, dumpOps(plan)) + } + + if _, _, err := runner.Run(t.Context(), false, false, "test"); err != nil { + t.Fatalf("执行复职失败: %v", err) + } + users, _ = conn.ListUsers(peopleOU) + if _, back := users["lisi"]; !back { + t.Errorf("lisi 复职后未回到 %s", peopleOU) + } + + // 复职后应重新加入部门分组。 + groups, _ = conn.ListGroups(groupRoot) + if _, has := groups[yanfaDN].Members()["uid=lisi,"+peopleOU]; !has { + t.Errorf("复职后 lisi 未重新加入分组 %s", yanfaDN) + } +} + +// TestEndToEndGuard 验证停用比例护栏会阻止异常的大规模变更。 +func TestEndToEndGuard(t *testing.T) { + runner, ldapClient, _, fake, cleanup := setup(t) + defer cleanup() + + const base = "dc=example,dc=com" + peopleOU := "ou=people," + base + groupRoot := "ou=feishuroot," + base + + conn, err := ldapClient.Dial() + if err != nil { + t.Fatalf("连接失败: %v", err) + } + defer conn.Close() + for _, ou := range []struct{ dn, name string }{ + {peopleOU, "people"}, {"ou=Inactive," + base, "Inactive"}, {groupRoot, "feishuroot"}, + } { + if err := conn.EnsureOU(ou.dn, ou.name); err != nil { + t.Fatalf("创建 %s 失败: %v", ou.dn, err) + } + } + + fake.depts = []fakeDept{{openID: "od-root", parentID: "0", name: "Acme"}} + fake.users = nil + for i := 0; i < 4; i++ { + fake.users = append(fake.users, fakeUser{ + unionID: fmt.Sprintf("g%d", i), userID: fmt.Sprintf("%d", i), + name: fmt.Sprintf("用户%d", i), enterpriseEm: fmt.Sprintf("user%d@example.com", i), + deptIDs: []string{"od-root"}, + }) + } + + wipeTestOUs(t, conn, base) + defer wipeTestOUs(t, conn, base) + + if _, res, err := runner.Run(t.Context(), false, false, "test"); err != nil || res.Failed != 0 { + t.Fatalf("初次同步失败: err=%v res=%+v", err, res) + } + + // 让飞书侧几乎所有用户消失:这模拟了权限范围被误改的灾难场景。 + fake.users = fake.users[:1] + + if _, _, err := runner.Run(t.Context(), false, false, "test"); err == nil { + t.Fatalf("停用 3/4 账号应当被护栏拦截,却成功执行了") + } else if !strings.Contains(err.Error(), "停用") { + t.Errorf("护栏错误信息应当说明停用数量问题,实际: %v", err) + } + + // 强制模式应当放行。 + if _, res, err := runner.Run(t.Context(), false, true, "test"); err != nil { + t.Fatalf("强制模式执行失败: %v", err) + } else if res.Failed != 0 { + t.Errorf("强制模式有 %d 个操作失败: %v", res.Failed, res.Errors) + } +} + +// wipeTestOUs 删除测试用 OU 下的全部用户与分组。 +// +// 刻意不依赖 fake.users:测试过程中会改动这个切片(例如模拟员工离职), +// 若按它来清理就会漏删条目,污染后续测试。直接以 LDAP 现状为准才可靠。 +func wipeTestOUs(t *testing.T, conn *ldaplib.Conn, base string) { + t.Helper() + + peopleOU := "ou=people," + base + inactiveOU := "ou=Inactive," + base + groupRoot := "ou=feishuroot," + base + + for _, ou := range []string{peopleOU, inactiveOU} { + users, err := conn.ListUsers(ou) + if err != nil { + continue + } + for uid := range users { + _ = conn.DeleteUser("uid=" + uid + "," + ou) + } + } + + groups, err := conn.ListGroups(groupRoot) + if err != nil { + return + } + // 先删深层分组,再删浅层,避免「非叶子节点无法删除」。 + dns := keysOf(groups) + sort.Slice(dns, func(i, j int) bool { return len(dns[i]) > len(dns[j]) }) + for _, dn := range dns { + _ = conn.DeleteGroup(groups[dn].DN) + } +} + +// acmeDNFor 返回测试中 Acme 部门对应的分组 DN。 +func acmeDNFor(groupRoot string) string { return "cn=acme," + groupRoot } + +func keysOf[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +func dumpOps(p *syncer.Plan) string { + var b strings.Builder + for _, op := range p.Ops { + fmt.Fprintf(&b, "\n [%s] %s: %s", op.Kind, op.Target, op.Detail) + } + for _, wr := range p.Warnings { + fmt.Fprintf(&b, "\n 警告: %s", wr) + } + return b.String() +} diff --git a/internal/syncer/exec.go b/internal/syncer/exec.go new file mode 100644 index 0000000..2d2d7b4 --- /dev/null +++ b/internal/syncer/exec.go @@ -0,0 +1,274 @@ +package syncer + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "idsync/internal/ldaplib" + "idsync/internal/mapping" + "idsync/internal/password" +) + +// Guard 是执行前的安全阈值。 +// +// 这些检查存在的意义是防住“最坏的一次同步”:飞书应用权限被收窄、 +// token 指向了错误的租户、或部门过滤配置写错,都会让期望状态突然大幅缩小。 +// 没有阈值的话,那种情况会一次性把大批账号移出 ou=people。 +type Guard struct { + // MaxDeactivate 单次允许停用的最大账号数,0 表示不限制。 + MaxDeactivate int + // MaxDeactivatePercent 单次允许停用的账号比例(0-1),0 表示不限制。 + MaxDeactivatePercent float64 + // AllowBulk 显式跳过全部阈值检查。 + AllowBulk bool +} + +// GuardError 表示计划未通过安全阈值检查。 +type GuardError struct { + Message string +} + +func (e *GuardError) Error() string { return e.Message } + +// Check 校验计划是否满足安全阈值。 +func (g Guard) Check(p *Plan) error { + if g.AllowBulk { + return nil + } + + deactivate := p.Stats.Deactivate + if deactivate == 0 { + return nil + } + + if g.MaxDeactivate > 0 && deactivate > g.MaxDeactivate { + return &GuardError{Message: fmt.Sprintf( + "本次计划要停用 %d 个账号,超过上限 %d;如确认无误,请使用强制模式重跑", + deactivate, g.MaxDeactivate)} + } + + if g.MaxDeactivatePercent > 0 && p.Stats.TotalAccounts > 0 { + ratio := float64(deactivate) / float64(p.Stats.TotalAccounts) + if ratio > g.MaxDeactivatePercent { + return &GuardError{Message: fmt.Sprintf( + "本次计划要停用 %d/%d 个账号(%.1f%%),超过比例上限 %.1f%%;如确认无误,请使用强制模式重跑", + deactivate, p.Stats.TotalAccounts, ratio*100, g.MaxDeactivatePercent*100)} + } + } + + return nil +} + +// Result 是执行结果统计。 +type Result struct { + Created int + Updated int + Moved int + Added int + Removed int + Failed int + Errors []string +} + +// Executor 把计划应用到 LDAP。 +type Executor struct { + client *ldaplib.Client + cfg ExecConfig + log *slog.Logger +} + +// ExecConfig 是执行器所需的环境参数。 +type ExecConfig struct { + UserOUDN string + InactiveOUDN string + GroupRootDN string + GroupRootName string + AdminDN string + // InitialPassword 非空时作为新建用户的固定初始密码;留空则逐个随机生成。 + InitialPassword string +} + +// NewExecutor 构造执行器。 +func NewExecutor(client *ldaplib.Client, cfg ExecConfig, log *slog.Logger) *Executor { + return &Executor{client: client, cfg: cfg, log: log} +} + +// Apply 执行计划。任何一步失败都会被记录,并继续处理后续操作—— +// 一次同步中某个用户的数据问题不应阻断其余几百人的同步。 +func (e *Executor) Apply(ctx context.Context, plan *Plan) *Result { + res := &Result{} + + conn, err := e.client.Dial() + if err != nil { + res.Failed++ + res.Errors = append(res.Errors, err.Error()) + return res + } + defer conn.Close() + + // 确保基础 OU 存在,否则后续写入会失败。 + // + // ou=people 与分组根 OU 在既有部署中通常已存在,但首次接管一个新目录、 + // 或它们被误删时,自动补齐能让同步直接可用,而不是抛出一串 + // 难以理解的 No Such Object。 + baseOUs := []struct{ dn, name string }{ + {e.cfg.UserOUDN, ouName(e.cfg.UserOUDN)}, + {e.cfg.InactiveOUDN, ouName(e.cfg.InactiveOUDN)}, + {e.cfg.GroupRootDN, e.cfg.GroupRootName}, + } + for _, ou := range baseOUs { + if ou.dn == "" { + continue + } + if err := conn.EnsureOU(ou.dn, ou.name); err != nil { + res.Failed++ + res.Errors = append(res.Errors, err.Error()) + return res + } + } + + for _, op := range plan.Ops { + if err := ctx.Err(); err != nil { + res.Errors = append(res.Errors, "同步被取消: "+err.Error()) + return res + } + + if err := e.applyOne(conn, op); err != nil { + res.Failed++ + msg := fmt.Sprintf("%s(%s): %s", op.Kind, op.Target, err.Error()) + res.Errors = append(res.Errors, msg) + e.log.Error("执行操作失败", "kind", op.Kind, "target", op.Target, "err", err) + continue + } + + switch op.Kind { + case OpCreateUser: + res.Created++ + case OpUpdateUser, OpReactivate: + res.Updated++ + if op.Kind == OpReactivate { + res.Moved++ + } + case OpDeactivate: + res.Updated++ + res.Moved++ + case OpAddMember: + res.Added++ + case OpRemoveMember: + res.Removed++ + } + } + + return res +} + +// applyOne 执行单个操作。 +func (e *Executor) applyOne(conn *ldaplib.Conn, op Op) error { + switch op.Kind { + case OpCreateGroup: + // 组必须先于成员操作存在;即使用户操作排在前面,这里也幂等。 + return conn.AddGroup(op.DN, op.Group.Name, op.Group.Name, e.cfg.AdminDN) + + case OpUpdateGroup: + return conn.UpdateGroup(op.DN, op.Group.Name) + + case OpCreateUser: + plain := e.cfg.InitialPassword + if plain == "" { + generated, err := password.Random() + if err != nil { + return err + } + plain = generated + } + // 必须自行哈希:slapd 不会对普通 Modify 写入的 userPassword 自动加盐哈希, + // 直接写明文会让密码以明文形式留在目录里。 + encoded, err := password.EncodeSSHA(plain) + if err != nil { + return err + } + return conn.AddUser(toLDAPUser(op.User), e.cfg.UserOUDN, encoded) + + case OpUpdateUser: + // 若 DN 因 OU 配置变化而不同,先移动再更新属性。 + if op.TargetDN != "" && !strings.EqualFold(op.TargetDN, op.DN) { + uid := op.User.UID + if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil { + return err + } + } + // 读取现状:UpdateUser 需要据此决定哪些属性应当删除, + // 对不存在的属性执行 Delete 会导致整个 Modify 失败。 + existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs()) + if err != nil { + return err + } + return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing) + + case OpDeactivate: + // 移出原 OU 即离开 Authelia 的搜索范围,等同于禁止登录; + // 同时不改除 employeeType 外的任何属性,密码与历史属性都完整保留, + // 便于日后重新入职时原样恢复。 + uid := op.Target + if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.InactiveOUDN); err != nil { + return err + } + return conn.SetEmployeeType(ldaplib.UserDN(uid, e.cfg.InactiveOUDN), mapping.EmployeeTypeResigned) + + case OpReactivate: + uid := op.User.UID + if err := conn.MoveUser(op.TargetDN, "uid="+ldaplib.EscapeDN(uid), e.cfg.UserOUDN); err != nil { + return err + } + if err := conn.SetEmployeeType(op.DN, mapping.EmployeeTypeActive); err != nil { + return err + } + existing, err := conn.GetEntry(op.DN, ldaplib.UserReadAttrs()) + if err != nil { + return err + } + return conn.UpdateUser(op.DN, toLDAPUser(op.User), existing) + + case OpAddMember: + return conn.AddMember(op.GroupDN, op.MemberDN) + + case OpRemoveMember: + return conn.RemoveMember(op.GroupDN, op.MemberDN) + + default: + return fmt.Errorf("未知操作类型: %s", op.Kind) + } +} + +// toLDAPUser 把期望状态转成 LDAP 写入结构。 +func toLDAPUser(u *mapping.User) *ldaplib.User { + return &ldaplib.User{ + UID: u.UID, + CN: u.CN, + SN: u.SN, + GivenName: u.GivenName, + DisplayName: u.DisplayName, + Mail: u.Mail, + Mobile: u.Mobile, + EmployeeNumber: u.EmployeeNumber, + DepartmentNumber: u.DepartmentNumber, + PostalAddress: u.PostalAddress, + BusinessCategory: u.BusinessCategory, + EmployeeType: u.EmployeeType, + Description: u.Description, + } +} + +// ouName 从 OU 的 DN 中取出 ou 的值。 +func ouName(dn string) string { + first := dn + if i := strings.Index(dn, ","); i >= 0 { + first = dn[:i] + } + if i := strings.Index(first, "="); i >= 0 { + return first[i+1:] + } + return first +} diff --git a/internal/syncer/plan.go b/internal/syncer/plan.go new file mode 100644 index 0000000..e75b798 --- /dev/null +++ b/internal/syncer/plan.go @@ -0,0 +1,524 @@ +// Package syncer 计算期望状态与 LDAP 实际状态之间的差异,并负责执行。 +// +// 设计要点: +// - ComputePlan 是纯函数:输入快照与台账,输出操作列表,不触碰任何 IO。 +// 因此它能被完整单测,且 dry-run 与真正执行共享同一份代码——预览即所得。 +// - 所有权来自台账:只有台账记录在案的条目才会被修改或停用。台账之外的 +// 条目(服务账号、手工账号、admin)永远不会出现在计划里,因此不可能被误伤。 +package syncer + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "idsync/internal/ldaplib" + "idsync/internal/mapping" +) + +// OpKind 是操作类型。 +type OpKind string + +const ( + OpCreateUser OpKind = "create_user" + OpUpdateUser OpKind = "update_user" + OpDeactivate OpKind = "deactivate_user" + OpReactivate OpKind = "reactivate_user" + OpCreateGroup OpKind = "create_group" + OpUpdateGroup OpKind = "update_group" + OpAddMember OpKind = "add_member" + OpRemoveMember OpKind = "remove_member" +) + +// Op 是一次待执行(或仅预览)的操作。 +type Op struct { + Kind OpKind `json:"kind"` + // Target 是操作对象的稳定标识:用户为 uid,分组为 dept_id。 + Target string `json:"target"` + // Name 是展示名,方便界面阅读。 + Name string `json:"name"` + // DN 是操作涉及的条目 DN(新建时为目标 DN)。 + DN string `json:"dn,omitempty"` + // Detail 是人类可读的说明,直接展示在预览页。 + Detail string `json:"detail"` + + // 以下字段供执行器使用,不参与 JSON 展示。 + User *mapping.User `json:"-"` + Group *mapping.Group `json:"-"` + MemberDN string `json:"-"` + GroupDN string `json:"-"` + // TargetDN 是操作对象的现有 DN(用于移动/删除已存在的条目)。 + TargetDN string `json:"-"` + AttrsHash string `json:"-"` + // Seq 记录该操作的生成顺序,用于在同一阶段内保持依赖顺序 + // (例如父分组必须先于子分组创建)。 + Seq int `json:"-"` +} + +// Plan 是一次同步的完整计划。 +type Plan struct { + Ops []Op `json:"ops"` + // Warnings 是不阻塞执行、但需要人工留意的信息。 + Warnings []string `json:"warnings"` + Stats Stats `json:"stats"` +} + +// Stats 是计划的分类统计。 +type Stats struct { + CreateUser int `json:"createUser"` + UpdateUser int `json:"updateUser"` + Deactivate int `json:"deactivateUser"` + Reactivate int `json:"reactivateUser"` + CreateGroup int `json:"createGroup"` + UpdateGroup int `json:"updateGroup"` + AddMember int `json:"addMember"` + RemoveMember int `json:"removeMember"` + TotalAccounts int `json:"totalAccounts"` + Unchanged int `json:"unchanged"` +} + +// Input 是计算计划所需的全部输入。 +type Input struct { + // Desired 是期望状态(来自飞书) + Desired *mapping.State + // Active 是 ou=people 下现有用户:uid(小写)→ 条目 + Active map[string]*ldaplib.Entry + // Inactive 是 ou=Inactive 下现有用户:uid(小写)→ 条目 + Inactive map[string]*ldaplib.Entry + // Groups 是现有分组:DN(小写)→ 条目 + Groups map[string]*ldaplib.Entry + // LedgerAccounts 是台账中的账号:uid → state(active/inactive) + LedgerAccounts map[string]string + // LedgerGroups 是台账中的分组:dept_id → dn + LedgerGroups map[string]string + // AdminDN 与相关基础 DN + AdminDN string + GroupRootDN string + UserOUDN string + InactiveOUDN string +} + +// opPhase 决定操作的执行顺序。 +// +// 顺序是硬约束而非偏好:分组必须先于成员关系存在,用户必须先于其成员关系 +// 存在,而停用必须最后执行(否则会把刚加进组的用户又移出去)。 +// 早期版本按操作名字母序排序,导致 add_member 排在 create_group 之前, +// 集成测试立刻暴露了这个问题。 +func opPhase(k OpKind) int { + switch k { + case OpCreateGroup, OpUpdateGroup: + return 0 + case OpCreateUser, OpUpdateUser: + return 1 + case OpReactivate: + return 2 + case OpAddMember: + return 3 + case OpRemoveMember: + return 4 + case OpDeactivate: + return 5 + default: + return 6 + } +} + +// sortOps 按依赖顺序排列操作。 +// +// 阶段决定大类顺序;同阶段内按 Seq(生成顺序)而非字母序,这很关键: +// 分组必须父先于子创建,而字母序无法保证这一点(例如 “od-yanfa” 会排在 +// “od-root” 之前,导致子分组先于父分组创建而失败)。 +func sortOps(ops []Op) { + sort.SliceStable(ops, func(i, j int) bool { + pi, pj := opPhase(ops[i].Kind), opPhase(ops[j].Kind) + if pi != pj { + return pi < pj + } + return ops[i].Seq < ops[j].Seq + }) +} + +// ComputePlan 计算同步计划。它是纯函数,不产生任何副作用。 +func ComputePlan(in *Input) *Plan { + p := &Plan{ + Ops: make([]Op, 0, 64), + Warnings: append([]string{}, in.Desired.Warnings...), + Stats: Stats{ + TotalAccounts: len(in.LedgerAccounts), + }, + } + + planGroups(p, in) + planUsers(p, in) + planMembership(p, in) + + // 打上生成序号后排序:阶段决定大类顺序,序号保证同阶段内的依赖顺序 + // (mapping 已按父先于子的顺序产出分组)。 + for i := range p.Ops { + p.Ops[i].Seq = i + } + sortOps(p.Ops) + + return p +} + +// planGroups 处理分组的创建与更新。 +func planGroups(p *Plan, in *Input) { + for _, g := range in.Desired.Groups { + existing, exists := in.Groups[strings.ToLower(g.DN)] + + if !exists { + p.Ops = append(p.Ops, Op{ + Kind: OpCreateGroup, + Target: g.DeptID, + Name: g.Name, + DN: g.DN, + Detail: fmt.Sprintf("创建部门分组 %s (%s)", g.Name, g.DN), + Group: g, + }) + p.Stats.CreateGroup++ + continue + } + + // 分组已存在:仅当描述不一致时更新。 + // 说明:分组 DN 由部门名推导,部门改名会导致 DN 变化,此时会在下一轮 + // 以“新 DN 不存在”的形式创建新组,旧组成为孤儿——这需要人工处理, + // 因此这里显式告警而不是自动改名(自动改名会牵动成员与 ACL)。 + if desc := existing.Get(ldaplib.AttrDescription); desc != g.Name { + p.Ops = append(p.Ops, Op{ + Kind: OpUpdateGroup, + Target: g.DeptID, + Name: g.Name, + DN: g.DN, + Detail: fmt.Sprintf("更新部门分组描述 %s: %q → %q", g.Name, desc, g.Name), + Group: g, + }) + p.Stats.UpdateGroup++ + } + } + + // 台账中存在、但期望状态里已消失的分组:不自动删除。 + // 部门被删除往往意味着组织调整,删除组会连带丢失成员关系,值得人工确认。 + wantedDept := make(map[string]string, len(in.Desired.Groups)) + for _, g := range in.Desired.Groups { + wantedDept[g.DeptID] = g.DN + } + for deptID, dn := range in.LedgerGroups { + wantDN, stillWanted := wantedDept[deptID] + switch { + case !stillWanted: + p.Warnings = append(p.Warnings, + fmt.Sprintf("分组 %s (%s) 已从飞书消失,未自动删除,请人工确认", deptID, dn)) + case !strings.EqualFold(wantDN, dn): + // 部门改名会改变其 CN 进而改变 DN。自动改名会牵动成员的 + // uniqueMember 引用与依赖该 DN 的 ACL,因此交由人工处理。 + p.Warnings = append(p.Warnings, + fmt.Sprintf("分组 %s 的 DN 已变化:%s → %s,需要人工改名(自动改名会影响 ACL 与成员引用)", + deptID, dn, wantDN)) + } + } +} + +// planUsers 处理用户的创建、更新、停用与恢复。 +func planUsers(p *Plan, in *Input) { + desiredByUID := make(map[string]*mapping.User, len(in.Desired.Users)) + for _, u := range in.Desired.Users { + desiredByUID[u.UID] = u + } + + for _, u := range in.Desired.Users { + _, inActiveOU := in.Active[u.UID] + _, inInactiveOU := in.Inactive[u.UID] + + switch { + case inActiveOU: + // 已存在且在职:比对属性是否漂移。 + existing := in.Active[u.UID] + canonical := strings.ToLower(existing.DN) + + // DN 不匹配(例如所属 OU 配置变更导致 DN 变化)需要移动条目。 + // 用大小写无关比较:LDAP 服务端可能对 DN 做规范化, + // 直接字符串比较会造成每轮都产生无意义的“更新”。 + if !strings.EqualFold(existing.DN, u.DN) { + p.Ops = append(p.Ops, Op{ + Kind: OpUpdateUser, + Target: u.UID, + Name: u.DisplayName, + DN: u.DN, + Detail: fmt.Sprintf("用户 %s 的 DN 需要校正:%s → %s", u.UID, existing.DN, u.DN), + User: u, + TargetDN: canonical, + }) + p.Stats.UpdateUser++ + continue + } + + if attrsDiffer(existing, u) { + p.Ops = append(p.Ops, Op{ + Kind: OpUpdateUser, + Target: u.UID, + Name: u.DisplayName, + DN: u.DN, + Detail: "用户属性发生变化:" + describeAttrDiff(existing, u), + User: u, + TargetDN: canonical, + }) + p.Stats.UpdateUser++ + continue + } + p.Stats.Unchanged++ + + case inInactiveOU: + // 存在于 ou=Inactive 但期望是在职:恢复。 + // 仅当台账确认该账号由我们管理时才动它。 + if _, managed := in.LedgerAccounts[u.UID]; !managed { + p.Warnings = append(p.Warnings, + fmt.Sprintf("uid=%s 存在于 %s 但不在台账中,不予恢复", u.UID, in.InactiveOUDN)) + continue + } + p.Ops = append(p.Ops, Op{ + Kind: OpReactivate, + Target: u.UID, + Name: u.DisplayName, + DN: u.DN, + Detail: fmt.Sprintf("用户 %s 重新入职,移回 %s", u.UID, in.UserOUDN), + User: u, + TargetDN: strings.ToLower(in.Inactive[u.UID].DN), + }) + p.Stats.Reactivate++ + + default: + // 两处都没有:新建。 + p.Ops = append(p.Ops, Op{ + Kind: OpCreateUser, + Target: u.UID, + Name: u.DisplayName, + DN: u.DN, + Detail: fmt.Sprintf("创建用户 %s (%s) 于 %s", u.UID, u.DisplayName, in.UserOUDN), + User: u, + }) + p.Stats.CreateUser++ + } + } + + // 台账中在职、但期望状态里已消失:候选停用。 + // 注意这里只标记为候选,真正的离职确认由调用方通过飞书接口完成。 + for uid, state := range in.LedgerAccounts { + if state != "active" { + continue + } + if _, ok := desiredByUID[uid]; ok { + continue + } + entry, ok := in.Active[uid] + if !ok { + // 台账说在职但 LDAP 里找不到:数据不一致,仅告警。 + p.Warnings = append(p.Warnings, + fmt.Sprintf("台账记录 %s 为在职,但 %s 中不存在该条目", uid, in.UserOUDN)) + continue + } + p.Ops = append(p.Ops, Op{ + Kind: OpDeactivate, + Target: uid, + Name: entry.Get(ldaplib.AttrDisplayName), + DN: ldaplib.UserDN(uid, in.InactiveOUDN), + Detail: fmt.Sprintf("用户 %s 已离职,移入 %s", uid, in.InactiveOUDN), + TargetDN: strings.ToLower(entry.DN), + }) + p.Stats.Deactivate++ + } +} + +// planMembership 比对分组内的 uniqueMember,产出加组/移组操作。 +func planMembership(p *Plan, in *Input) { + for _, g := range in.Desired.Groups { + existing, ok := in.Groups[strings.ToLower(g.DN)] + if !ok { + // 分组是新建的,成员关系会在创建后由执行器处理; + // 这里仍生成加组操作,因为新建组的初始成员只有 admin(或为空)。 + for memberDN := range g.Members { + p.Ops = append(p.Ops, Op{ + Kind: OpAddMember, + Target: g.DeptID, + Name: g.Name, + Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name), + Group: g, + GroupDN: g.DN, + MemberDN: memberDN, + }) + p.Stats.AddMember++ + } + continue + } + + actual := existing.Members() + + // 期望有、实际没有 → 加入 + for memberDN := range g.Members { + if _, has := actual[strings.ToLower(memberDN)]; has { + continue + } + p.Ops = append(p.Ops, Op{ + Kind: OpAddMember, + Target: g.DeptID, + Name: g.Name, + Detail: fmt.Sprintf("将 %s 加入 %s", memberRDN(memberDN), g.Name), + Group: g, + GroupDN: existing.DN, + MemberDN: memberDN, + }) + p.Stats.AddMember++ + } + + // 实际有、期望没有 → 移除。但永不移除 admin,避免把管理员踢出组。 + for memberDN := range actual { + if _, want := g.Members[memberDN]; want { + continue + } + if in.AdminDN != "" && strings.EqualFold(memberDN, in.AdminDN) { + continue + } + // 只移除我们管理的用户,避免把外部手工加入的成员踢掉。 + if !isManagedMember(memberDN, in) { + p.Warnings = append(p.Warnings, + fmt.Sprintf("%s 中的成员 %s 非本工具管理,未移除", g.Name, memberRDN(memberDN))) + continue + } + p.Ops = append(p.Ops, Op{ + Kind: OpRemoveMember, + Target: g.DeptID, + Name: g.Name, + Detail: fmt.Sprintf("将 %s 移出 %s", memberRDN(memberDN), g.Name), + Group: g, + GroupDN: existing.DN, + MemberDN: memberDN, + }) + p.Stats.RemoveMember++ + } + } +} + +// isManagedMember 判断某个成员 DN 是否属于本工具管理的范围。 +// +// 判断依据是“该 DN 对应的 uid 在台账中”。台账是唯一的所有权来源, +// 因此手工创建的账号即使被手工加入部门组,也不会被自动移除。 +func isManagedMember(memberDN string, in *Input) bool { + uid, ok := uidFromDN(memberDN) + if !ok { + return false + } + if _, managed := in.LedgerAccounts[uid]; managed { + return true + } + // 本轮新建的用户此时尚未进入台账,也应当被视为受管。 + for _, u := range in.Desired.Users { + if u.UID == uid { + return true + } + } + return false +} + +// uidFromDN 从形如 uid=xxx,ou=... 的 DN 中取出 uid。 +func uidFromDN(dn string) (string, bool) { + first := dn + if i := strings.Index(dn, ","); i >= 0 { + first = dn[:i] + } + first = strings.TrimSpace(first) + if len(first) < 4 || !strings.EqualFold(first[:4], "uid=") { + return "", false + } + return strings.ToLower(strings.TrimSpace(first[4:])), true +} + +// memberRDN 从成员 DN 中提取用于展示的标识。 +func memberRDN(dn string) string { + if uid, ok := uidFromDN(dn); ok { + return uid + } + return dn +} + +// attrsDiffer 判断 LDAP 现有属性与期望是否不一致。 +func attrsDiffer(existing *ldaplib.Entry, want *mapping.User) bool { + checks := []struct { + attr string + want string + }{ + {ldaplib.AttrCN, want.CN}, + {ldaplib.AttrSN, want.SN}, + {ldaplib.AttrGivenName, want.GivenName}, + {ldaplib.AttrDisplayName, want.DisplayName}, + {ldaplib.AttrMail, want.Mail}, + {ldaplib.AttrMobile, want.Mobile}, + {ldaplib.AttrEmployeeNumber, want.EmployeeNumber}, + {ldaplib.AttrDepartmentNumber, want.DepartmentNumber}, + {ldaplib.AttrPostalAddress, want.PostalAddress}, + {ldaplib.AttrBusinessCategory, want.BusinessCategory}, + {ldaplib.AttrDescription, want.Description}, + // employeeType 也会被写入,必须纳入比对,否则预览会漏报这项变更。 + {ldaplib.AttrEmployeeType, want.EmployeeType}, + } + for _, c := range checks { + if normalizeAttr(existing.Get(c.attr)) != normalizeAttr(c.want) { + return true + } + } + return false +} + +// describeAttrDiff 生成人类可读的属性差异摘要,用于预览页。 +func describeAttrDiff(existing *ldaplib.Entry, want *mapping.User) string { + fields := []struct { + label string + attr string + want string + }{ + {"mail", ldaplib.AttrMail, want.Mail}, + {"displayName", ldaplib.AttrDisplayName, want.DisplayName}, + {"mobile", ldaplib.AttrMobile, want.Mobile}, + {"employeeNumber", ldaplib.AttrEmployeeNumber, want.EmployeeNumber}, + {"departmentNumber", ldaplib.AttrDepartmentNumber, want.DepartmentNumber}, + {"postalAddress", ldaplib.AttrPostalAddress, want.PostalAddress}, + {"businessCategory", ldaplib.AttrBusinessCategory, want.BusinessCategory}, + {"givenName", ldaplib.AttrGivenName, want.GivenName}, + {"sn", ldaplib.AttrSN, want.SN}, + {"employeeType", ldaplib.AttrEmployeeType, want.EmployeeType}, + } + + var diffs []string + for _, f := range fields { + old := existing.Get(f.attr) + if normalizeAttr(old) == normalizeAttr(f.want) { + continue + } + diffs = append(diffs, fmt.Sprintf("%s: %q → %q", f.label, old, f.want)) + } + if len(diffs) == 0 { + return "属性无变化" + } + return strings.Join(diffs, "; ") +} + +// normalizeAttr 归一化属性值用于比较:忽略首尾空白与大小写差异, +// 避免因为无关紧要的格式差异产生大量无意义的更新操作。 +func normalizeAttr(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +// AttrsHash 计算用户属性的指纹,供台账记录“上次写入的内容”。 +func AttrsHash(u *mapping.User) string { + h := sha256.New() + for _, v := range []string{ + u.CN, u.SN, u.GivenName, u.DisplayName, u.Mail, u.Mobile, + u.EmployeeNumber, u.DepartmentNumber, u.PostalAddress, + u.BusinessCategory, u.Description, + } { + h.Write([]byte(v)) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/syncer/plan_test.go b/internal/syncer/plan_test.go new file mode 100644 index 0000000..4a1c303 --- /dev/null +++ b/internal/syncer/plan_test.go @@ -0,0 +1,435 @@ +package syncer + +import ( + "strings" + "testing" + + "idsync/internal/ldaplib" + "idsync/internal/mapping" +) + +const ( + testUserOU = "ou=people,dc=example,dc=com" + testInactiveOU = "ou=Inactive,dc=example,dc=com" + testGroupRoot = "ou=feishuroot,dc=example,dc=com" + testAdminDN = "cn=admin,dc=example,dc=com" +) + +// entry 构造一个 LDAP 条目,便于测试。 +func entry(dn string, attrs map[string]string, members []string) *ldaplib.Entry { + return ldaplib.NewEntry(dn, attrs, members) +} + +func newUser(uid string) *mapping.User { + return &mapping.User{ + UID: uid, + DN: "uid=" + uid + "," + testUserOU, + InactiveDN: "uid=" + uid + "," + testInactiveOU, + CN: uid, + SN: uid, + GivenName: uid, + DisplayName: uid, + Mail: uid + "@example.com", + Description: uid, + } +} + +func baseInput(desired *mapping.State) *Input { + return &Input{ + Desired: desired, + Active: map[string]*ldaplib.Entry{}, + Inactive: map[string]*ldaplib.Entry{}, + Groups: map[string]*ldaplib.Entry{}, + LedgerAccounts: map[string]string{}, + LedgerGroups: map[string]string{}, + AdminDN: testAdminDN, + GroupRootDN: testGroupRoot, + UserOUDN: testUserOU, + InactiveOUDN: testInactiveOU, + } +} + +func kindsOf(plan *Plan) map[OpKind]int { + out := make(map[OpKind]int) + for _, op := range plan.Ops { + out[op.Kind]++ + } + return out +} + +func TestComputePlan(t *testing.T) { + tests := []struct { + name string + desired *mapping.State + mutate func(in *Input) + wantKinds map[OpKind]int + // wantDetailContains 断言某个操作详情包含特定文本 + wantDetailContains string + // forbidKinds 断言这些操作绝不出现(安全性回归) + forbidKinds []OpKind + }{ + { + name: "全新用户:创建", + desired: &mapping.State{ + Users: []*mapping.User{newUser("alice")}, + GroupByDeptID: map[string]*mapping.Group{}, + }, + wantKinds: map[OpKind]int{OpCreateUser: 1}, + }, + { + name: "属性一致:不产生操作", + desired: &mapping.State{ + Users: []*mapping.User{newUser("alice")}, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + u := newUser("alice") + in.Active["alice"] = entry(u.DN, map[string]string{ + ldaplib.AttrCN: u.CN, + ldaplib.AttrSN: u.SN, + ldaplib.AttrGivenName: u.GivenName, + ldaplib.AttrDisplayName: u.DisplayName, + ldaplib.AttrMail: u.Mail, + ldaplib.AttrMobile: u.Mobile, + ldaplib.AttrEmployeeNumber: u.EmployeeNumber, + ldaplib.AttrDepartmentNumber: u.DepartmentNumber, + ldaplib.AttrPostalAddress: u.PostalAddress, + ldaplib.AttrBusinessCategory: u.BusinessCategory, + ldaplib.AttrDescription: u.Description, + }, nil) + in.LedgerAccounts["alice"] = "active" + }, + wantKinds: map[OpKind]int{}, + }, + { + name: "邮箱变化:更新属性", + desired: &mapping.State{ + Users: []*mapping.User{ + func() *mapping.User { u := newUser("alice"); u.Mail = "alice.new@example.com"; return u }(), + }, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + u := newUser("alice") + in.Active["alice"] = entry(u.DN, map[string]string{ + ldaplib.AttrCN: u.CN, + ldaplib.AttrDisplayName: u.DisplayName, + ldaplib.AttrMail: "old@example.com", + }, nil) + in.LedgerAccounts["alice"] = "active" + }, + wantKinds: map[OpKind]int{OpUpdateUser: 1}, + wantDetailContains: "alice.new@example.com", + }, + { + name: "台账在职但飞书消失:停用", + desired: &mapping.State{ + Users: []*mapping.User{}, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{ + ldaplib.AttrDisplayName: "Bob", + }, nil) + in.LedgerAccounts["bob"] = "active" + }, + wantKinds: map[OpKind]int{OpDeactivate: 1}, + }, + { + name: "非台账账号缺席:不得停用(关键安全回归)", + desired: &mapping.State{ + Users: []*mapping.User{}, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + // LDAP 中存在但台账里没有 —— 例如手工创建的服务账号。 + in.Active["svc"] = entry("uid=svc,"+testUserOU, map[string]string{ + ldaplib.AttrDisplayName: "Service", + }, nil) + }, + wantKinds: map[OpKind]int{}, + forbidKinds: []OpKind{OpDeactivate}, + }, + { + name: "在 Inactive 且台账受管:恢复", + desired: &mapping.State{ + Users: []*mapping.User{newUser("carol")}, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + in.Inactive["carol"] = entry("uid=carol,"+testInactiveOU, map[string]string{ + ldaplib.AttrDisplayName: "Carol", + }, nil) + in.LedgerAccounts["carol"] = "inactive" + }, + wantKinds: map[OpKind]int{OpReactivate: 1}, + }, + { + name: "在 Inactive 但非台账:只告警不改动", + desired: &mapping.State{ + Users: []*mapping.User{newUser("dave")}, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + in.Inactive["dave"] = entry("uid=dave,"+testInactiveOU, map[string]string{}, nil) + }, + wantKinds: map[OpKind]int{}, + forbidKinds: []OpKind{OpReactivate}, + }, + { + name: "分组不存在:创建并加成员", + desired: &mapping.State{ + Users: []*mapping.User{}, + Groups: []*mapping.Group{ + { + DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", + Members: map[string]struct{}{"uid=alice," + testUserOU: {}}, + }, + }, + GroupByDeptID: map[string]*mapping.Group{}, + }, + wantKinds: map[OpKind]int{OpCreateGroup: 1, OpAddMember: 1}, + }, + { + name: "成员缺席:加入分组", + desired: &mapping.State{ + Users: []*mapping.User{}, + Groups: []*mapping.Group{ + { + DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", + Members: map[string]struct{}{"uid=alice," + testUserOU: {}}, + }, + }, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( + "cn=yanfa,"+testGroupRoot, + map[string]string{ldaplib.AttrDescription: "研发部"}, + []string{testAdminDN}) + in.LedgerAccounts["alice"] = "active" + }, + wantKinds: map[OpKind]int{OpAddMember: 1}, + }, + { + name: "成员多余且受管:移出分组", + desired: &mapping.State{ + Users: []*mapping.User{}, + Groups: []*mapping.Group{ + { + DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", + Members: map[string]struct{}{}, + }, + }, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( + "cn=yanfa,"+testGroupRoot, + map[string]string{ldaplib.AttrDescription: "研发部"}, + []string{testAdminDN, "uid=alice," + testUserOU}) + in.LedgerAccounts["alice"] = "active" + }, + wantKinds: map[OpKind]int{OpRemoveMember: 1}, + }, + { + name: "admin 永不被移出分组(关键安全回归)", + desired: &mapping.State{ + Users: []*mapping.User{}, + Groups: []*mapping.Group{ + { + DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", + Members: map[string]struct{}{}, + }, + }, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( + "cn=yanfa,"+testGroupRoot, + map[string]string{ldaplib.AttrDescription: "研发部"}, + []string{testAdminDN}) + }, + wantKinds: map[OpKind]int{}, + forbidKinds: []OpKind{OpRemoveMember}, + }, + { + // 这个用例刻意把 admin 造成 uid 形式并放进台账,从而让 + // isManagedMember 判定为“受管”。只有专门的 admin 保护逻辑 + // 才能阻止它被移出——否则该用例会失败。没有这层构造, + // 前面的 admin 用例会被 isManagedMember 顺带保护而失去意义。 + name: "admin 即使形如 uid 且在台账中也不被移出(关键安全回归)", + desired: &mapping.State{ + Users: []*mapping.User{}, + Groups: []*mapping.Group{ + { + DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", + Members: map[string]struct{}{}, + }, + }, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + adminUIDDN := "uid=admin," + testUserOU + in.AdminDN = adminUIDDN + in.LedgerAccounts["admin"] = "active" + in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( + "cn=yanfa,"+testGroupRoot, + map[string]string{ldaplib.AttrDescription: "研发部"}, + []string{adminUIDDN}) + }, + wantKinds: map[OpKind]int{}, + forbidKinds: []OpKind{OpRemoveMember}, + }, + { + name: "外部手工成员:不移出,仅告警(关键安全回归)", + desired: &mapping.State{ + Users: []*mapping.User{}, + Groups: []*mapping.Group{ + { + DeptID: "d1", DN: "cn=yanfa," + testGroupRoot, Name: "研发部", + Members: map[string]struct{}{}, + }, + }, + GroupByDeptID: map[string]*mapping.Group{}, + }, + mutate: func(in *Input) { + in.Groups[strings.ToLower("cn=yanfa,"+testGroupRoot)] = entry( + "cn=yanfa,"+testGroupRoot, + map[string]string{ldaplib.AttrDescription: "研发部"}, + []string{testAdminDN, "uid=external," + testUserOU}) + }, + wantKinds: map[OpKind]int{}, + forbidKinds: []OpKind{OpRemoveMember}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := baseInput(tt.desired) + if tt.mutate != nil { + tt.mutate(in) + } + + plan := ComputePlan(in) + got := kindsOf(plan) + + for kind, want := range tt.wantKinds { + if got[kind] != want { + t.Errorf("操作 %s 数量 = %d, 期望 %d\n计划详情: %s", + kind, got[kind], want, dumpPlan(plan)) + } + } + for kind, count := range got { + if _, expected := tt.wantKinds[kind]; !expected { + t.Errorf("出现了未预期的操作 %s(%d 次)\n计划详情: %s", + kind, count, dumpPlan(plan)) + } + } + for _, kind := range tt.forbidKinds { + if got[kind] > 0 { + t.Errorf("出现了被禁止的操作 %s\n计划详情: %s", kind, dumpPlan(plan)) + } + } + if tt.wantDetailContains != "" { + found := false + for _, op := range plan.Ops { + if strings.Contains(op.Detail, tt.wantDetailContains) { + found = true + break + } + } + if !found { + t.Errorf("没有任何操作的详情包含 %q\n计划详情: %s", + tt.wantDetailContains, dumpPlan(plan)) + } + } + }) + } +} + +// TestComputePlanNeverTouchesPassword 是一个结构性回归测试: +// 无论输入如何,计划中都不能出现改写 userPassword 的操作。 +// 这防止将来有人“顺手”把密码同步加回来,覆盖 Authelia 重置的密码。 +func TestComputePlanNeverTouchesPassword(t *testing.T) { + u := newUser("alice") + in := baseInput(&mapping.State{ + Users: []*mapping.User{u}, + GroupByDeptID: map[string]*mapping.Group{}, + }) + in.LedgerAccounts["bob"] = "active" + in.Active["bob"] = entry("uid=bob,"+testUserOU, map[string]string{}, nil) + + plan := ComputePlan(in) + for _, op := range plan.Ops { + if strings.Contains(strings.ToLower(op.Detail), "userpassword") { + t.Errorf("计划中出现涉及 userPassword 的操作: %s", op.Detail) + } + } +} + +func TestGuard(t *testing.T) { + tests := []struct { + name string + guard Guard + plan *Plan + wantErr bool + }{ + { + name: "无停用时通过", + guard: Guard{MaxDeactivatePercent: 0.2}, + plan: &Plan{Stats: Stats{Deactivate: 0, TotalAccounts: 100}}, + }, + { + name: "超过绝对上限被拒", + guard: Guard{MaxDeactivate: 5}, + plan: &Plan{Stats: Stats{Deactivate: 6, TotalAccounts: 100}}, + wantErr: true, + }, + { + name: "超过比例上限被拒", + guard: Guard{MaxDeactivatePercent: 0.2}, + plan: &Plan{Stats: Stats{Deactivate: 30, TotalAccounts: 100}}, + wantErr: true, + }, + { + name: "比例在阈值内通过", + guard: Guard{MaxDeactivatePercent: 0.2}, + plan: &Plan{Stats: Stats{Deactivate: 10, TotalAccounts: 100}}, + }, + { + name: "强制模式跳过检查", + guard: Guard{MaxDeactivate: 1, AllowBulk: true}, + plan: &Plan{Stats: Stats{Deactivate: 50, TotalAccounts: 100}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.guard.Check(tt.plan) + if tt.wantErr && err == nil { + t.Errorf("期望返回错误,实际通过") + } + if !tt.wantErr && err != nil { + t.Errorf("期望通过,实际报错: %v", err) + } + }) + } +} + +func dumpPlan(p *Plan) string { + var b strings.Builder + for _, op := range p.Ops { + b.WriteString("\n ") + b.WriteString(string(op.Kind)) + b.WriteString(" target=") + b.WriteString(op.Target) + b.WriteString(" detail=") + b.WriteString(op.Detail) + } + for _, w := range p.Warnings { + b.WriteString("\n WARN ") + b.WriteString(w) + } + return b.String() +} diff --git a/internal/syncer/runner.go b/internal/syncer/runner.go new file mode 100644 index 0000000..c947870 --- /dev/null +++ b/internal/syncer/runner.go @@ -0,0 +1,364 @@ +package syncer + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "idsync/internal/config" + "idsync/internal/feishu" + "idsync/internal/ldaplib" + "idsync/internal/mapping" + "idsync/internal/store" +) + +// Runner 编排一次完整的同步:读取飞书 → 读取 LDAP → 计算计划 → (可选)执行 → 回写台账。 +type Runner struct { + cfg *config.Config + feishu *feishu.Client + ldap *ldaplib.Client + store *store.Store + log *slog.Logger + + // guard 是执行前的安全阈值。 + guard Guard +} + +// NewRunner 构造编排器。 +func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, s *store.Store, log *slog.Logger) *Runner { + return &Runner{ + cfg: cfg, + feishu: f, + ldap: l, + store: s, + log: log, + guard: Guard{ + MaxDeactivate: cfg.Sync.MaxDeactivate, + MaxDeactivatePercent: cfg.Sync.MaxDeactivatePercent, + }, + } +} + +// Preview 只计算计划,不做任何写入。dry-run 与实际执行走同一份计划代码, +// 因此预览内容与真正会发生的事情一致。 +func (r *Runner) Preview(ctx context.Context) (*Plan, error) { + plan, _, err := r.plan(ctx) + return plan, err +} + +// Run 执行一次同步。dryRun 为 true 时只计算并返回计划。 +func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) { + run, err := r.store.StartRun(modeOf(dryRun), trigger) + if err != nil { + return nil, nil, fmt.Errorf("创建同步记录失败: %w", err) + } + + plan, desired, err := r.prepare(ctx, run, force) + if err != nil { + return plan, nil, err + } + if dryRun { + fillRunFromPlan(run, plan) + run.Status = "success" + if err := r.store.FinishRun(run); err != nil { + r.log.Warn("写入同步记录失败", "err", err) + } + return plan, nil, nil + } + + res := r.apply(ctx, plan) + fillRunFromResult(run, plan, res) + + // 只有全部成功才回写台账:部分失败时保持台账不变, + // 让下一轮重新计算差异,避免把未真正落地的变更记成“已完成”。 + if res.Failed == 0 { + if err := r.persist(plan, desired); err != nil { + run.Status = "failed" + run.ErrorMsg = "回写台账失败: " + err.Error() + _ = r.store.FinishRun(run) + return plan, res, fmt.Errorf("回写台账失败: %w", err) + } + } else { + r.log.Warn("存在失败的操作,跳过台账回写", "failed", res.Failed) + } + + run.Status = "success" + if res.Failed > 0 { + run.Status = "failed" + run.ErrorMsg = strings.Join(res.Errors, "; ") + } + if err := r.store.FinishRun(run); err != nil { + r.log.Warn("写入同步记录失败", "err", err) + } + + return plan, res, nil +} + +// prepare 计算计划并做安全检查。 +func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.State, error) { + plan, desired, err := r.plan(ctx) + if err != nil { + run.Status = "failed" + run.ErrorMsg = err.Error() + _ = r.store.FinishRun(run) + return nil, nil, err + } + + // 阈值检查对 dry-run 也执行,让用户在预览阶段就知道本次变更规模异常。 + // 只有显式传入 force 才跳过,避免把“预览”与“放行”混为一谈。 + guard := r.guard + if force { + guard.AllowBulk = true + } + if err := guard.Check(plan); err != nil { + run.Status = "failed" + run.ErrorMsg = err.Error() + _ = r.store.FinishRun(run) + return plan, desired, err + } + return plan, desired, nil +} + +// plan 读取两侧状态并计算计划,同时把计划规模记录到 run。 +func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.State, error) { + snap, err := r.feishu.Fetch(ctx, r.cfg.Feishu.DeptList) + if err != nil { + return nil, nil, fmt.Errorf("读取飞书数据失败: %w", err) + } + r.log.Info("已读取飞书数据", + "departments", len(snap.Departments), "users", len(snap.Users), + "skipped_no_email", len(snap.SkippedNoEmail), "duplicates", len(snap.SkippedDuplicate)) + + // 把“被跳过的用户”显式转成告警:这些人不会出现在 LDAP 里, + // 如果不提示,管理者只能靠人肉比对才能发现有人没同步进来。 + var skipWarnings []string + for _, u := range snap.SkippedNoEmail { + skipWarnings = append(skipWarnings, fmt.Sprintf( + "用户 %s(union_id=%s)没有企业邮箱,无法推导登录名,已跳过", + feishu.DisplayName(u), u.UnionID)) + } + for _, d := range snap.SkippedDuplicate { + skipWarnings = append(skipWarnings, fmt.Sprintf( + "企业邮箱前缀 %s 被多个用户占用(%s),为安全起见全部跳过,请人工确认后修正邮箱", + d.UID, strings.Join(d.Names, "、"))) + } + + desired := mapping.Build(snap, mapping.BuildOptions{ + UserOUDN: r.cfg.UserOUDN(), + InactiveOUDN: r.cfg.InactiveOUDN(), + GroupRootDN: r.cfg.GroupRootDN(), + }) + + conn, err := r.ldap.Dial() + if err != nil { + return nil, nil, err + } + defer conn.Close() + + active, err := conn.ListUsers(r.cfg.UserOUDN()) + if err != nil { + return nil, nil, err + } + inactive, err := conn.ListUsers(r.cfg.InactiveOUDN()) + if err != nil { + return nil, nil, err + } + groups, err := conn.ListGroups(r.cfg.GroupRootDN()) + if err != nil { + return nil, nil, err + } + + accounts, err := r.store.AccountsByUID() + if err != nil { + return nil, nil, err + } + groupLedger, err := r.store.GroupsByDeptID() + if err != nil { + return nil, nil, err + } + + ledgerAccounts := make(map[string]string, len(accounts)) + for uid, a := range accounts { + ledgerAccounts[uid] = a.State + } + ledgerGroups := make(map[string]string, len(groupLedger)) + for deptID, g := range groupLedger { + ledgerGroups[deptID] = g.DN + } + + plan := ComputePlan(&Input{ + Desired: desired, + Active: active, + Inactive: inactive, + Groups: groups, + LedgerAccounts: ledgerAccounts, + LedgerGroups: ledgerGroups, + AdminDN: r.cfg.Ldap.AdminDN, + GroupRootDN: r.cfg.GroupRootDN(), + UserOUDN: r.cfg.UserOUDN(), + InactiveOUDN: r.cfg.InactiveOUDN(), + }) + + plan.Warnings = append(skipWarnings, plan.Warnings...) + + // 对“候选停用”的用户,向飞书确认是否真的离职。 + // 缺席也可能由权限范围收窄或部门调整造成,确认后再停用可避免误伤。 + if err := r.confirmResigned(ctx, plan, snap); err != nil { + r.log.Warn("确认离职状态时出错,相关账号本轮不停用", "err", err) + } + + return plan, desired, nil +} + +// confirmResigned 把未能确认离职的停用操作从计划中剔除,并追加告警。 +func (r *Runner) confirmResigned(ctx context.Context, plan *Plan, _ *feishu.Snapshot) error { + var uids []string + + // 只有台账里记录了 union_id 的账号才能精确回查;缺失者无从确认。 + accounts, err := r.store.AccountsByUID() + if err != nil { + return err + } + + for i := range plan.Ops { + if plan.Ops[i].Kind != OpDeactivate { + continue + } + acc, ok := accounts[plan.Ops[i].Target] + if !ok || acc.UnionID == "" { + continue + } + uids = append(uids, acc.UnionID) + } + + if len(uids) == 0 { + return nil + } + + resigned, err := r.feishu.CheckResigned(ctx, uids) + if err != nil { + return err + } + + confirmed := make(map[string]bool, len(resigned)) + for _, rr := range resigned { + confirmed[rr.UnionID] = true + } + + keep := make([]Op, 0, len(plan.Ops)) + for _, op := range plan.Ops { + if op.Kind != OpDeactivate { + keep = append(keep, op) + continue + } + acc := accounts[op.Target] + if acc != nil && confirmed[acc.UnionID] { + keep = append(keep, op) + continue + } + // 未能确认离职:不动作,并明确告知。 + plan.Stats.Deactivate-- + plan.Warnings = append(plan.Warnings, fmt.Sprintf( + "账号 %s 在飞书已不可见,但未能确认离职状态(可能因通讯录权限范围变化),本轮不迁入 %s", + op.Target, r.cfg.InactiveOUDN())) + } + plan.Ops = keep + + return nil +} + +// apply 执行计划。 +func (r *Runner) apply(ctx context.Context, plan *Plan) *Result { + exec := NewExecutor(r.ldap, ExecConfig{ + UserOUDN: r.cfg.UserOUDN(), + InactiveOUDN: r.cfg.InactiveOUDN(), + GroupRootDN: r.cfg.GroupRootDN(), + GroupRootName: r.cfg.Ldap.GroupRootName, + AdminDN: r.cfg.Ldap.AdminDN, + InitialPassword: r.cfg.Ldap.InitialPassword, + }, r.log) + return exec.Apply(ctx, plan) +} + +// persist 把本次同步的期望状态回写台账。 +// +// 关键点:按**期望状态**整体刷新,而不是只记录本次实际执行的操作。 +// 否则会出现一个隐蔽问题——LDAP 中已存在、但台账里没有的分组永远不会被登记 +// (因为它不需要新建,也就不会产生操作),于是本工具永远不认为它归自己管。 +// 这个场景在接管既有目录时是常态,因此必须按期望状态登记。 +func (r *Runner) persist(plan *Plan, desired *mapping.State) error { + now := time.Now() + + // 分组:凡在本次期望范围内的都登记。 + for _, g := range desired.Groups { + if err := r.store.UpsertGroup(&store.Group{ + DeptID: g.DeptID, + DN: g.DN, + Name: g.Name, + ParentID: g.ParentID, + State: "active", + LastSyncedAt: now, + }); err != nil { + return err + } + } + + // 用户:在职的登记为 active。 + for _, u := range desired.Users { + if err := r.store.UpsertAccount(&store.Account{ + UID: u.UID, + DN: u.DN, + Email: u.Mail, + Name: u.DisplayName, + State: "active", + AttrsHash: AttrsHash(u), + DeptIDs: strings.Join(u.DeptIDs, ","), + LastSyncedAt: now, + }); err != nil { + return err + } + } + + // 停用的账号:更新状态与,DN 指向离职 OU。 + for _, op := range plan.Ops { + if op.Kind == OpDeactivate { + if err := r.store.SetAccountState(op.Target, "inactive", op.DN); err != nil { + return err + } + } + } + + return nil +} + +// modeOf 把 dry-run 标志转成记录用的模式名。 +func modeOf(dryRun bool) string { + if dryRun { + return "dry-run" + } + return "apply" +} + +// fillRunFromPlan 用计划统计填充同步记录(dry-run 时使用)。 +func fillRunFromPlan(run *store.SyncRun, plan *Plan) { + run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup + run.Updated = plan.Stats.UpdateUser + plan.Stats.UpdateGroup + + plan.Stats.Reactivate + plan.Stats.Deactivate + run.Moved = plan.Stats.Deactivate + plan.Stats.Reactivate + run.Added = plan.Stats.AddMember + run.Removed = plan.Stats.RemoveMember + run.Skipped = plan.Stats.Unchanged +} + +// fillRunFromResult 用实际执行结果填充同步记录。 +func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) { + run.Created = res.Created + run.Updated = res.Updated + run.Moved = res.Moved + run.Added = res.Added + run.Removed = res.Removed + run.Failed = res.Failed + run.Skipped = plan.Stats.Unchanged +} diff --git a/internal/webui/dist/app.css b/internal/webui/dist/app.css new file mode 100644 index 0000000..ac35aee --- /dev/null +++ b/internal/webui/dist/app.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-gray-200:oklch(92.8% .006 264.531);--color-black:#000;--spacing:.25rem;--container-sm:24rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--radius-xs:.125rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-2xl:1rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--color-base-100:oklch(31.23% .026 301.24);--color-base-200:oklch(25.39% .025 298.72);--color-base-300:oklch(23.44% .0194 298.63);--color-base-content:oklch(84.98% .014 295.28);--color-primary:oklch(53.93% .271 286.75);--color-primary-content:oklch(88.22% .062 290.17);--color-secondary:oklch(49.12% .021 303.05);--color-secondary-content:oklch(88.83% .007 304.23);--color-accent:oklch(54.61% .215 262.88);--color-accent-content:oklch(88.23% .057 254.13);--color-neutral:oklch(65.75% .022 294.95);--color-neutral-content:oklch(18.51% .017 301.92);--color-info:oklch(60.89% .111 221.72);--color-info-content:oklch(91.67% .077 205.04);--color-success:oklch(67.35% .201 146.84);--color-success-content:oklch(91.38% .069 168.24);--color-warning:oklch(72.59% .152 69.05);--color-warning-content:oklch(96.07% .057 93.2);--color-error:oklch(59.54% .208 26.28);--color-error-content:oklch(93.4% .039 54.86);--radius-selector:.25rem;--radius-field:.375rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}}:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97.8% .005 297.73);--color-base-300:oklch(37.01% .0069 297.49);--color-base-content:oklch(37.57% .022 281.8);--color-primary:oklch(57.59% .247 287.24);--color-primary-content:oklch(96.57% .017 289.61);--color-secondary:oklch(55.79% .022 301.91);--color-secondary-content:oklch(97.64% .001 286.38);--color-accent:oklch(62.31% .188 259.81);--color-accent-content:oklch(97.05% .014 254.6);--color-neutral:oklch(37.57% .0222 281.8);--color-neutral-content:oklch(98% .0013 286.38);--color-info:oklch(71.48% .126 215.22);--color-info-content:oklch(98.41% .019 200.87);--color-success:oklch(73.11% .217 147.04);--color-success-content:oklch(98.1% .014 174.18);--color-warning:oklch(79.87% .164 73.09);--color-warning-content:oklch(99.34% .011 95.16);--color-error:oklch(65.39% .222 25.86);--color-error-content:oklch(98.76% .009 67.73);--radius-selector:.25rem;--radius-field:.375rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root{--fx-noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E")}.chat{--mask-chat:url("data:image/svg+xml,%3csvg width='13' height='13' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill='black' d='M0 11.5004C0 13.0004 2 13.0004 2 13.0004H12H13V0.00036329L12.5 0C12.5 0 11.977 2.09572 11.8581 2.50033C11.6075 3.35237 10.9149 4.22374 9 5.50036C6 7.50036 0 10.0004 0 11.5004Z'/%3e%3c/svg%3e")}.tooltip{--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A")}:root{scrollbar-color:currentColor #0000}@supports (color:color-mix(in lab, red, red)){:root{scrollbar-color:color-mix(in oklch, currentColor 20%, #0000) #0000}}@property --radialprogress{syntax:"";inherits:true;initial-value:0%}:root,[data-theme]{background-color:var(--root-bg,var(--color-base-100));color:var(--color-base-content)}button:not(:disabled),[role=button]:not(:disabled){cursor:pointer}:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97.8% .005 297.73);--color-base-300:oklch(37.01% .0069 297.49);--color-base-content:oklch(37.57% .022 281.8);--color-primary:#609926;--color-primary-content:#fff;--color-secondary:oklch(55.79% .022 301.91);--color-secondary-content:oklch(97.64% .001 286.38);--color-accent:oklch(62.31% .188 259.81);--color-accent-content:oklch(97.05% .014 254.6);--color-neutral:oklch(37.57% .0222 281.8);--color-neutral-content:oklch(98% .0013 286.38);--color-info:oklch(71.48% .126 215.22);--color-info-content:oklch(98.41% .019 200.87);--color-success:#21ba45;--color-success-content:#fff;--color-warning:oklch(79.87% .164 73.09);--color-warning-content:oklch(99.34% .011 95.16);--color-error:oklch(65.39% .222 25.86);--color-error-content:oklch(98.76% .009 67.73);--radius-selector:.25rem;--radius-field:.375rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=dark]:checked),[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(31.23% .026 301.24);--color-base-200:oklch(25.39% .025 298.72);--color-base-300:oklch(23.44% .0194 298.63);--color-base-content:oklch(84.98% .014 295.28);--color-primary:#7ab63a;--color-primary-content:#101d05;--color-secondary:oklch(49.12% .021 303.05);--color-secondary-content:oklch(88.83% .007 304.23);--color-accent:oklch(54.61% .215 262.88);--color-accent-content:oklch(88.23% .057 254.13);--color-neutral:oklch(65.75% .022 294.95);--color-neutral-content:oklch(18.51% .017 301.92);--color-info:oklch(60.89% .111 221.72);--color-info-content:oklch(91.67% .077 205.04);--color-success:#46de6a;--color-success-content:#071505;--color-warning:oklch(72.59% .152 69.05);--color-warning-content:oklch(96.07% .057 93.2);--color-error:oklch(59.54% .208 26.28);--color-error-content:oklch(93.4% .039 54.86);--radius-selector:.25rem;--radius-field:.375rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}}@layer components{.idsync-dn{word-break:break-all;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.75rem}.idsync-scroll{overflow-x:auto}}@layer utilities{.modal{pointer-events:none;z-index:80;opacity:0;width:100%;height:100%;transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));justify-items:center;margin:0;padding:1rem;display:grid;position:fixed;inset:0;overflow:hidden auto}.carousel-prev{cursor:pointer;justify-content:center;align-items:center;height:max-content;margin-block:auto;display:inline-flex;position:absolute;inset-block:0}.carousel-prev.disabled{pointer-events:none}.carousel-prev{border-start-start-radius:var(--radius-2xl);border-end-start-radius:var(--radius-2xl)}.carousel-next{cursor:pointer;justify-content:center;align-items:center;height:max-content;margin-block:auto;display:inline-flex;position:absolute;inset-block:0}.carousel-next.disabled{pointer-events:none}.carousel-next{border-start-end-radius:var(--radius-2xl);border-end-end-radius:var(--radius-2xl)}.menu{border-radius:var(--radius-box);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--menu-active-fg:var(--color-primary);--menu-active-bg:var(--color-primary);flex-flow:column wrap;gap:.125rem;padding:.5rem;display:flex}@supports (color:color-mix(in lab, red, red)){.menu{--menu-active-bg:color-mix(in oklab, var(--color-primary) 10%, #0000)}}.menu{--menu-hover-fg:var(--color-base-content);--menu-hover-bg:var(--color-neutral)}@supports (color:color-mix(in lab, red, red)){.menu{--menu-hover-bg:color-mix(in oklab, var(--color-neutral) 10%, #0000)}}.menu :where(li ul){white-space:nowrap;position:relative}.menu :where(li:not(.dropdown) ul){margin-inline-start:1rem;padding-inline-start:.5rem}.menu :where(li:not(.dropdown) ul):before{background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";inset-inline-start:0;position:absolute;top:.75rem;bottom:.625rem}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.menu :where(.menu li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.menu :where(li:not(.menu-title,.tooltip)>:not(ul,.collapse,.accordion-content,.menu-title,.btn)){border-radius:var(--radius-field);text-align:start;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-timing-function:var(--ease-out);transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.2s;--tw-ease:var(--ease-out);text-wrap:balance;padding-block:.625rem;padding-inline:1rem;transition-duration:.2s}.menu :where(li:not(.menu-title)>:not(ul,.collapse,.accordion-content,.menu-title,.btn)){-webkit-user-select:none;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;display:grid}.menu :where(li.tooltip)>*{border-radius:var(--radius-field);text-align:start;padding-block:.625rem;padding-inline:1rem}.menu :where(.menu li:not(.menu-title,.menu-disabled)){color:var(--color-base-content)}.menu :where(.menu li:not(.menu-title,.menu-disabled)>:not(ul,.collapse,.accordion-content,.dropdown-active,.menu-title)):not(.btn):focus-visible,.menu :where(.menu li:not(.menu-title,.menu-disabled)>:not(ul,.collapse,.accordion-content,.dropdown-active,.menu-title)):hover{cursor:pointer;--tw-outline-style:none;color:var(--menu-hover-fg);background-color:var(--menu-hover-bg);outline-style:none}.menu li:not(.menu-title,.menu-disabled)>:not(ul,.menu-title,.collapse,.accordion-content,.btn).menu-active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}.menu li.menu-disabled{pointer-events:none;opacity:.5;webkit-user-select:none;-webkit-user-select:none;user-select:none}:where(.menu li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative}:where(.menu li) .badge{justify-self:flex-end}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab{cursor:pointer;appearance:none;text-align:center;font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));color:var(--color-base-content);webkit-user-select:none;-webkit-user-select:none;user-select:none;height:var(--size);--size:calc(var(--size-field,.25rem) * 9.5);--tab-p:1rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-content);justify-content:center;align-items:center;display:inline-flex;position:relative}@supports (color:color-mix(in lab, red, red)){.tab{--tab-border-color:color-mix(in oklab, var(--color-base-content), #0000 90%)}}.tab{padding-inline:var(--tab-p,1rem)}.tab:focus,.tab:focus-visible{--tw-outline-style:none;outline-style:none}.tab[disabled]{pointer-events:none;opacity:.4}@media (hover:hover){.tab[disabled]:hover{pointer-events:none;opacity:.4}}.tabs-bordered>.tab{border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.tabs-bordered>.tab{border-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}@media (hover:hover){@media (hover:hover){.tabs-bordered>.tab:hover{border-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){.tabs-bordered>.tab:hover{border-color:color-mix(in oklab, var(--color-primary) 30%, transparent)}}.tabs-bordered>.tab:hover{color:var(--color-primary)}}}.tabs-bordered>.tab{border-style:solid;border-bottom-width:calc(var(--border,1px) + 1px)}@media (hover:hover){@media (hover:hover){.tabs-lifted>.tab:hover{border-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){.tabs-lifted>.tab:hover{border-color:color-mix(in oklab, var(--color-primary) 30%, transparent)}}.tabs-lifted>.tab:hover{color:var(--color-primary)}}}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-width:0 0 var(--tab-border,1px) 0;border-radius:var(--radius-field,.375rem);border-bottom-color:var(--tab-border-color);padding-inline-start:var(--tab-p,1rem);padding-inline-end:var(--tab-p,1rem);padding-top:var(--tab-border,1px)}[dir=rtl] .tabs-lifted>.tab.tab-active:not([disabled]):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-xl :where(.tab){font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--size:calc(var(--size-field,.25rem) * 14);--tab-p:1.5rem}.tabs-lg :where(.tab){font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--size:calc(var(--size-field,.25rem) * 11.5);--tab-p:1.25rem}.tabs-md :where(.tab){font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--size:calc(var(--size-field,.25rem) * 9.5);--tab-p:1rem}.tabs-sm :where(.tab){font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--size:calc(var(--size-field,.25rem) * 7.5);--tab-p:.75rem}.tabs-xs :where(.tab){font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--size:calc(var(--size-field,.25rem) * 6);--tab-p:.5rem}.btn{cursor:pointer;text-align:center;vertical-align:middle;font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);webkit-user-select:none;-webkit-user-select:none;user-select:none;flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.5rem;text-decoration-line:none;display:inline-flex}@media (prefers-reduced-motion:reduce){.btn{transition-property:none}}.btn{padding-inline:var(--btn-p);color:var(--btn-fg);height:var(--size);outline-color:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn{outline-color:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 30%, var(--color-base-100))}}.btn{background-color:var(--btn-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--btn-noise);border:var(--border) solid var(--btn-border);box-shadow:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 8%)) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 9.5);--btn-bg:var(--btn-color,var(--color-neutral));--btn-fg:var(--color-neutral-content);--btn-p:1rem;--btn-border:var(--btn-bg);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));transition-property:color,background-color,border-color,box-shadow;transition-duration:.25s;transition-timing-function:cubic-bezier(0,0,.96,1.03)}@supports (color:color-mix(in lab, red, red)){.btn{--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%))}}.btn{--btn-shadow:0px 1px 3px 0px var(--color-base-300), 0px 1px 2px -1px var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.btn{--btn-shadow:0px 1px 3px 0px color-mix(in oklab, var(--color-base-300) 40%, #0000), 0px 1px 2px -1px color-mix(in oklab, var(--color-base-300) 40%, #0000)}}.btn{--btn-noise:var(--fx-noise)}@media (hover:hover){.btn:hover{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 90%, #000)}}}.btn:active:hover:not(.btn-active),.btn:active:focus:not(.btn-active){--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y);transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-timing-function:ease-out;transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;transition-duration:.3s}.btn:focus-visible{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn:focus-visible{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)), #000 10%)}}.btn:focus-visible{outline-width:2px;outline-style:solid}.btn:is(:disabled,[disabled],.btn-disabled){pointer-events:none;opacity:.5;--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.btn:is(input[type=checkbox],input[type=radio]){appearance:none}.btn:is(input[type=checkbox],input[type=radio]):after{content:attr(aria-label)}.btn:is(input[type=checkbox],input[type=radio]):checked{background:var(--color-primary);color:var(--color-primary-content);outline-color:var(--color-primary);--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0);isolation:isolate}.pin-input{border-radius:var(--radius-field);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.pin-input{border-color:color-mix(in oklab, var(--color-base-content) 25%, transparent)}}.pin-input{background-color:var(--color-base-100);text-align:center;font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.pin-input::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.pin-input::placeholder{color:color-mix(in oklab, var(--color-base-content) 80%, transparent)}}.pin-input{height:var(--size);width:var(--size);--size:calc(var(--size-field,.25rem) * 9.5);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.pin-input:hover:not(:focus,:focus-within){border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.pin-input:hover:not(:focus,:focus-within){border-color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.pin-input:focus,.pin-input:focus-within{border-color:var(--color-primary);--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){.pin-input:focus,.pin-input:focus-within{--tw-shadow-color:color-mix(in oklab, var(--color-primary) 30%, #0000)}}.pin-input:focus,.pin-input:focus-within{outline:1px solid var(--color-primary);isolation:isolate}.pin-input.disabled,.pin-input:disabled,.pin-input[disabled]{pointer-events:none;border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.pin-input.disabled,.pin-input:disabled,.pin-input[disabled]{border-color:color-mix(in oklab, var(--color-base-content) 25%, transparent)}}.pin-input.disabled,.pin-input:disabled,.pin-input[disabled]{background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.pin-input.disabled,.pin-input:disabled,.pin-input[disabled]{background-color:color-mix(in oklab, var(--color-base-content) 5%, transparent)}}.pin-input.disabled,.pin-input:disabled,.pin-input[disabled]{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.pin-input.disabled,.pin-input:disabled,.pin-input[disabled]{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}:is(.pin-input.disabled,.pin-input:disabled,.pin-input[disabled])::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.pin-input.disabled,.pin-input:disabled,.pin-input[disabled])::placeholder{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}.dropdown-item{clear:both;border-radius:var(--radius-field);width:100%;color:var(--color-base-content);text-align:inherit;background-color:#0000;align-items:center;column-gap:.5rem;padding-block:.625rem;padding-inline:1rem;text-decoration-line:none;text-decoration-thickness:0;display:flex}.dropdown-item:hover:not(.dropdown-active),.dropdown-item:focus:not(.dropdown-active),.dropdown-item:focus-within:not(.dropdown-active),.dropdown-item:focus-visible:not(.dropdown-active){background-color:var(--color-neutral)}@supports (color:color-mix(in lab, red, red)){.dropdown-item:hover:not(.dropdown-active),.dropdown-item:focus:not(.dropdown-active),.dropdown-item:focus-within:not(.dropdown-active),.dropdown-item:focus-visible:not(.dropdown-active){background-color:color-mix(in oklab, var(--color-neutral) 10%, transparent)}}.dropdown-item:hover:not(.dropdown-active),.dropdown-item:focus:not(.dropdown-active),.dropdown-item:focus-within:not(.dropdown-active),.dropdown-item:focus-visible:not(.dropdown-active){--tw-outline-style:none;outline-style:none}.dropdown-item.dropdown-disabled,.dropdown-item:disabled,.dropdown-item[disabled]{pointer-events:none;background-color:var(--color-neutral)}@supports (color:color-mix(in lab, red, red)){.dropdown-item.dropdown-disabled,.dropdown-item:disabled,.dropdown-item[disabled]{background-color:color-mix(in oklab, var(--color-neutral) 5%, transparent)}}.dropdown-item.dropdown-disabled,.dropdown-item:disabled,.dropdown-item[disabled]{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.dropdown-item.dropdown-disabled,.dropdown-item:disabled,.dropdown-item[disabled]{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.loading{pointer-events:none;aspect-ratio:1;vertical-align:middle;width:calc(var(--size-selector,.25rem) * 6);background-color:currentColor;display:inline-block;-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E");mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:100%;mask-size:100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.accordion-toggle{cursor:pointer;width:100%;font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-base-content);padding-block:1rem;padding-inline:1.25rem}.accordion-toggle:disabled,.range.disabled{pointer-events:none;opacity:.5}.carousel-prev.disabled,.carousel-next.disabled{pointer-events:none}.tooltip-content{visibility:hidden;opacity:0;transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));z-index:20;width:max-content;padding:.5rem;display:inline-block;position:absolute}.collapse{visibility:collapse}.collapse:not(td):not(tr):not(colgroup){visibility:visible}.invisible{visibility:hidden}.table{border-radius:var(--radius-box);text-align:left;width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));position:relative;overflow:hidden auto}.table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(.table tr.row-hover,.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}@supports (color:color-mix(in lab, red, red)){:is(.table tr.row-hover,.table tr.row-hover:nth-child(2n)):hover{background-color:color-mix(in oklab, var(--color-base-200) 80%, transparent)}}}.table tr.row-active,.table tr.row-active:nth-child(2n){background-color:var(--color-base-200)}.table :where(th,td){vertical-align:middle;text-wrap:nowrap;padding-block:.75rem;padding-inline:1.25rem}.table :where(thead,tfoot){font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));white-space:nowrap;color:var(--color-base-content);text-transform:uppercase;letter-spacing:1px}.table :where(th){--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.table :where(.table-pin-rows thead tr){z-index:1;background-color:var(--color-base-100);position:sticky;top:0}.table :where(.table-pin-rows tfoot tr){z-index:1;background-color:var(--color-base-100);position:sticky;bottom:0}.table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.table :where(thead tr){border-bottom:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead tr){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 25%, #0000)}}.table :where(tbody tr:not(:last-child)){border-bottom:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(tbody tr:not(:last-child)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 10%, #0000)}}.table :where(tfoot){border-top:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(tfoot){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 25%, #0000)}}.tab-active:not([disabled]){border-color:var(--color-primary);color:var(--color-primary)}.tabs-lifted>.tab.tab-active:not([disabled]){background-color:var(--tab-bg);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);border-color:var(--tab-border-color);padding-inline-start:calc(var(--tab-p,1rem) - var(--tab-border,1px));padding-inline-end:calc(var(--tab-p,1rem) - var(--tab-border,1px));padding-bottom:var(--tab-border,1px);border-end-end-radius:0;border-end-start-radius:0;padding-top:0}.tabs-lifted>.tab.tab-active:not([disabled]):before{content:"";z-index:1;width:calc(100% + var(--radius-field,.375rem) * 2);height:var(--radius-field,.375rem);background-size:var(--radius-field,.375rem);--tab-grad:calc(69% - var(--tab-border,1px));--radius-start:radial-gradient(circle at top left, transparent var(--tab-grad), var(--tab-border-color) calc(var(--tab-grad) + .25px), var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border,1px)), var(--tab-bg) calc(var(--tab-grad) + var(--tab-border,1px) + .25px));--radius-end:radial-gradient(circle at top right, transparent var(--tab-grad), var(--tab-border-color) calc(var(--tab-grad) + .25px), var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border,1px)), var(--tab-bg) calc(var(--tab-grad) + var(--tab-border,1px) + .25px));background-position:0 0,100% 0;background-repeat:no-repeat;background-image:var(--radius-start), var(--radius-end);display:block;position:absolute;bottom:0}.tabs-lifted>.tab.tab-active:not([disabled]):first-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab.tab-active:not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab.tab-active:not([disabled]):first-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not([disabled]):last-child:before,.tabs-lifted>.tab-active:not([disabled])+.tabs-lifted .tab-active:not([disabled]):before{background-image:var(--radius-end);background-position:100% 0}.select{cursor:pointer;appearance:none;background-color:var(--color-base-100);vertical-align:middle;width:100%;font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));color:var(--color-base-content);webkit-user-select:none;-webkit-user-select:none;user-select:none;height:var(--size);touch-action:manipulation;text-overflow:ellipsis;border:var(--border) solid #0000;border-color:var(--input-color);--input-color:var(--color-base-content);background-image:url("data:image/svg+xml;utf8,");background-position:calc(100% - .5rem);background-repeat:no-repeat;background-size:1.3em;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;padding-inline:.75rem 2.5rem;display:inline-flex;position:relative}@supports (color:color-mix(in lab, red, red)){.select{--input-color:color-mix(in oklab, var(--color-base-content) 40%, #0000)}}.select{--size:calc(var(--size-field,.25rem) * 9.5)}.select:hover:not(:focus,:focus-within),.select:has(:hover):not(:focus,:focus-within){border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:hover:not(:focus,:focus-within),.select:has(:hover):not(:focus,:focus-within){border-color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}[dir=rtl] .select{background-position:1.3em}.select select{appearance:none;background-color:#0000;border-style:none;width:calc(100% + 2.75rem);height:100%;margin-inline-end:-2.5rem;padding-inline:.75rem 2.5rem}.select select:focus,.select select:focus-within{--tw-outline-style:none;outline-style:none}.select[multiple]{background-image:none}.select:focus,.select:focus-within{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--input-color:var(--color-primary);--tw-shadow-color:var(--input-color)}@supports (color:color-mix(in lab, red, red)){.select:focus,.select:focus-within{--tw-shadow-color:color-mix(in oklab, var(--input-color) 30%, #0000)}}.select:focus,.select:focus-within{outline:1px solid var(--input-color);border-color:var(--input-color);isolation:isolate}.select:has(>select[disabled]),.select:is(:disabled,[disabled]){cursor:not-allowed;border-style:var(--tw-border-style);border-width:0;border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]){border-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.select:has(>select[disabled]),.select:is(:disabled,[disabled]){background-color:var(--color-neutral)}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]){background-color:color-mix(in oklab, var(--color-neutral) 10%, transparent)}}.select:has(>select[disabled]),.select:is(:disabled,[disabled]){color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.select:has(>select[disabled])>select[disabled]{cursor:not-allowed}.select:has(>.select-floating) select::placeholder{color:#0000}.select:has(>.select-floating) select:focus::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:has(>.select-floating) select:focus::placeholder{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.card{border-radius:var(--radius-box);--tw-border-style:solid;border-style:solid;border-color:var(--color-base-content);flex-direction:column;display:flex;position:relative}@supports (color:color-mix(in lab, red, red)){.card{border-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.card{background-color:var(--color-base-100);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.card{color:color-mix(in oklab, var(--color-base-content) 80%, transparent)}}.card{--tw-outline-style:none;box-shadow:var(--card-shadow,0 1px 3px 0 var(--color-base-300), 0 1px 2px -1px var(--color-base-300));outline-style:none}@supports (color:color-mix(in lab, red, red)){.card{box-shadow:var(--card-shadow,0 1px 3px 0 color-mix(in oklab, var(--color-base-300) 20%, #0000), 0 1px 2px -1px color-mix(in oklab, var(--color-base-300) 20%, #0000))}}.card{border-width:var(--card-border,0px)}.card:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.card:focus{outline-offset:2px;outline:2px solid #0000}}.card :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.card :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.card:where(.card-border){border:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.card:where(.card-border){border:var(--border) solid color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--radius-box);background-color:var(--color-neutral);opacity:.75;content:"";grid-row-start:1;grid-column-start:1;position:relative}.card.image-full>*{grid-row-start:1;grid-column-start:1}.card.image-full>.card-body{color:var(--color-neutral-content);position:relative}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.card.image-full>figure img{object-fit:cover;height:100%}.card figure{justify-content:center;align-items:center;display:flex}.card:has(>input:is(input[type=checkbox],input[type=radio])){cursor:pointer;-webkit-user-select:none;user-select:none}.card:has(>:checked){outline:2px solid}.card .card-alert{border-radius:0}.card .card-alert:first-child{border-top-left-radius:var(--radius-box);border-top-right-radius:var(--radius-box)}.card .card-alert:last-child{border-bottom-right-radius:var(--radius-box);border-bottom-left-radius:var(--radius-box)}.checkbox{cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-neutral-content);border:var(--border) solid var(--color-base-content);flex-shrink:0;padding:.25rem;position:relative}@supports (color:color-mix(in lab, red, red)){.checkbox{border:var(--border) solid color-mix(in oklab, var(--color-base-content) 40%, #0000)}}.checkbox{--input-color:var(--color-neutral);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);transition:background-color .2s,box-shadow .2s}.checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.checkbox:focus-visible{outline:2px solid var(--input-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.checkbox:focus-visible{outline:2px solid color-mix(in oklab, var(--input-color,var(--color-neutral)) 30%, #0000)}}.checkbox:focus-visible{outline-offset:0px}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--input-color,#0000);border-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 2px 0 var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.checkbox:checked,.checkbox[aria-checked=true]{box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px 2px 0 color-mix(in oklab, var(--color-base-300) 20%, #0000)}}:is(.checkbox:checked,.checkbox[aria-checked=true]):before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1}@media (forced-colors:active){:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}@media print{:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:0deg}}.checkbox:indeterminate{background-color:var(--color-neutral)}.checkbox:indeterminate:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -35%;rotate:0deg}.checkbox:disabled{cursor:not-allowed;opacity:.5}.carousel{border-radius:var(--radius-2xl);width:100%;position:relative;overflow:hidden}.alert{border-radius:var(--radius-box);width:100%;color:var(--color-neutral-content);text-align:start;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--alert-bg);border:var(--border) solid var(--alert-border);box-shadow:0 .1875rem 0 -.125rem oklch(100% 0 0 / calc(var(--depth) * .08)) inset;--alert-bg:var(--alert-color,var(--color-neutral));--alert-border:var(--alert-color,var(--color-neutral));padding:1rem;position:relative}.alert.alert-outline{color:var(--alert-color,var(--color-neutral));--alert-bg:transparent;box-shadow:none;background-image:none}.alert.alert-soft{color:var(--alert-color,var(--color-neutral));--alert-bg:var(--alert-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.alert.alert-soft{--alert-bg:color-mix(in oklab, var(--alert-color,var(--color-neutral)) 10%, var(--color-base-100))}}.alert.alert-soft{box-shadow:none;background-image:none}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.top-0{top:0}.top-full{top:100%}.bottom-full{bottom:100%}.input{cursor:text;appearance:none;background-color:var(--color-base-100);width:100%;font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal);color:var(--color-base-content);flex-shrink:1;padding-inline:.75rem;display:inline-flex}.input::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input::placeholder{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.input:focus-visible{--tw-outline-style:none;outline-style:none}.input{border:var(--border) solid #0000;height:var(--size);touch-action:manipulation;border-color:var(--input-color);--input-color:var(--color-base-content);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field))}@supports (color:color-mix(in lab, red, red)){.input{--input-color:color-mix(in oklab, var(--color-base-content) 40%, #0000)}}.input{--size:calc(var(--size-field,.25rem) * 9.5)}.input:hover:not(:focus,:focus-within),.input:has(:hover):not(:focus,:focus-within){border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input:hover:not(:focus,:focus-within),.input:has(:hover):not(:focus,:focus-within){border-color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.input:where(input){display:inline-block}.input :where(input:not([type=checkbox]):not([type=radio])){appearance:none;background-color:#0000;border:none;width:100%;height:100%;display:inline-block}.input :where(input:not([type=checkbox]):not([type=radio])):focus,.input :where(input:not([type=checkbox]):not([type=radio])):focus-within,.input :where(input:not([type=checkbox]):not([type=radio])):focus-visible{--tw-outline-style:none;outline-style:none}.input:focus,.input:focus-within{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--input-color:var(--color-primary);--tw-shadow-color:var(--input-color)}@supports (color:color-mix(in lab, red, red)){.input:focus,.input:focus-within{--tw-shadow-color:color-mix(in oklab, var(--input-color) 30%, #0000)}}.input:focus,.input:focus-within{outline:1px solid var(--input-color);isolation:isolate}.input:has(>input[disabled]),.input:is(:disabled,[disabled]){cursor:not-allowed;border-style:var(--tw-border-style);background-color:var(--color-neutral);border-width:0}@supports (color:color-mix(in lab, red, red)){.input:has(>input[disabled]),.input:is(:disabled,[disabled]){background-color:color-mix(in oklab, var(--color-neutral) 10%, transparent)}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]){color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input:has(>input[disabled]),.input:is(:disabled,[disabled]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]){box-shadow:none}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.input[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input:has(>.input-floating) input::placeholder{color:#0000}.input:has(>.input-floating) input:focus::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input:has(>.input-floating) input:focus::placeholder{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.textarea{appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);width:100%;min-height:5rem;font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));color:var(--color-base-content);flex-shrink:1;padding-block:.5rem;padding-inline:1rem;display:inline-flex}.textarea::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea::placeholder{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}@media (hover:hover){.textarea:hover{border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea:hover{border-color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}}.textarea:focus-visible{--tw-outline-style:none;outline-style:none}.textarea{border:var(--border) solid #0000;border-color:var(--input-color);touch-action:manipulation;--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea{--input-color:color-mix(in oklab, var(--color-base-content) 40%, #0000)}}.textarea :where(textarea){appearance:none;background-color:#0000;border:none}.textarea :where(textarea):focus,.textarea :where(textarea):focus-within{--tw-outline-style:none;outline-style:none}.textarea:focus,.textarea:focus-within{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--input-color:var(--color-primary);--tw-shadow-color:var(--input-color)}@supports (color:color-mix(in lab, red, red)){.textarea:focus,.textarea:focus-within{--tw-shadow-color:color-mix(in oklab, var(--input-color) 30%, #0000)}}.textarea:focus,.textarea:focus-within{outline:1px solid var(--input-color);border-color:var(--input-color);isolation:isolate}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.textarea:has(>textarea){padding:0}.textarea:has(>textarea) textarea{width:100%;height:auto;min-height:5rem}.textarea:has(>textarea) textarea:first-child{padding-block:.5rem;padding-inline-start:1rem}.textarea:has(>textarea) textarea:nth-child(2){padding-block:.5rem}.textarea:has(>textarea) textarea:last-child{padding-block:.5rem;padding-inline-end:1rem}.textarea:has(>.textarea-floating){padding:0}.textarea:has(>.textarea-floating) :where(.textarea-floating:first-child)>textarea{padding-block:.5rem;padding-inline-start:1rem}.textarea:has(>.textarea-floating) :where(.textarea-floating:nth-child(2))>textarea{padding-block:.5rem}.textarea:has(>.textarea-floating) :where(.textarea-floating:last-child)>textarea{padding-block:.5rem;padding-inline-end:1rem}.textarea:has(>.textarea-floating) :where(.textarea-floating:nth-child(2)) .textarea-floating-label,.textarea:has(>.textarea-floating) :where(.textarea-floating:last-child) .textarea-floating-label{margin-inline-start:0}.textarea:has(>.textarea-floating) *>textarea{width:100%;height:100%;min-height:5rem}.textarea:has(>.textarea-floating) textarea::placeholder{color:#0000}.textarea:has(>.textarea-floating) textarea:focus::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea:has(>.textarea-floating) textarea:focus::placeholder{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.dropdown-menu{z-index:10;margin-top:.5rem}:where(.dropdown-menu>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(.25rem * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(.25rem * 1) * calc(1 - var(--tw-space-y-reverse)))}.dropdown-menu{border-radius:var(--radius-box);background-color:var(--color-base-100);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));text-wrap:nowrap;opacity:0;--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:var(--color-base-300);padding:.5rem}@supports (color:color-mix(in lab, red, red)){.dropdown-menu{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-base-300) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dropdown-menu{transition-property:opacity,margin;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.3s;transition-duration:.3s}.z-20{z-index:20}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.filter{flex-wrap:wrap;display:flex}.filter input[type=radio]{width:auto}.filter input{opacity:1;border-width:0;transition:margin .1s,opacity .3s,padding .3s,border-width .1s;overflow:hidden;scale:1}.filter input:not(:last-child){margin-inline-end:.25rem}.filter input.filter-reset{aspect-ratio:1}.filter input.filter-reset:after{content:"×"}.filter:not(:has(input:checked:not(.filter-reset))) .filter-reset,.filter:not(:has(input:checked:not(.filter-reset))) input[type=reset],.filter:has(input:checked:not(.filter-reset)) input:not(:checked,.filter-reset,input[type=reset]){opacity:0;border-width:0;width:0;margin-inline:0;padding-inline:0;scale:0}.mx-auto{margin-inline:auto}.input-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--size:calc(var(--size-field,.25rem) * 7.5);padding-inline:.625rem}.input-sm[type=number]::-webkit-inner-spin-button{margin-block:-.5rem;margin-inline-end:-.75rem}.\!ms-auto{margin-inline-start:auto!important}.ms-0\.5{margin-inline-start:calc(var(--spacing) * .5)}.ms-2{margin-inline-start:calc(var(--spacing) * 2)}.input::file-selector-button{cursor:pointer;border-style:var(--tw-border-style);background-color:var(--color-primary);text-align:center;height:100%;font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-leading:1;--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-primary-content);text-transform:uppercase;webkit-user-select:none;-webkit-user-select:none;user-select:none;--input-color:var(--color-primary);border-width:0;flex-wrap:wrap;flex-shrink:0;justify-content:center;align-items:center;margin-inline-end:1rem;padding-inline:1rem;text-decoration-line:none;display:inline-flex}.input[type=file]{padding-inline-start:0;overflow:hidden}.input-floating .input::file-selector-button{background-color:var(--color-base-100);color:var(--color-base-content);border-inline-end:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input-floating .input::file-selector-button{border-inline-end:var(--border) solid color-mix(in oklab, var(--color-base-content) 40%, #0000)}}.input-floating .input::file-selector-button{outline:none}.\!me-1\.5{margin-inline-end:calc(var(--spacing) * 1.5)!important}.mt-1{margin-top:var(--spacing)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.status{aspect-ratio:1;background-color:var(--color-neutral);vertical-align:middle;width:.625rem;height:.625rem;color:var(--color-neutral);background-position:50%;background-repeat:no-repeat;background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);border-radius:3.40282e38px;display:inline-block;box-shadow:0 2px 3px -1px}@supports (color:color-mix(in lab, red, red)){.status{box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000)}}.divider{width:100%;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));white-space:nowrap;align-self:stretch;align-items:center;display:flex}.divider:not(:empty){gap:1rem}.divider:before{content:"";border-inline-end-style:var(--tw-border-style);border-inline-end-width:0;border-top-style:var(--tw-border-style);--tw-border-style:solid;border-style:solid;border-top-width:1px;border-color:var(--color-base-content);flex-grow:1;width:100%;height:1px}@supports (color:color-mix(in lab, red, red)){.divider:before{border-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.divider:after{content:"";border-inline-end-style:var(--tw-border-style);border-inline-end-width:0;border-top-style:var(--tw-border-style);--tw-border-style:solid;border-style:solid;border-top-width:1px;border-color:var(--color-base-content);flex-grow:1;width:100%;height:1px}@supports (color:color-mix(in lab, red, red)){.divider:after{border-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.badge{border-radius:var(--radius-selector);text-align:center;vertical-align:middle;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));border:var(--border) solid var(--badge-border);width:fit-content;color:var(--badge-fg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-border:var(--badge-color,var(--color-neutral));--badge-bg:var(--badge-color,var(--color-neutral));--badge-fg:var(--color-neutral-content);--size:calc(var(--size-selector,.25rem) * 6);height:var(--size);justify-content:center;align-items:center;gap:.375rem;padding-inline:.75rem;display:inline-flex}.badge.badge-outline{--badge-fg:var(--badge-color,var(--color-neutral));--badge-bg:transparent;background-image:none}.badge.badge-soft{--badge-border:none;--badge-fg:var(--badge-color,var(--color-neutral));--badge-bg:var(--badge-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.badge.badge-soft{--badge-bg:color-mix(in oklab, var(--badge-color,var(--color-neutral)) 10%, var(--color-base-100))}}.badge.badge-soft{background-image:none}.progress{border-radius:var(--radius-box);background-color:var(--color-base-200);width:100%;height:.375rem;display:flex;overflow:hidden}.tabs{--tabs-height:auto;--tabs-direction:row;height:var(--tabs-height);flex-direction:var(--tabs-direction);display:flex}.progress-bar{border-radius:var(--radius-box);background-color:var(--color-neutral);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);white-space:nowrap;color:var(--color-neutral-content);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-timing-function:var(--ease-in-out);transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.5s;--tw-ease:var(--ease-in-out);justify-content:center;align-items:center;transition-duration:.5s;display:flex;overflow:hidden}.progress-bar.progress-indeterminate{background:linear-gradient(90deg, #0000 50%, var(--progress-color) 50%);background-repeat:no-repeat;background-size:50% 100%;width:100%;animation:4s ease-in-out infinite indeterminate-progress}.navbar{background-color:var(--color-base-100);align-items:center;width:100%;padding-block:.75rem;padding-inline:1.5rem;display:flex}.card-body{padding:var(--card-p,1.5rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.card-body :where(p){flex-grow:1}:where(.card-header)+.card-body,:where(.card-body)+.card-footer{padding-top:0}.label-text{cursor:pointer;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-base-content);padding:.25rem;display:block}.stats{grid-auto-flow:column;display:inline-grid}:where(.stats>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)));--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(0px * var(--tw-divide-y-reverse));border-bottom-width:calc(0px * calc(1 - var(--tw-divide-y-reverse)))}.stats{border-radius:var(--radius-box);--tw-border-style:solid;border-style:solid;border-color:var(--color-base-content);overflow-x:auto}@supports (color:color-mix(in lab, red, red)){.stats{border-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.stats{background-color:var(--color-base-100);box-shadow:var(--card-shadow,0 1px 3px 0 var(--color-base-300), 0 1px 2px -1px var(--color-base-300))}@supports (color:color-mix(in lab, red, red)){.stats{box-shadow:var(--card-shadow,0 1px 3px 0 color-mix(in oklab, var(--color-base-300) 20%, #0000), 0 1px 2px -1px color-mix(in oklab, var(--color-base-300) 20%, #0000))}}.stats{border-width:var(--card-border,0px)}.stats:where(.stats-border){border:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.stats:where(.stats-border){border:var(--border) solid color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.carousel-body{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.7s;flex-wrap:nowrap;transition-duration:.7s;display:flex}.tooltip{text-align:center;display:inline-block}.\!flex{display:flex!important}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.btn-circle{width:var(--size);height:var(--size);border-radius:3.40282e38px;padding-inline:0}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.input>.select{border-style:var(--tw-border-style);border-width:0;height:auto}.input>.select:focus,.input>.select:focus-within{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline-style:var(--tw-outline-style);outline-width:0}.\!h-2\.5{height:calc(var(--spacing) * 2.5)!important}.h-2{height:calc(var(--spacing) * 2)}.min-h-screen{min-height:100vh}.advance-select-option{cursor:pointer;border-radius:var(--radius-field);width:100%;font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal);color:var(--color-base-content);padding-block:.625rem;padding-inline:1rem}@media (hover:hover){.advance-select-option:hover{background-color:var(--color-neutral)}@supports (color:color-mix(in lab, red, red)){.advance-select-option:hover{background-color:color-mix(in oklab, var(--color-neutral) 10%, transparent)}}}.advance-select-option:focus{background-color:var(--color-neutral)}@supports (color:color-mix(in lab, red, red)){.advance-select-option:focus{background-color:color-mix(in oklab, var(--color-neutral) 10%, transparent)}}.advance-select-option:focus{--tw-outline-style:none;outline-style:none}.\!w-2\.5{width:calc(var(--spacing) * 2.5)!important}.w-40{width:calc(var(--spacing) * 40)}.w-56{width:calc(var(--spacing) * 56)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-full{max-width:100%}.max-w-sm{max-width:var(--container-sm)}.min-w-28{min-width:calc(var(--spacing) * 28)}.min-w-48{min-width:calc(var(--spacing) * 48)}.flex-1{flex:1}.flex-none{flex:none}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.\!justify-between{justify-content:space-between!important}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1{column-gap:var(--spacing)}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}:where(.accordion:where(.accordion-bordered)>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:where(.accordion:where(.accordion-bordered)>:not(:last-child)){border-color:color-mix(in oklab, var(--color-base-content) 25%, transparent)}}.accordion:where(.accordion-bordered){border-radius:var(--radius-box);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.accordion:where(.accordion-bordered){border-color:color-mix(in oklab, var(--color-base-content) 25%, transparent)}}.accordion:where(.accordion-bordered){background-color:var(--color-base-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}:where(.menu-horizontal>li:not(.menu-title)>.collapse>ul){border-radius:var(--radius-box);background-color:var(--color-base-100);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-shadow-color:var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){:where(.menu-horizontal>li:not(.menu-title)>.collapse>ul){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-base-300) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.\!rounded-full{border-radius:3.40282e38px!important}.\!rounded-xs{border-radius:var(--radius-xs)!important}.rounded-box{border-radius:var(--radius-box)}.rounded-field{border-radius:var(--radius-field)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.btn-text{--btn-shadow:"";color:var(--btn-color,var(--color-neutral));--btn-bg:"";--btn-border:"";--btn-noise:none;outline-color:var(--btn-color,var(--color-neutral));border-width:0}@media (hover:hover){.btn-text:hover{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-text:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 10%, #0000)}}}.btn-text:focus-visible{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-text:focus-visible{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 10%, #0000)}}.btn-text.btn-active{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-text.btn-active{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 10%, #0000)}}.btn-soft{--btn-shadow:"";color:var(--btn-color,var(--color-neutral));--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-soft{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 10%, var(--color-base-100))}}.btn-soft{--btn-noise:none;border-width:0}.btn-soft:focus-visible{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-soft:focus-visible{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 20%, var(--color-base-100))}}@media (hover:hover){.btn-soft:hover{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-soft:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 20%, var(--color-base-100))}}}.btn-soft.btn-active{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-soft.btn-active{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 20%, var(--color-base-100))}}.\!border-none{--tw-border-style:none!important;border-style:none!important}.input.is-valid,.input:has(.is-valid),.validate .input:valid,.validate .input:has(:valid){border-color:var(--color-success)}@media (hover:hover){:is(.input.is-valid,.input:has(.is-valid),.validate .input:valid,.validate .input:has(:valid)):hover{border-color:var(--color-success)}}:is(.input.is-valid,.input:has(.is-valid),.validate .input:valid,.validate .input:has(:valid)):has(:hover):not(:focus,:focus-within){border-color:var(--color-success)}:is(.input.is-valid,.input:has(.is-valid),.validate .input:valid,.validate .input:has(:valid)):focus,:is(.input.is-valid,.input:has(.is-valid),.validate .input:valid,.validate .input:has(:valid)):focus-within{--input-color:var(--color-success)}.input.is-invalid,.input:has(.is-invalid),.validate .input:invalid,.validate .input:has(:invalid){border-color:var(--color-error)}@media (hover:hover){:is(.input.is-invalid,.input:has(.is-invalid),.validate .input:invalid,.validate .input:has(:invalid)):hover{border-color:var(--color-error)}}:is(.input.is-invalid,.input:has(.is-invalid),.validate .input:invalid,.validate .input:has(:invalid)):has(:hover):not(:focus,:focus-within){border-color:var(--color-error)}:is(.input.is-invalid,.input:has(.is-invalid),.validate .input:invalid,.validate .input:has(:invalid)):focus,:is(.input.is-invalid,.input:has(.is-invalid),.validate .input:invalid,.validate .input:has(:invalid)):focus-within{--input-color:var(--color-error)}.validate .input:valid::file-selector-button{background-color:var(--color-success);color:var(--color-success-content);--input-color:var(--color-success)}.validate .input:invalid::file-selector-button{background-color:var(--color-error);color:var(--color-error-content);--input-color:var(--color-error)}.validate .input-floating>.input:valid::file-selector-button{background-color:var(--color-base-100);color:var(--color-base-content);border-inline-end:var(--border) solid var(--color-success)}.validate .input-floating>.input:invalid::file-selector-button{background-color:var(--color-base-100);color:var(--color-base-content);border-inline-end:var(--border) solid var(--color-error)}.input.is-invalid::file-selector-button{background-color:var(--color-error);color:var(--color-error-content);--input-color:var(--color-error)}.input-floating>.input.is-invalid::file-selector-button{background-color:var(--color-base-100);color:var(--color-base-content);border-inline-end:var(--border) solid var(--color-error)}.label-text:has(~.is-invalid),.label-text:has(~* .is-invalid),.is-invalid~.helper-text,:has(.is-invalid)~.helper-text,.is-invalid~.label-text,.is-invalid~.label-text *,.is-invalid~*>.label-text{color:var(--color-error)}select.is-invalid+.advance-select-toggle,select.is-invalid+.advance-select-toggle:focus,select.is-invalid+.advance-select-toggle:focus-within,.advance-select-tag:has(>.is-invalid),.advance-select-tag:has(>.is-invalid):focus,.advance-select-tag:has(>.is-invalid):focus-within{--input-color:var(--color-error)}@media (hover:hover){.input-floating:has(.is-invalid):hover{border-color:var(--color-error)}}.input-floating:has(.is-invalid) .input-floating-label,:is(.input-floating:has(.is-invalid):focus-within,.input-floating:has(.is-invalid):not(:has(input:placeholder-shown)),.input-floating:has(.is-invalid):has(.input:focus)) .input-floating-label,.textarea-floating:has(.is-invalid) .textarea-floating-label,:is(.textarea-floating:has(.is-invalid):focus-within,.textarea-floating:has(.is-invalid):not(:has(textarea:placeholder-shown)),.textarea-floating:has(.is-invalid):has(.textarea:focus)) .textarea-floating-label,.select-floating:has(.is-invalid) .select-floating-label,:is(.select-floating:has(.is-invalid):focus-within,.select-floating:has(.is-invalid):not(:has(select:placeholder-shown)),.select-floating:has(.is-invalid):has(.select:focus)) .select-floating-label{color:var(--color-error)}.input.is-valid::file-selector-button{background-color:var(--color-success);color:var(--color-success-content);--input-color:var(--color-success)}.input-floating>.input.is-valid::file-selector-button{background-color:var(--color-base-100);color:var(--color-base-content);border-inline-end:var(--border) solid var(--color-success)}.label-text:has(~.is-valid),.label-text:has(~* .is-valid),.is-valid~.helper-text,:has(.is-valid)~.helper-text,.is-valid~.label-text,.is-valid~.label-text *,.is-valid~*>.label-text{color:var(--color-success)}select.is-valid+.advance-select-toggle,select.is-valid+.advance-select-toggle:focus,select.is-valid+.advance-select-toggle:focus-within,.advance-select-tag:has(>.is-valid),.advance-select-tag:has(>.is-valid):focus,.advance-select-tag:has(>.is-valid):focus-within{--input-color:var(--color-success)}@media (hover:hover){.input-floating:has(.is-valid):hover{border-color:var(--color-success)}}.input-floating:has(.is-valid) .input-floating-label,:is(.input-floating:has(.is-valid):focus-within,.input-floating:has(.is-valid):not(:has(input:placeholder-shown)),.input-floating:has(.is-valid):has(.input:focus)) .input-floating-label,.textarea-floating:has(.is-valid) .textarea-floating-label,:is(.textarea-floating:has(.is-valid):focus-within,.textarea-floating:has(.is-valid):not(:has(textarea:placeholder-shown)),.textarea-floating:has(.is-valid):has(.textarea:focus)) .textarea-floating-label,.select-floating:has(.is-valid) .select-floating-label,:is(.select-floating:has(.is-valid):focus-within,.select-floating:has(.is-valid):not(:has(select:placeholder-shown)),.select-floating:has(.is-valid):has(.select:focus)) .select-floating-label{color:var(--color-success)}.checkbox.is-valid,.checkbox:has(.is-valid),.validate .checkbox:valid,.validate .checkbox:has(:valid){--input-color:var(--color-success);border-color:var(--input-color)}:is(.checkbox.is-valid,.checkbox:has(.is-valid),.validate .checkbox:valid,.validate .checkbox:has(:valid)):checked,:is(.checkbox.is-valid,.checkbox:has(.is-valid),.validate .checkbox:valid,.validate .checkbox:has(:valid))[aria-checked=true],:is(.checkbox.is-valid,.checkbox:has(.is-valid),.validate .checkbox:valid,.validate .checkbox:has(:valid)):has(>input:checked){--input-color:var(--color-success)}.checkbox.is-invalid,.checkbox:has(.is-invalid),.validate .checkbox:invalid,.validate .checkbox:has(:invalid){--input-color:var(--color-error);border-color:var(--input-color)}:is(.checkbox.is-invalid,.checkbox:has(.is-invalid),.validate .checkbox:invalid,.validate .checkbox:has(:invalid)):checked,:is(.checkbox.is-invalid,.checkbox:has(.is-invalid),.validate .checkbox:invalid,.validate .checkbox:has(:invalid))[aria-checked=true],:is(.checkbox.is-invalid,.checkbox:has(.is-invalid),.validate .checkbox:invalid,.validate .checkbox:has(:invalid)):has(>input:checked){--input-color:var(--color-error)}.checkbox.is-valid,.checkbox:has(.is-valid),.validate .checkbox:valid,.validate .checkbox:has(:valid){color:var(--color-success-content)}.checkbox.is-invalid,.checkbox:has(.is-invalid),.validate .checkbox:invalid,.validate .checkbox:has(:invalid){color:var(--color-error-content)}.select.is-valid,.select:has(.is-valid),.validate .select:valid,.validate .select:has(:valid){border-color:var(--color-success)}@media (hover:hover){:is(.select.is-valid,.select:has(.is-valid),.validate .select:valid,.validate .select:has(:valid)):hover{border-color:var(--color-success)}}:is(.select.is-valid,.select:has(.is-valid),.validate .select:valid,.validate .select:has(:valid)):has(:hover):not(:focus,:focus-within){border-color:var(--color-success)}:is(.select.is-valid,.select:has(.is-valid),.validate .select:valid,.validate .select:has(:valid)):focus,:is(.select.is-valid,.select:has(.is-valid),.validate .select:valid,.validate .select:has(:valid)):focus-within{--input-color:var(--color-success)}.select.is-invalid,.select:has(.is-invalid),.validate .select:invalid,.validate .select:has(:invalid){border-color:var(--color-error)}@media (hover:hover){:is(.select.is-invalid,.select:has(.is-invalid),.validate .select:invalid,.validate .select:has(:invalid)):hover{border-color:var(--color-error)}}:is(.select.is-invalid,.select:has(.is-invalid),.validate .select:invalid,.validate .select:has(:invalid)):has(:hover):not(:focus,:focus-within){border-color:var(--color-error)}:is(.select.is-invalid,.select:has(.is-invalid),.validate .select:invalid,.validate .select:has(:invalid)):focus,:is(.select.is-invalid,.select:has(.is-invalid),.validate .select:invalid,.validate .select:has(:invalid)):focus-within{--input-color:var(--color-error)}.textarea.is-valid,.textarea:has(.is-valid),.validate .textarea:valid,.validate .textarea:has(:valid){border-color:var(--color-success)}@media (hover:hover){:is(.textarea.is-valid,.textarea:has(.is-valid),.validate .textarea:valid,.validate .textarea:has(:valid)):hover{border-color:var(--color-success)}}:is(.textarea.is-valid,.textarea:has(.is-valid),.validate .textarea:valid,.validate .textarea:has(:valid)):has(:hover):not(:focus,:focus-within){border-color:var(--color-success)}:is(.textarea.is-valid,.textarea:has(.is-valid),.validate .textarea:valid,.validate .textarea:has(:valid)):focus,:is(.textarea.is-valid,.textarea:has(.is-valid),.validate .textarea:valid,.validate .textarea:has(:valid)):focus-within{--input-color:var(--color-success)}.textarea.is-invalid,.textarea:has(.is-invalid),.validate .textarea:invalid,.validate .textarea:has(:invalid){border-color:var(--color-error)}@media (hover:hover){:is(.textarea.is-invalid,.textarea:has(.is-invalid),.validate .textarea:invalid,.validate .textarea:has(:invalid)):hover{border-color:var(--color-error)}}:is(.textarea.is-invalid,.textarea:has(.is-invalid),.validate .textarea:invalid,.validate .textarea:has(:invalid)):has(:hover):not(:focus,:focus-within){border-color:var(--color-error)}:is(.textarea.is-invalid,.textarea:has(.is-invalid),.validate .textarea:invalid,.validate .textarea:has(:invalid)):focus,:is(.textarea.is-invalid,.textarea:has(.is-invalid),.validate .textarea:invalid,.validate .textarea:has(:invalid)):focus-within{--input-color:var(--color-error)}.\!border-base-content\/40{border-color:var(--color-base-content)!important}@supports (color:color-mix(in lab, red, red)){.\!border-base-content\/40{border-color:color-mix(in oklab, var(--color-base-content) 40%, transparent)!important}}.border-base-content\/20{border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.border-base-content\/20{border-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.border-base-content\/40{border-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.border-base-content\/40{border-color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}.progress-primary{background-color:var(--color-primary);color:var(--color-primary-content)}.\!bg-base-100{background-color:var(--color-base-100)!important}.bg-base-100{background-color:var(--color-base-100)}.bg-base-200{background-color:var(--color-base-200)}.bg-base-300\/60{background-color:var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.bg-base-300\/60{background-color:color-mix(in oklab, var(--color-base-300) 60%, transparent)}}.bg-primary{background-color:var(--color-primary)}.loading-spinner{-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E");mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='%23000' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E.spinner_V8m1%7Btransform-origin:center;animation:spinner_zKoa 2s linear infinite%7D.spinner_V8m1 circle%7Bstroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite%7D%40keyframes spinner_zKoa%7B100%25%7Btransform:rotate(360deg)%7D%7D%40keyframes spinner_YpZS%7B0%25%7Bstroke-dasharray:0 150;stroke-dashoffset:0%7D47.5%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-16%7D95%25%2C100%25%7Bstroke-dasharray:42 150;stroke-dashoffset:-59%7D%7D%3C%2Fstyle%3E%3Cg class='spinner_V8m1'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3'%3E%3C%2Fcircle%3E%3C%2Fg%3E%3C%2Fsvg%3E")}.fill-black{fill:var(--color-black)}.fill-gray-200{fill:var(--color-gray-200)}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.table-sm :where(thead,tfoot){font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.table-sm :not(thead,tfoot) tr{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.badge-sm{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--size:calc(var(--size-selector,.25rem) * 5);padding-inline:.5rem}.\!px-2\.5{padding-inline:calc(var(--spacing) * 2.5)!important}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.\!py-0\.5{padding-block:calc(var(--spacing) * .5)!important}.py-2{padding-block:calc(var(--spacing) * 2)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pl-5{padding-left:calc(var(--spacing) * 5)}.text-center{text-align:center}.align-bottom{vertical-align:bottom}.card-title{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--color-base-content)}.btn-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 7.5)}.\!text-sm{font-size:var(--text-sm)!important;line-height:var(--tw-leading,var(--text-sm--line-height))!important}.input-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.\!font-medium{--tw-font-weight:var(--font-weight-medium)!important;font-weight:var(--font-weight-medium)!important}.\!font-semibold{--tw-font-weight:var(--font-weight-semibold)!important;font-weight:var(--font-weight-semibold)!important}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.btn-outline{--btn-shadow:"";--btn-bg:"";color:var(--btn-color,var(--color-neutral));--btn-border:var(--btn-color,var(--color-neutral));--btn-noise:none;outline-color:var(--btn-color,var(--color-neutral))}@media (hover:hover){.btn-outline:hover{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-outline:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 10%, #0000)}}}.btn-outline:focus-visible{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-outline:focus-visible{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 10%, #0000)}}.btn-outline:focus-visible{outline-width:1px}.btn-outline.btn-active{--btn-bg:var(--btn-color,var(--color-neutral))}@supports (color:color-mix(in lab, red, red)){.btn-outline.btn-active{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-neutral)) 10%, #0000)}}.label-text:has(~.is-valid),.label-text:has(~* .is-valid),.validate .label-text:has(~:valid),.validate .label-text:has(~* :valid),.is-valid~.label-text,.is-valid~.label-text *,.is-valid~*>.label-text,.validate :valid~.label-text,.validate :valid~.label-text *,.validate :valid~*>.label-text{color:var(--color-success)}.label-text:has(~.is-invalid),.label-text:has(~* .is-invalid),.validate .label-text:has(~:invalid),.validate .label-text:has(~* :invalid),.is-invalid~.label-text,.is-invalid~.label-text *,.is-invalid~*>.label-text,.validate :invalid~.label-text,.validate :invalid~.label-text *,.validate :invalid~*>.label-text{color:var(--color-error)}.alert-error{color:var(--color-error-content);--alert-color:var(--color-error)}.alert-success{color:var(--color-success-content);--alert-color:var(--color-success)}.alert-warning{color:var(--color-warning-content);--alert-color:var(--color-warning)}.text-base-content,.text-base-content\/30{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.text-base-content\/30{color:color-mix(in oklab, var(--color-base-content) 30%, transparent)}}.text-base-content\/40{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.text-base-content\/40{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}.text-base-content\/50{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.text-base-content\/50{color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.text-base-content\/60{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.text-base-content\/60{color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.text-base-content\/70{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.text-base-content\/70{color:color-mix(in oklab, var(--color-base-content) 70%, transparent)}}.text-base-content\/80{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.text-base-content\/80{color:color-mix(in oklab, var(--color-base-content) 80%, transparent)}}.text-error{color:var(--color-error)}.text-info{color:var(--color-info)}.text-primary{color:var(--color-primary)}.text-success{color:var(--color-success)}.text-warning{color:var(--color-warning)}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.opacity-0{opacity:0}.opacity-100{opacity:1}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-base-300\/20{--tw-shadow-color:var(--color-base-300)}@supports (color:color-mix(in lab, red, red)){.shadow-base-300\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-base-300) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.carousel-slide{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.7s;transition-duration:.7s}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.badge-primary{--badge-color:var(--color-primary);--badge-fg:var(--color-primary-content)}.badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}.validate select:has(~*>.selected)+.advance-select-toggle,.validate select:has(~*>.selected)+.advance-select-toggle:focus,.validate select:has(~*>.selected)+.advance-select-toggle:focus-within{--input-color:var(--color-success)}.focus\:border-primary:focus{border-color:var(--color-primary)}.focus\:outline-1:focus{outline-style:var(--tw-outline-style);outline-width:1px}.focus\:outline-primary:focus{outline-color:var(--color-primary)}@media (min-width:40rem){.sm\:inline{display:inline}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}}.rtl\:\!mr-0:where(:dir(rtl),[dir=rtl],[dir=rtl] *){margin-right:0!important}}@keyframes radio{0%{padding:5px}50%{padding:3px}}@keyframes radio-inset{0%{padding:1px}50%{padding:4px}}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}@keyframes indeterminate-progress{0%{background-position-x:-75%}50%{background-position-x:125%}to{background-position-x:-75%}}@keyframes progress-bar-stripes{0%{background-position-x:.75rem}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false} \ No newline at end of file diff --git a/internal/webui/dist/app.js b/internal/webui/dist/app.js new file mode 100644 index 0000000..8465f3f --- /dev/null +++ b/internal/webui/dist/app.js @@ -0,0 +1,423 @@ +'use strict'; + +// idsync 管理界面。刻意不引入前端框架:页面数量少、状态简单, +// 原生 JS 足以胜任,且省去一整套构建链与依赖升级负担。 + +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +// 操作类型的中文标签,与后端 syncer.OpKind 对应。 +const OP_LABELS = { + create_user: '新建用户', + update_user: '更新用户', + deactivate_user: '迁入离职 OU', + reactivate_user: '恢复用户', + create_group: '新建分组', + update_group: '更新分组', + add_member: '加入分组', + remove_member: '移出分组', +}; + +const OP_BADGES = { + create_user: 'badge-success', + create_group: 'badge-success', + update_user: 'badge-info', + update_group: 'badge-info', + reactivate_user: 'badge-warning', + deactivate_user: 'badge-error', + add_member: 'badge-success', + remove_member: 'badge-error', +}; + +// ---------------------------------------------------------------- 主题 + +function initTheme() { + const saved = localStorage.getItem('idsync-theme'); + if (saved === 'light' || saved === 'dark') { + document.documentElement.dataset.theme = saved; + } + $('#themeToggle').addEventListener('click', () => { + const root = document.documentElement; + const next = root.dataset.theme === 'dark' ? 'light' : 'dark'; + root.dataset.theme = next; + localStorage.setItem('idsync-theme', next); + }); +} + +// ---------------------------------------------------------------- HTTP + +async function api(path, options = {}) { + const res = await fetch(path, { + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + + if (res.status === 401) { + showLogin(); + throw new Error('登录状态已失效'); + } + + let body = null; + try { + body = await res.json(); + } catch (_) { + // 非 JSON 响应(如上游超时返回的 HTML),下面按状态码统一处理 + } + + if (!res.ok) { + throw new Error((body && body.error) || `请求失败(HTTP ${res.status})`); + } + return body; +} + +function showError(msg) { + const el = $('#globalError'); + el.textContent = msg; + el.classList.remove('hidden'); + setTimeout(() => el.classList.add('hidden'), 8000); +} + +function hideError() { + $('#globalError').classList.add('hidden'); +} + +// ---------------------------------------------------------------- 视图切换 + +function showLogin() { + $('#loginView').classList.remove('hidden'); + $('#loginView').classList.add('flex'); + $('#appView').classList.add('hidden'); +} + +function showApp() { + $('#loginView').classList.add('hidden'); + $('#loginView').classList.remove('flex'); + $('#appView').classList.remove('hidden'); +} + +function switchView(name) { + $$('.view').forEach((el) => el.classList.add('hidden')); + const view = $(`#view-${name}`); + if (view) view.classList.remove('hidden'); + + $$('.tabs .tab').forEach((t) => { + t.classList.toggle('tab-active', t.dataset.view === name); + }); + + if (name === 'overview') loadStatus(); + if (name === 'users') loadUsers(); + if (name === 'groups') loadGroups(); + if (name === 'plan') loadPlan(); + if (name === 'runs') loadRuns(); +} + +// ---------------------------------------------------------------- 登录 + +async function doLogin() { + const errEl = $('#loginError'); + errEl.classList.add('hidden'); + + try { + await api('/api/login', { + method: 'POST', + body: JSON.stringify({ username: $('#username').value, password: $('#password').value }), + }); + showApp(); + switchView('overview'); + } catch (err) { + errEl.textContent = err.message; + errEl.classList.remove('hidden'); + } +} + +async function doLogout() { + try { + await api('/api/logout', { method: 'POST' }); + } catch (_) { + // 登出失败也要回到登录页,本地状态不该依赖服务端响应 + } + showLogin(); +} + +// ---------------------------------------------------------------- 概览 + +async function loadStatus() { + try { + const d = await api('/api/status'); + hideError(); + + $('#statActive').textContent = d.counts.activeUsers; + $('#statInactive').textContent = d.counts.inactiveUsers; + $('#statGroups').textContent = d.counts.groups; + + $('#connStatus').innerHTML = [ + statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url), + statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`), + ].join(''); + + $('#lastRunInfo').innerHTML = d.lastRun + ? `
+
时间:${fmtTime(d.lastRun.finishedAt)}
+
模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}
+
变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}
+
` + : '暂无成功的同步记录'; + } catch (err) { + showError(err.message); + } +} + +function statusRow(name, ok, error, detail) { + const badge = ok + ? '正常' + : '异常'; + const extra = ok + ? `${escapeHtml(detail || '')}` + : `${escapeHtml(error || '')}`; + return `
+ ${name}${badge} + ${extra} +
`; +} + +// ---------------------------------------------------------------- 用户 + +let usersCache = []; + +async function loadUsers() { + try { + const d = await api('/api/users'); + usersCache = d.users || []; + renderUsers(); + hideError(); + } catch (err) { + showError(err.message); + } +} + +function renderUsers() { + const kw = $('#userFilter').value.trim().toLowerCase(); + const rows = usersCache.filter((u) => { + if (!kw) return true; + return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw)); + }); + + $('#userRows').innerHTML = rows.map((u) => ` + + ${escapeHtml(u.uid)} + ${escapeHtml(u.name || '')} + ${escapeHtml(u.email || '')} + ${u.state === 'inactive' + ? '离职' + : '在职'} + ${(u.deptIds || '').split(',').filter(Boolean).length} + ${fmtTime(u.lastSyncedAt)} + ${u.drift + ? `需关注` + : '—'} + ${escapeHtml(u.dn)} + `).join(''); + + $('#userEmpty').classList.toggle('hidden', rows.length > 0); +} + +// ---------------------------------------------------------------- 分组 + +async function loadGroups() { + try { + const d = await api('/api/groups'); + const groups = d.groups || []; + $('#groupRows').innerHTML = groups.map((g) => ` + + ${escapeHtml(g.name || '')} + ${escapeHtml(g.deptId || '')} + ${escapeHtml(g.parentId || '—')} + ${escapeHtml(g.dn || '')} + `).join(''); + $('#groupEmpty').classList.toggle('hidden', groups.length > 0); + hideError(); + } catch (err) { + showError(err.message); + } +} + +// ---------------------------------------------------------------- 变更预览 + +let currentPlan = null; + +async function loadPlan() { + $('#planRows').innerHTML = ''; + $('#planSummary').innerHTML = ''; + $('#planWarnings').classList.add('hidden'); + $('#planEmpty').classList.add('hidden'); + + try { + const plan = await api('/api/plan'); + currentPlan = plan; + renderPlan(plan); + hideError(); + } catch (err) { + showError(err.message); + } +} + +function renderPlan(plan) { + const s = plan.stats || {}; + + const cards = [ + ['新建用户', s.createUser, 'text-success'], + ['更新用户', s.updateUser, 'text-info'], + ['迁入离职', s.deactivateUser, 'text-error'], + ['恢复用户', s.reactivateUser, 'text-warning'], + ['新建分组', s.createGroup, 'text-success'], + ['加入分组', s.addMember, 'text-success'], + ['移出分组', s.removeMember, 'text-error'], + ]; + + $('#planSummary').innerHTML = cards.map(([label, value, cls]) => ` +
+
+ ${label} + ${value || 0} +
+
`).join(''); + + const warnings = plan.warnings || []; + if (warnings.length > 0) { + const el = $('#planWarnings'); + el.innerHTML = `
+
+
需要注意 ${warnings.length} 项
+
    + ${warnings.map((w) => `
  • ${escapeHtml(w)}
  • `).join('')} +
+
+
`; + el.classList.remove('hidden'); + } + + const ops = plan.ops || []; + $('#planRows').innerHTML = ops.map((op) => ` + + ${OP_LABELS[op.kind] || op.kind} + ${escapeHtml(op.target)} + ${escapeHtml(op.detail)} + `).join(''); + + $('#planEmpty').classList.toggle('hidden', ops.length > 0); +} + +async function applyPlan(force = false) { + const modal = document.getElementById('applyModal'); + if (modal && typeof modal.close === 'function') modal.close(); + + try { + const d = await api('/api/sync', { + method: 'POST', + body: JSON.stringify({ dryRun: false, force }), + }); + const res = d.result || {}; + showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`); + $('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4'; + switchView('runs'); + } catch (err) { + showError(err.message); + } +} + +// ---------------------------------------------------------------- 同步历史 + +async function loadRuns() { + try { + const d = await api('/api/runs'); + const runs = d.runs || []; + $('#runRows').innerHTML = runs.map((r) => ` + + ${fmtTime(r.startedAt)} + ${r.mode === 'dry-run' + ? '预览' + : '执行'} + ${r.trigger === 'schedule' ? '定时' : '手动'} + ${r.status === 'success' + ? '成功' + : '失败'} + ${r.created}${r.updated}${r.moved} + ${r.added}${r.removed} + ${r.failed > 0 ? `${r.failed}` : '0'} + `).join(''); + $('#runEmpty').classList.toggle('hidden', runs.length > 0); + hideError(); + } catch (err) { + showError(err.message); + } +} + +// ---------------------------------------------------------------- 工具 + +function fmtTime(s) { + if (!s) return '—'; + const d = new Date(s); + if (isNaN(d.getTime())) return '—'; + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// ---------------------------------------------------------------- 启动 + +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + + $('#loginBtn').addEventListener('click', doLogin); + $('#password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); + $('#username').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); + $('#logoutBtn').addEventListener('click', doLogout); + + $$('.tabs .tab').forEach((t) => { + t.addEventListener('click', () => switchView(t.dataset.view)); + }); + + $('#userFilter').addEventListener('input', renderUsers); + $('#reloadUsers').addEventListener('click', loadUsers); + $('#reloadRuns').addEventListener('click', loadRuns); + $('#previewBtn').addEventListener('click', loadPlan); + + $('#applyBtn').addEventListener('click', () => { + if (!currentPlan) return; + const s = currentPlan.stats || {}; + const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0) + + (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0); + if (total === 0) { + showError('没有需要应用的变更。'); + return; + } + $('#applyModalBody').innerHTML = ` +
+
新建用户 ${s.createUser || 0} · 更新用户 ${s.updateUser || 0}
+
迁入离职 OU ${s.deactivateUser || 0} · 恢复用户 ${s.reactivateUser || 0}
+
新建分组 ${s.createGroup || 0} · 加入分组 ${s.addMember || 0} · 移出分组 ${s.removeMember || 0}
+
`; + const modal = document.getElementById('applyModal'); + if (modal && typeof modal.showModal === 'function') modal.showModal(); + }); + + $('#applyCancel').addEventListener('click', () => { + const modal = document.getElementById('applyModal'); + if (modal && typeof modal.close === 'function') modal.close(); + }); + $('#applyConfirm').addEventListener('click', () => applyPlan(false)); + + // 已有会话则直接进入主界面 + api('/api/status') + .then(() => { showApp(); switchView('overview'); }) + .catch(() => showLogin()); +}); diff --git a/internal/webui/dist/flyonui.js b/internal/webui/dist/flyonui.js new file mode 100644 index 0000000..97f12bb --- /dev/null +++ b/internal/webui/dist/flyonui.js @@ -0,0 +1,206 @@ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(self,()=>(()=>{"use strict";var e={12:function(e,t,i){ +/* + * HSCopyMarkup + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t){super(e,t),this.count=0;const i=e.getAttribute("data-copy-markup"),s=i?JSON.parse(i):{},n=Object.assign(Object.assign({},s),t);this.targetSelector=(null==n?void 0:n.targetSelector)||null,this.wrapperSelector=(null==n?void 0:n.wrapperSelector)||null,this.limit=(null==n?void 0:n.limit)||null,this.items=[],this.targetSelector&&this.init()}elementClick(){this.copy()}deleteItemButtonClick(e){this.delete(e)}init(){this.createCollection(window.$hsCopyMarkupCollection,this),this.onElementClickListener=()=>this.elementClick(),this.setTarget(),this.setWrapper(),this.addPredefinedItems(),this.el.addEventListener("click",this.onElementClickListener)}copy(){if(this.limit&&this.items.length>=this.limit)return!1;this.el.hasAttribute("disabled")&&this.el.setAttribute("disabled","");const e=this.target.cloneNode(!0),t=`${this.target.id}-${this.count++}`;e.setAttribute("id",t),this.addToItems(e),this.limit&&this.items.length>=this.limit&&this.el.setAttribute("disabled","disabled"),this.fireEvent("copy",e),(0,n.dispatch)("copy.copyMarkup",e,e)}addPredefinedItems(){Array.from(this.wrapper.children).filter(e=>!e.classList.contains("[--ignore-for-count]")).forEach(e=>{this.addToItems(e)}),this.limit&&this.items.length>=this.limit&&this.el.setAttribute("disabled","disabled")}setTarget(){const e="string"==typeof this.targetSelector?document.querySelector(this.targetSelector).cloneNode(!0):this.targetSelector.cloneNode(!0);this.target=e}setWrapper(){this.wrapper="string"==typeof this.wrapperSelector?document.querySelector(this.wrapperSelector):this.wrapperSelector}addToItems(e){const t=e.querySelector("[data-copy-markup-delete-item]");this.wrapper?this.wrapper.append(e):this.el.before(e),t&&(this.onDeleteItemButtonClickListener=()=>this.deleteItemButtonClick(e),t.addEventListener("click",this.onDeleteItemButtonClickListener)),this.items.push(e)}delete(e){if(e){const t=this.items.indexOf(e);-1!==t&&this.items.splice(t,1),e.remove(),this.fireEvent("delete",e),(0,n.dispatch)("delete.copyMarkup",e,e),this.limit&&this.items.lengthe.removeEventListener("click",this.onDeleteItemButtonClickListener)),this.el.removeAttribute("disabled"),this.target=null,this.wrapper=null,this.items=null,window.$hsCopyMarkupCollection=window.$hsCopyMarkupCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsCopyMarkupCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsCopyMarkupCollection||(window.$hsCopyMarkupCollection=[]),window.$hsCopyMarkupCollection&&(window.$hsCopyMarkupCollection=window.$hsCopyMarkupCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-copy-markup]:not(.--prevent-on-load-init)").forEach(e=>{if(!window.$hsCopyMarkupCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})){const t=e.getAttribute("data-copy-markup"),i=t?JSON.parse(t):{};new l(e,i)}})}}window.addEventListener("load",()=>{l.autoInit()}),"undefined"!=typeof window&&(window.HSCopyMarkup=l),t.default=l},75:function(e,t,i){ +/* + * HSComboBox + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__awaiter||function(e,t,i,s){return new(i||(i=Promise))(function(n,o){function l(e){try{a(s.next(e))}catch(e){o(e)}}function r(e){try{a(s.throw(e))}catch(e){o(e)}}function a(e){var t;e.done?n(e.value):(t=e.value,t instanceof i?t:new i(function(e){e(t)})).then(l,r)}a((s=s.apply(e,t||[])).next())})},n=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const o=i(806),l=n(i(287)),r=n(i(294));class a extends l.default{constructor(e,t,i){var s,n,o,l,r,a,h,d,c,u,p,m,g,v,f,w,y,b,C,S,L,I,E,T,x,k,A,O,P,B,$;super(e,t,i),this.isSearchLengthExceeded=!1;const M=e.getAttribute("data-combo-box"),D=M?JSON.parse(M):{},N=Object.assign(Object.assign({},D),t);this.gap=6,this.viewport=null!==(s="string"==typeof(null==N?void 0:N.viewport)?document.querySelector(null==N?void 0:N.viewport):null==N?void 0:N.viewport)&&void 0!==s?s:null,this.preventVisibility=null!==(n=null==N?void 0:N.preventVisibility)&&void 0!==n&&n,this.minSearchLength=null!==(o=null==N?void 0:N.minSearchLength)&&void 0!==o?o:0,this.apiUrl=null!==(l=null==N?void 0:N.apiUrl)&&void 0!==l?l:null,this.apiDataPart=null!==(r=null==N?void 0:N.apiDataPart)&&void 0!==r?r:null,this.apiQuery=null!==(a=null==N?void 0:N.apiQuery)&&void 0!==a?a:null,this.apiSearchQuery=null!==(h=null==N?void 0:N.apiSearchQuery)&&void 0!==h?h:null,this.apiSearchPath=null!==(d=null==N?void 0:N.apiSearchPath)&&void 0!==d?d:null,this.apiSearchDefaultPath=null!==(c=null==N?void 0:N.apiSearchDefaultPath)&&void 0!==c?c:null,this.apiHeaders=null!==(u=null==N?void 0:N.apiHeaders)&&void 0!==u?u:{},this.apiGroupField=null!==(p=null==N?void 0:N.apiGroupField)&&void 0!==p?p:null,this.outputItemTemplate=null!==(m=null==N?void 0:N.outputItemTemplate)&&void 0!==m?m:'',this.outputEmptyTemplate=null!==(g=null==N?void 0:N.outputEmptyTemplate)&&void 0!==g?g:'',this.outputLoaderTemplate=null!==(v=null==N?void 0:N.outputLoaderTemplate)&&void 0!==v?v:'',this.groupingType=null!==(f=null==N?void 0:N.groupingType)&&void 0!==f?f:null,this.groupingTitleTemplate=null!==(w=null==N?void 0:N.groupingTitleTemplate)&&void 0!==w?w:"default"===this.groupingType?'
':'',this.tabsWrapperTemplate=null!==(y=null==N?void 0:N.tabsWrapperTemplate)&&void 0!==y?y:'
',this.preventSelection=null!==(b=null==N?void 0:N.preventSelection)&&void 0!==b&&b,this.preventAutoPosition=null!==(C=null==N?void 0:N.preventAutoPosition)&&void 0!==C&&C,this.preventClientFiltering=null!==(S=null==t?void 0:t.preventClientFiltering)&&void 0!==S?S:!!(null==N?void 0:N.apiSearchQuery)||!!(null==N?void 0:N.apiSearchPath),this.isOpenOnFocus=null!==(L=null==N?void 0:N.isOpenOnFocus)&&void 0!==L&&L,this.keepOriginalOrder=null!==(I=null==N?void 0:N.keepOriginalOrder)&&void 0!==I&&I,this.preserveSelectionOnEmpty=null===(E=null==N?void 0:N.preserveSelectionOnEmpty)||void 0===E||E,this.input=null!==(T=this.el.querySelector("[data-combo-box-input]"))&&void 0!==T?T:null,this.output=null!==(x=this.el.querySelector("[data-combo-box-output]"))&&void 0!==x?x:null,this.itemsWrapper=null!==(k=this.el.querySelector("[data-combo-box-output-items-wrapper]"))&&void 0!==k?k:null,this.items=null!==(A=Array.from(this.el.querySelectorAll("[data-combo-box-output-item]")))&&void 0!==A?A:[],this.tabs=[],this.toggle=null!==(O=this.el.querySelector("[data-combo-box-toggle]"))&&void 0!==O?O:null,this.toggleClose=null!==(P=this.el.querySelector("[data-combo-box-close]"))&&void 0!==P?P:null,this.toggleOpen=null!==(B=this.el.querySelector("[data-combo-box-open]"))&&void 0!==B?B:null,this.outputPlaceholder=null,this.selected=this.value=null!==($=this.el.querySelector("[data-combo-box-input]").value)&&void 0!==$?$:"",this.currentData=null,this.isOpened=!1,this.isCurrent=!1,this.animationInProcess=!1,this.selectedGroup="all",this.init()}inputFocus(){this.isOpened||(this.setResultAndRender(),this.open())}inputInput(e){const t=e.target.value.trim();t.length<=this.minSearchLength?this.setResultAndRender(""):this.setResultAndRender(t),this.preserveSelectionOnEmpty||""!==t||(this.selected="",this.value="",this.currentData=null),""!==this.input.value?this.el.classList.add("has-value"):this.el.classList.remove("has-value"),this.isOpened||this.open()}toggleClick(){this.isOpened?this.close():this.open(this.toggle.getAttribute("data-combo-box-toggle"))}toggleCloseClick(){this.close()}toggleOpenClick(){this.open()}init(){this.createCollection(window.$hsComboBoxCollection,this),this.build(),"undefined"!=typeof window&&(window.HSAccessibilityObserver||(window.HSAccessibilityObserver=new r.default),this.setupAccessibility())}build(){this.buildInput(),this.groupingType&&this.setGroups(),this.buildItems(),this.preventVisibility&&(this.preventAutoPosition||this.recalculateDirection()),this.toggle&&this.buildToggle(),this.toggleClose&&this.buildToggleClose(),this.toggleOpen&&this.buildToggleOpen()}getNestedProperty(e,t){return t.split(".").reduce((e,t)=>e&&e[t],e)}setValue(e,t=null){this.selected=e,this.value=e,this.input.value=e,t&&(this.currentData=t),this.fireEvent("select",this.currentData),(0,o.dispatch)("select.combobox",this.el,this.currentData)}setValueAndOpen(e){this.value=e,this.items.length&&this.setItemsVisibility()}setValueAndClear(e,t=null){e?this.setValue(e,t):this.setValue(this.selected,t),this.outputPlaceholder&&this.destroyOutputPlaceholder()}setSelectedByValue(e){this.items.forEach(t=>{const i=t.querySelector("[data-combo-box-value]");i&&e.includes(i.textContent)?t.classList.add("selected"):t.classList.remove("selected")})}setResultAndRender(e=""){let t=this.preventVisibility?this.input.value:e;this.setResults(t),(this.apiSearchQuery||this.apiSearchPath||this.apiSearchDefaultPath)&&this.itemsFromJson(),this.isSearchLengthExceeded=""===t,this.updatePlaceholderVisibility()}setResults(e){this.value=e,this.resultItems(),this.updatePlaceholderVisibility()}updatePlaceholderVisibility(){this.hasVisibleItems()?this.destroyOutputPlaceholder():this.buildOutputPlaceholder()}setGroups(){const e=[];this.items.forEach(t=>{const{group:i}=JSON.parse(t.getAttribute("data-combo-box-output-item"));e.some(e=>(null==e?void 0:e.name)===i.name)||e.push(i)}),this.groups=e}setApiGroups(e){const t=[];e.forEach(e=>{const i=e[this.apiGroupField];t.some(e=>e.name===i)||t.push({name:i,title:i})}),this.groups=t}setItemsVisibility(){if(this.preventClientFiltering)return this.items.forEach(e=>{e.style.display=""}),!1;"tabs"===this.groupingType&&"all"!==this.selectedGroup&&this.items.forEach(e=>{e.style.display="none"});const e="tabs"===this.groupingType?"all"===this.selectedGroup?this.items:this.items.filter(e=>{const{group:t}=JSON.parse(e.getAttribute("data-combo-box-output-item"));return t.name===this.selectedGroup}):this.items;"tabs"===this.groupingType&&"all"!==this.selectedGroup&&e.forEach(e=>{e.style.display="block"}),e.forEach(e=>{this.isTextExistsAny(e,this.value)?e.style.display="block":e.style.display="none"}),"default"===this.groupingType&&this.output.querySelectorAll("[data-combo-box-group-title]").forEach(e=>{const t=e.getAttribute("data-combo-box-group-title");this.items.filter(e=>{const{group:i}=JSON.parse(e.getAttribute("data-combo-box-output-item"));return i.name===t&&"block"===e.style.display}).length?e.style.display="block":e.style.display="none"})}isTextExistsAny(e,t){return Array.from(e.querySelectorAll("[data-combo-box-search-text]")).some(e=>e.getAttribute("data-combo-box-search-text").toLowerCase().includes(t.toLowerCase()))}hasVisibleItems(){return!!this.items.length&&this.items.some(e=>{const t=window.getComputedStyle(e);return"none"!==t.display&&"hidden"!==t.visibility})}valuesBySelector(e){return Array.from(e.querySelectorAll("[data-combo-box-search-text]")).reduce((e,t)=>[...e,t.getAttribute("data-combo-box-search-text")],[])}sortItems(){if(this.keepOriginalOrder)return this.items;return this.items.sort((e,t)=>{const i=e.querySelector("[data-combo-box-value]").textContent,s=t.querySelector("[data-combo-box-value]").textContent;return is?1:0})}buildInput(){this.isOpenOnFocus&&(this.onInputFocusListener=()=>this.inputFocus(),this.input.addEventListener("focus",this.onInputFocusListener)),this.onInputInputListener=(0,o.debounce)(e=>this.inputInput(e)),this.input.addEventListener("input",this.onInputInputListener)}buildItems(){return s(this,void 0,void 0,function*(){this.output.role="listbox",this.output.tabIndex=-1,this.output.ariaOrientation="vertical",this.apiUrl?yield this.itemsFromJson():(this.itemsWrapper?this.itemsWrapper.innerHTML="":this.output.innerHTML="",this.itemsFromHtml()),(null==this?void 0:this.items.length)&&this.items[0].classList.contains("selected")&&(this.currentData=JSON.parse(this.items[0].getAttribute("data-combo-box-item-stored-data")))})}buildOutputLoader(){if(this.outputLoader)return!1;this.outputLoader=(0,o.htmlToElement)(this.outputLoaderTemplate),this.items.length||this.outputPlaceholder?(this.outputLoader.style.position="absolute",this.outputLoader.style.top="0",this.outputLoader.style.bottom="0",this.outputLoader.style.left="0",this.outputLoader.style.right="0",this.outputLoader.style.zIndex="2"):(this.outputLoader.style.position="",this.outputLoader.style.top="",this.outputLoader.style.bottom="",this.outputLoader.style.left="",this.outputLoader.style.right="",this.outputLoader.style.zIndex="",this.outputLoader.style.height="30px"),this.output.append(this.outputLoader)}buildToggle(){var e,t,i,s;this.isOpened?((null===(e=null==this?void 0:this.toggle)||void 0===e?void 0:e.ariaExpanded)&&(this.toggle.ariaExpanded="true"),(null===(t=null==this?void 0:this.input)||void 0===t?void 0:t.ariaExpanded)&&(this.input.ariaExpanded="true")):((null===(i=null==this?void 0:this.toggle)||void 0===i?void 0:i.ariaExpanded)&&(this.toggle.ariaExpanded="false"),(null===(s=null==this?void 0:this.input)||void 0===s?void 0:s.ariaExpanded)&&(this.input.ariaExpanded="false")),this.onToggleClickListener=()=>this.toggleClick(),this.toggle.addEventListener("click",this.onToggleClickListener)}buildToggleClose(){this.onToggleCloseClickListener=()=>this.toggleCloseClick(),this.toggleClose.addEventListener("click",this.onToggleCloseClickListener)}buildToggleOpen(){this.onToggleOpenClickListener=()=>this.toggleOpenClick(),this.toggleOpen.addEventListener("click",this.onToggleOpenClickListener)}buildOutputPlaceholder(){this.outputPlaceholder||(this.outputPlaceholder=(0,o.htmlToElement)(this.outputEmptyTemplate)),this.appendItemsToWrapper(this.outputPlaceholder)}destroyOutputLoader(){this.outputLoader&&this.outputLoader.remove(),this.outputLoader=null}itemRender(e){var t;const i=e.querySelector("[data-combo-box-value]").textContent,s=null!==(t=JSON.parse(e.getAttribute("data-combo-box-item-stored-data")))&&void 0!==t?t:null;this.itemsWrapper?this.itemsWrapper.append(e):this.output.append(e),this.preventSelection||e.addEventListener("click",()=>{this.close(i,s),this.setSelectedByValue(this.valuesBySelector(e))})}plainRender(e){e.forEach(e=>{this.itemRender(e)})}jsonItemsRender(e){e.forEach((e,t)=>{const i=(0,o.htmlToElement)(this.outputItemTemplate);i.setAttribute("data-combo-box-item-stored-data",JSON.stringify(e)),i.querySelectorAll("[data-combo-box-output-item-field]").forEach(t=>{const i=t.getAttribute("data-combo-box-output-item-field");let s="";try{const t=JSON.parse(i);s=Array.isArray(t)?t.map(t=>this.getNestedProperty(e,t)).filter(Boolean).join(" "):this.getNestedProperty(e,i)}catch(t){s=this.getNestedProperty(e,i)}t.textContent=null!=s?s:"",!s&&t.hasAttribute("data-combo-box-output-item-hide-if-empty")&&(t.style.display="none")}),i.querySelectorAll("[data-combo-box-search-text]").forEach(t=>{const i=t.getAttribute("data-combo-box-output-item-field");let s="";try{const t=JSON.parse(i);s=Array.isArray(t)?t.map(t=>this.getNestedProperty(e,t)).filter(Boolean).join(" "):this.getNestedProperty(e,i)}catch(t){s=this.getNestedProperty(e,i)}t.setAttribute("data-combo-box-search-text",null!=s?s:"")}),i.querySelectorAll("[data-combo-box-output-item-attr]").forEach(t=>{JSON.parse(t.getAttribute("data-combo-box-output-item-attr")).forEach(i=>{let s=e[i.valueFrom];"class"===i.attr&&t.className?t.className=`${t.className} ${s}`.trim():t.setAttribute(i.attr,s)})}),i.setAttribute("tabIndex",`${t}`),"tabs"!==this.groupingType&&"default"!==this.groupingType||i.setAttribute("data-combo-box-output-item",`{"group": {"name": "${e[this.apiGroupField]}", "title": "${e[this.apiGroupField]}"}}`),this.items=[...this.items,i],this.preventSelection||i.addEventListener("click",()=>{this.close(i.querySelector("[data-combo-box-value]").textContent,JSON.parse(i.getAttribute("data-combo-box-item-stored-data"))),this.setSelectedByValue(this.valuesBySelector(i))}),this.appendItemsToWrapper(i)})}groupDefaultRender(){this.groups.forEach(e=>{const t=(0,o.htmlToElement)(this.groupingTitleTemplate);t.setAttribute("data-combo-box-group-title",e.name),t.classList.add("--exclude-accessibility"),t.innerText=e.title,this.itemsWrapper?this.itemsWrapper.append(t):this.output.append(t);const i=this.sortItems().filter(t=>{const{group:i}=JSON.parse(t.getAttribute("data-combo-box-output-item"));return i.name===e.name});this.plainRender(i)})}groupTabsRender(){const e=(0,o.htmlToElement)(this.tabsWrapperTemplate),t=(0,o.htmlToElement)('
');e.append(t),this.output.insertBefore(e,this.output.firstChild);const i=(0,o.htmlToElement)(this.groupingTitleTemplate);i.setAttribute("data-combo-box-group-title","all"),i.classList.add("--exclude-accessibility","active"),i.innerText="All",this.tabs=[...this.tabs,i],t.append(i),i.addEventListener("click",()=>{this.selectedGroup="all";const e=this.tabs.find(e=>e.getAttribute("data-combo-box-group-title")===this.selectedGroup);this.tabs.forEach(e=>e.classList.remove("active")),e.classList.add("active"),this.setItemsVisibility()}),this.groups.forEach(e=>{const i=(0,o.htmlToElement)(this.groupingTitleTemplate);i.setAttribute("data-combo-box-group-title",e.name),i.classList.add("--exclude-accessibility"),i.innerText=e.title,this.tabs=[...this.tabs,i],t.append(i),i.addEventListener("click",()=>{this.selectedGroup=e.name;const t=this.tabs.find(e=>e.getAttribute("data-combo-box-group-title")===this.selectedGroup);this.tabs.forEach(e=>e.classList.remove("active")),t.classList.add("active"),this.setItemsVisibility()})})}itemsFromHtml(){if("default"===this.groupingType)this.groupDefaultRender();else if("tabs"===this.groupingType){const e=this.sortItems();this.groupTabsRender(),this.plainRender(e)}else{const e=this.sortItems();this.plainRender(e)}this.setResults(this.input.value)}itemsFromJson(){return s(this,void 0,void 0,function*(){if(this.isSearchLengthExceeded)return this.buildOutputPlaceholder(),!1;this.buildOutputLoader();try{const e=`${this.apiQuery}`;let t,i,s=this.apiUrl;!this.apiSearchQuery&&this.apiSearchPath?(i=this.apiSearchDefaultPath&&""===this.value?`/${this.apiSearchDefaultPath}`:`/${this.apiSearchPath}/${this.value.toLowerCase()}`,(this.apiSearchPath||this.apiSearchDefaultPath)&&(s+=i)):(t=`${this.apiSearchQuery}=${this.value.toLowerCase()}`,this.apiQuery&&this.apiSearchQuery?s+=`?${t}&${e}`:this.apiQuery?s+=`?${e}`:this.apiSearchQuery&&(s+=`?${t}`));const n=yield fetch(s,this.apiHeaders);if(!n.ok)return this.items=[],this.itemsWrapper?this.itemsWrapper.innerHTML="":this.output.innerHTML="",void this.setResults(this.input.value);let l=yield n.json();this.apiDataPart&&(l=l[this.apiDataPart]),Array.isArray(l)||(l=[]),(this.apiSearchQuery||this.apiSearchPath)&&(this.items=[]),this.itemsWrapper?this.itemsWrapper.innerHTML="":this.output.innerHTML="","tabs"===this.groupingType?(this.setApiGroups(l),this.groupTabsRender(),this.jsonItemsRender(l)):"default"===this.groupingType?(this.setApiGroups(l),this.groups.forEach(e=>{const t=(0,o.htmlToElement)(this.groupingTitleTemplate);t.setAttribute("data-combo-box-group-title",e.name),t.classList.add("--exclude-accessibility"),t.innerText=e.title;const i=l.filter(t=>t[this.apiGroupField]===e.name);this.itemsWrapper?this.itemsWrapper.append(t):this.output.append(t),this.jsonItemsRender(i)})):this.jsonItemsRender(l),this.setResults(this.input.value.length<=this.minSearchLength?"":this.input.value),this.updatePlaceholderVisibility()}catch(e){console.error("Error fetching items:",e),this.items=[],this.itemsWrapper?this.itemsWrapper.innerHTML="":this.output.innerHTML="",this.setResults(this.input.value)}finally{this.destroyOutputLoader()}})}appendItemsToWrapper(e){this.itemsWrapper?this.itemsWrapper.append(e):this.output.append(e)}resultItems(){if(!this.items.length)return!1;this.setItemsVisibility(),this.setSelectedByValue([this.selected])}destroyOutputPlaceholder(){this.outputPlaceholder&&this.outputPlaceholder.remove(),this.outputPlaceholder=null}setHighlighted(e,t,i){t.focus(),i.value=t.querySelector("[data-combo-box-value]").getAttribute("data-combo-box-search-text"),e&&e.classList.remove("combo-box-output-item-highlighted"),t.classList.add("combo-box-output-item-highlighted")}setupAccessibility(){var e;const t=null!==(e=this.itemsWrapper)&&void 0!==e?e:this.output;this.accessibilityComponent=window.HSAccessibilityObserver.registerComponent(this.el,{onEnter:()=>this.onEnter(),onSpace:()=>this.onEnter(),onEsc:()=>{this.isOpened&&(this.close(),this.input&&this.input.focus())},onArrow:e=>{if(this.isOpened||this.preventVisibility||"ArrowDown"!==e.key){if(this.isOpened||this.preventVisibility)switch(e.key){case"ArrowDown":this.focusMenuItem("next");break;case"ArrowUp":this.focusMenuItem("prev");break;case"Home":this.onStartEnd(!0);break;case"End":this.onStartEnd(!1)}}else this.open()},onEnd:()=>this.onStartEnd(!1),onHome:()=>this.onStartEnd(!0)},this.isOpened||this.preventVisibility,"ComboBox","[data-combo-box]",t)}onEnter(){var e,t;if(this.isOpened){const i=document.activeElement,s=window.$hsComboBoxCollection.find(e=>!(0,o.isParentOrElementHidden)(e.element.el)&&i.closest("[data-combo-box]")===e.element.el);if(!s)return;const n=s.element.el.querySelector(".combo-box-output-item-highlighted a");i.hasAttribute("data-combo-box-input")?(s.element.close(),i.blur()):(s.element.preventSelection||s.element.setSelectedByValue(s.element.valuesBySelector(i)),s.element.preventSelection&&n&&window.location.assign(n.getAttribute("href")),s.element.close(s.element.preventSelection?null:null===(e=i.querySelector("[data-combo-box-value]"))||void 0===e?void 0:e.textContent,null!==(t=JSON.parse(i.getAttribute("data-combo-box-item-stored-data")))&&void 0!==t?t:null)),this.input&&this.input.focus()}else this.open()}focusMenuItem(e){var t;const i=null!==(t=this.itemsWrapper)&&void 0!==t?t:this.output;if(!i)return!1;const s=Array.from(i.querySelectorAll(":scope > *:not(.--exclude-accessibility)")).filter(e=>"none"!==e.style.display);if(!s.length)return!1;const n=i.querySelector(".combo-box-output-item-highlighted"),o=n?s.indexOf(n):-1,l="next"===e?(o+1)%s.length:(o-1+s.length)%s.length;n&&n.classList.remove("combo-box-output-item-highlighted"),s[l].classList.add("combo-box-output-item-highlighted"),s[l].focus(),this.input.value=s[l].querySelector("[data-combo-box-value]").getAttribute("data-combo-box-search-text")}onStartEnd(e=!0){var t;const i=null!==(t=this.itemsWrapper)&&void 0!==t?t:this.output;if(!i)return!1;const s=Array.from(i.querySelectorAll(":scope > *:not(.--exclude-accessibility)")).filter(e=>"none"!==e.style.display);if(!s.length)return!1;const n=i.querySelector(".combo-box-output-item-highlighted"),o=e?0:s.length-1;this.setHighlighted(n,s[o],this.input)}getCurrentData(){return this.currentData}setCurrent(){window.$hsComboBoxCollection.length&&(window.$hsComboBoxCollection.map(e=>e.element.isCurrent=!1),this.isCurrent=!0,this.preventVisibility&&window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,!0))}open(e){return!this.animationInProcess&&(void 0!==e&&this.setValueAndOpen(e),this.preventVisibility?(this.isOpened=!0,window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,!0),!1):(this.animationInProcess=!0,this.output.style.display="block",this.preventAutoPosition||this.recalculateDirection(),setTimeout(()=>{var e,t;(null===(e=null==this?void 0:this.input)||void 0===e?void 0:e.ariaExpanded)&&(this.input.ariaExpanded="true"),(null===(t=null==this?void 0:this.toggle)||void 0===t?void 0:t.ariaExpanded)&&(this.toggle.ariaExpanded="true"),this.el.classList.add("active"),this.animationInProcess=!1}),this.isOpened=!0,void(window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,!0))))}close(e,t=null){var i,s;return!this.animationInProcess&&(this.preventVisibility?(this.setValueAndClear(e,t),""!==this.input.value?this.el.classList.add("has-value"):this.el.classList.remove("has-value"),this.isOpened=!1,window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,!1),!1):(this.preserveSelectionOnEmpty||""!==this.input.value.trim()||(this.selected="",this.value=""),this.animationInProcess=!0,(null===(i=null==this?void 0:this.input)||void 0===i?void 0:i.ariaExpanded)&&(this.input.ariaExpanded="false"),(null===(s=null==this?void 0:this.toggle)||void 0===s?void 0:s.ariaExpanded)&&(this.toggle.ariaExpanded="false"),this.el.classList.remove("active"),this.preventAutoPosition||(this.output.classList.remove("bottom-full","top-full"),this.output.style.marginTop="",this.output.style.marginBottom=""),(0,o.afterTransition)(this.output,()=>{this.output.style.display="none",this.setValueAndClear(e,t||null),this.animationInProcess=!1}),""!==this.input.value?this.el.classList.add("has-value"):this.el.classList.remove("has-value"),this.isOpened=!1,void(window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,!1))))}recalculateDirection(){(0,o.isEnoughSpace)(this.output,this.input,"bottom",this.gap,this.viewport)?(this.output.classList.remove("bottom-full"),this.output.style.marginBottom="",this.output.classList.add("top-full"),this.output.style.marginTop=`${this.gap}px`):(this.output.classList.remove("top-full"),this.output.style.marginTop="",this.output.classList.add("bottom-full"),this.output.style.marginBottom=`${this.gap}px`)}destroy(){this.input.removeEventListener("focus",this.onInputFocusListener),this.input.removeEventListener("input",this.onInputInputListener),this.toggle.removeEventListener("click",this.onToggleClickListener),this.toggleClose&&this.toggleClose.removeEventListener("click",this.onToggleCloseClickListener),this.toggleOpen&&this.toggleOpen.removeEventListener("click",this.onToggleOpenClickListener),this.el.classList.remove("has-value","active"),this.items.length&&this.items.forEach(e=>{e.classList.remove("selected"),e.style.display=""}),this.output.removeAttribute("role"),this.output.removeAttribute("tabindex"),this.output.removeAttribute("aria-orientation"),this.outputLoader&&(this.outputLoader.remove(),this.outputLoader=null),this.outputPlaceholder&&(this.outputPlaceholder.remove(),this.outputPlaceholder=null),this.apiUrl&&(this.output.innerHTML=""),this.items=[],"undefined"!=typeof window&&window.HSAccessibilityObserver&&window.HSAccessibilityObserver.unregisterComponent(this.accessibilityComponent),window.$hsComboBoxCollection=window.$hsComboBoxCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsComboBoxCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsComboBoxCollection||(window.$hsComboBoxCollection=[],window.addEventListener("click",e=>{const t=e.target;a.closeCurrentlyOpened(t)})),window.$hsComboBoxCollection&&(window.$hsComboBoxCollection=window.$hsComboBoxCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-combo-box]:not(.--prevent-on-load-init)").forEach(e=>{if(!window.$hsComboBoxCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})){const t=e.getAttribute("data-combo-box"),i=t?JSON.parse(t):{};new a(e,i)}})}static close(e){const t=window.$hsComboBoxCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));t&&t.element.isOpened&&t.element.close()}static closeCurrentlyOpened(e=null){if(!e.closest("[data-combo-box].active")){const e=window.$hsComboBoxCollection.filter(e=>e.element.isOpened)||null;e&&e.forEach(e=>{e.element.close()})}}}window.addEventListener("load",()=>{a.autoInit()}),document.addEventListener("scroll",()=>{if(!window.$hsComboBoxCollection)return!1;const e=window.$hsComboBoxCollection.find(e=>e.element.isOpened);e&&!e.element.preventAutoPosition&&e.element.recalculateDirection()}),"undefined"!=typeof window&&(window.HSComboBox=a),t.default=a},86:function(e,t,i){ +/* + * HSInputNumber + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t){super(e,t),this.input=this.el.querySelector("[data-input-number-input]")||null,this.increment=this.el.querySelector("[data-input-number-increment]")||null,this.decrement=this.el.querySelector("[data-input-number-decrement]")||null,this.input&&this.checkIsNumberAndConvert();const i=this.el.dataset.inputNumber,s=i?JSON.parse(i):{step:1},n=Object.assign(Object.assign({},s),t);this.minInputValue="min"in n?n.min:0,this.maxInputValue="max"in n?n.max:null,this.step="step"in n&&n.step>0?n.step:1,this.init()}inputInput(){this.changeValue()}incrementClick(){this.changeValue("increment")}decrementClick(){this.changeValue("decrement")}init(){this.createCollection(window.$hsInputNumberCollection,this),this.input&&this.increment&&this.build()}checkIsNumberAndConvert(){const e=this.input.value.trim(),t=this.cleanAndExtractNumber(e);null!==t?(this.inputValue=t,this.input.value=t.toString()):(this.inputValue=0,this.input.value="0")}cleanAndExtractNumber(e){const t=[];let i=!1;e.split("").forEach(e=>{e>="0"&&e<="9"?t.push(e):"."!==e||i||(t.push(e),i=!0)});const s=t.join(""),n=parseFloat(s);return isNaN(n)?null:n}build(){this.input&&this.buildInput(),this.increment&&this.buildIncrement(),this.decrement&&this.buildDecrement(),this.inputValue<=this.minInputValue&&(this.inputValue=this.minInputValue,this.input.value=`${this.minInputValue}`),this.inputValue<=this.minInputValue&&this.changeValue(),this.input.hasAttribute("disabled")&&this.disableButtons()}buildInput(){this.onInputInputListener=()=>this.inputInput(),this.input.addEventListener("input",this.onInputInputListener)}buildIncrement(){this.onIncrementClickListener=()=>this.incrementClick(),this.increment.addEventListener("click",this.onIncrementClickListener)}buildDecrement(){this.onDecrementClickListener=()=>this.decrementClick(),this.decrement.addEventListener("click",this.onDecrementClickListener)}changeValue(e="none"){var t,i;const s={inputValue:this.inputValue},o=null!==(t=this.minInputValue)&&void 0!==t?t:Number.MIN_SAFE_INTEGER,l=null!==(i=this.maxInputValue)&&void 0!==i?i:Number.MAX_SAFE_INTEGER;switch(this.inputValue=isNaN(this.inputValue)?0:this.inputValue,e){case"increment":const e=this.inputValue+this.step;this.inputValue=e>=o&&e<=l?e:l,this.input.value=this.inputValue.toString();break;case"decrement":const t=this.inputValue-this.step;this.inputValue=t>=o&&t<=l?t:o,this.input.value=this.inputValue.toString();break;default:const i=isNaN(parseInt(this.input.value))?0:parseInt(this.input.value);this.inputValue=i>=l?l:i<=o?o:i,this.inputValue<=o&&(this.input.value=this.inputValue.toString())}s.inputValue=this.inputValue,this.inputValue===o?(this.el.classList.add("disabled"),this.decrement&&this.disableButtons("decrement")):(this.el.classList.remove("disabled"),this.decrement&&this.enableButtons("decrement")),this.inputValue===l?(this.el.classList.add("disabled"),this.increment&&this.disableButtons("increment")):(this.el.classList.remove("disabled"),this.increment&&this.enableButtons("increment")),this.fireEvent("change",s),(0,n.dispatch)("change.inputNumber",this.el,s)}disableButtons(e="all"){"all"===e?("BUTTON"!==this.increment.tagName&&"INPUT"!==this.increment.tagName||this.increment.setAttribute("disabled","disabled"),"BUTTON"!==this.decrement.tagName&&"INPUT"!==this.decrement.tagName||this.decrement.setAttribute("disabled","disabled")):"increment"===e?"BUTTON"!==this.increment.tagName&&"INPUT"!==this.increment.tagName||this.increment.setAttribute("disabled","disabled"):"decrement"===e&&("BUTTON"!==this.decrement.tagName&&"INPUT"!==this.decrement.tagName||this.decrement.setAttribute("disabled","disabled"))}enableButtons(e="all"){"all"===e?("BUTTON"!==this.increment.tagName&&"INPUT"!==this.increment.tagName||this.increment.removeAttribute("disabled"),"BUTTON"!==this.decrement.tagName&&"INPUT"!==this.decrement.tagName||this.decrement.removeAttribute("disabled")):"increment"===e?"BUTTON"!==this.increment.tagName&&"INPUT"!==this.increment.tagName||this.increment.removeAttribute("disabled"):"decrement"===e&&("BUTTON"!==this.decrement.tagName&&"INPUT"!==this.decrement.tagName||this.decrement.removeAttribute("disabled"))}destroy(){this.el.classList.remove("disabled"),this.increment.removeAttribute("disabled"),this.decrement.removeAttribute("disabled"),this.input.removeEventListener("input",this.onInputInputListener),this.increment.removeEventListener("click",this.onIncrementClickListener),this.decrement.removeEventListener("click",this.onDecrementClickListener),window.$hsInputNumberCollection=window.$hsInputNumberCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsInputNumberCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsInputNumberCollection||(window.$hsInputNumberCollection=[]),window.$hsInputNumberCollection&&(window.$hsInputNumberCollection=window.$hsInputNumberCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-input-number]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsInputNumberCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}}window.addEventListener("load",()=>{l.autoInit()}),"undefined"!=typeof window&&(window.HSInputNumber=l),t.default=l},113:function(e,t,i){ +/* + * HSRemoveElement + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t){super(e,t);const i=e.getAttribute("data-remove-element-options"),s=i?JSON.parse(i):{},n=Object.assign(Object.assign({},s),t);this.removeTargetId=this.el.getAttribute("data-remove-element"),this.removeTarget=document.querySelector(this.removeTargetId),this.removeTargetAnimationClass=(null==n?void 0:n.removeTargetAnimationClass)||"removing",this.removeTarget&&this.init()}elementClick(){this.remove()}init(){this.createCollection(window.$hsRemoveElementCollection,this),this.onElementClickListener=()=>this.elementClick(),this.el.addEventListener("click",this.onElementClickListener)}remove(){if(!this.removeTarget)return!1;this.removeTarget.classList.add(this.removeTargetAnimationClass),(0,n.afterTransition)(this.removeTarget,()=>setTimeout(()=>this.removeTarget.remove()))}destroy(){this.removeTarget.classList.remove(this.removeTargetAnimationClass),this.el.removeEventListener("click",this.onElementClickListener),window.$hsRemoveElementCollection=window.$hsRemoveElementCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsRemoveElementCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e)||t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static autoInit(){window.$hsRemoveElementCollection||(window.$hsRemoveElementCollection=[]),window.$hsRemoveElementCollection&&(window.$hsRemoveElementCollection=window.$hsRemoveElementCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-remove-element]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsRemoveElementCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}}window.addEventListener("load",()=>{l.autoInit()}),"undefined"!=typeof window&&(window.HSRemoveElement=l),t.default=l},126:function(e,t,i){ +/* + * HSToggleCount + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=s(i(287));class o extends n.default{constructor(e,t){super(e,t);const i=e.getAttribute("data-toggle-count"),s=i?JSON.parse(i):{},n=Object.assign(Object.assign({},s),t);this.target=(null==n?void 0:n.target)?"string"==typeof(null==n?void 0:n.target)?document.querySelector(n.target):n.target:null,this.min=(null==n?void 0:n.min)||0,this.max=(null==n?void 0:n.max)||0,this.duration=(null==n?void 0:n.duration)||700,this.isChecked=this.target.checked||!1,this.target&&this.init()}toggleChange(){this.isChecked=!this.isChecked,this.toggle()}init(){this.createCollection(window.$hsToggleCountCollection,this),this.isChecked&&(this.el.innerText=String(this.max)),this.onToggleChangeListener=()=>this.toggleChange(),this.target.addEventListener("change",this.onToggleChangeListener)}toggle(){this.isChecked?this.countUp():this.countDown()}animate(e,t){let i=0;const s=n=>{i||(i=n);const o=Math.min((n-i)/this.duration,1);this.el.innerText=String(Math.floor(o*(t-e)+e)),o<1&&window.requestAnimationFrame(s)};window.requestAnimationFrame(s)}countUp(){this.animate(this.min,this.max)}countDown(){this.animate(this.max,this.min)}destroy(){this.target.removeEventListener("change",this.onToggleChangeListener),window.$hsToggleCountCollection=window.$hsToggleCountCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsToggleCountCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsToggleCountCollection||(window.$hsToggleCountCollection=[]),window.$hsToggleCountCollection&&(window.$hsToggleCountCollection=window.$hsToggleCountCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-toggle-count]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsToggleCountCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new o(e)})}}window.addEventListener("load",()=>{o.autoInit()}),"undefined"!=typeof window&&(window.HSToggleCount=o),t.default=o},164:function(e,t,i){ +/* + * HSOverlay + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=i(917),l=s(i(287)),r=s(i(294));class a extends l.default{constructor(e,t,i){var s,l,r,a,h,d;super(e,t,i),this.lastFocusedToggle=null,this.initialZIndex=0,this.toggleButtons=Array.from(document.querySelectorAll(`[data-overlay="#${this.el.id}"]`));const c=this.collectToggleParameters(this.toggleButtons);this.toggleMinifierButtons=Array.from(document.querySelectorAll(`[data-overlay-minifier="#${this.el.id}"]`));const u=e.getAttribute("data-overlay-options"),p=u?JSON.parse(u):{},m=Object.assign(Object.assign(Object.assign({},p),c),t);this.hiddenClass=(null==m?void 0:m.hiddenClass)||"hidden",this.emulateScrollbarSpace=(null==m?void 0:m.emulateScrollbarSpace)||!1,this.isClosePrev=null===(s=null==m?void 0:m.isClosePrev)||void 0===s||s,this.backdropClasses=null!==(l=null==m?void 0:m.backdropClasses)&&void 0!==l?l:"overlay-backdrop transition duration-300 fixed inset-0 bg-base-300/60 overflow-y-auto",this.backdropParent="string"==typeof m.backdropParent?document.querySelector(m.backdropParent):document.body,this.backdropExtraClasses=null!==(r=null==m?void 0:m.backdropExtraClasses)&&void 0!==r?r:"",this.moveOverlayToBody=(null==m?void 0:m.moveOverlayToBody)||null,this.openNextOverlay=!1,this.autoHide=null,this.initContainer=(null===(a=this.el)||void 0===a?void 0:a.parentElement)||null,this.isCloseWhenClickInside=(0,n.stringToBoolean)((0,n.getClassProperty)(this.el,"--close-when-click-inside","false")||"false"),this.isTabAccessibilityLimited=(0,n.stringToBoolean)((0,n.getClassProperty)(this.el,"--tab-accessibility-limited","true")||"true"),this.isLayoutAffect=(0,n.stringToBoolean)((0,n.getClassProperty)(this.el,"--is-layout-affect","false")||"false"),this.hasAutofocus=(0,n.stringToBoolean)((0,n.getClassProperty)(this.el,"--has-autofocus","true")||"true"),this.hasDynamicZIndex=(0,n.stringToBoolean)((0,n.getClassProperty)(this.el,"--has-dynamic-z-index","false")||"false"),this.hasAbilityToCloseOnBackdropClick=(0,n.stringToBoolean)(this.el.getAttribute("data-overlay-keyboard")||"true");const g=(0,n.getClassProperty)(this.el,"--auto-close"),v=(0,n.getClassProperty)(this.el,"--auto-close-equality-type"),f=(0,n.getClassProperty)(this.el,"--opened");this.autoClose=!isNaN(+g)&&isFinite(+g)?+g:o.BREAKPOINTS[g]||null,this.autoCloseEqualityType=null!==(h=v)&&void 0!==h?h:null,this.openedBreakpoint=(!isNaN(+f)&&isFinite(+f)?+f:o.BREAKPOINTS[f])||null,this.animationTarget=(null===(d=null==this?void 0:this.el)||void 0===d?void 0:d.querySelector(".overlay-animation-target"))||this.el,this.initialZIndex=parseInt(getComputedStyle(this.el).zIndex,10),this.onElementClickListener=[],this.onElementMinifierClickListener=[],this.initiallyOpened=document.body.classList.contains("overlay-body-open"),this.init()}elementClick(){const e=()=>{const e={el:this.el,isOpened:!!this.el.classList.contains("open")};this.fireEvent("toggleClicked",e),(0,n.dispatch)("toggleClicked.overlay",this.el,e)};this.el.classList.contains("opened")?this.close(!1,e):this.open(e)}elementMinifierClick(){const e=()=>{const e={el:this.el,isMinified:!!this.el.classList.contains("minified")};this.fireEvent("toggleMinifierClicked",e),(0,n.dispatch)("toggleMinifierClicked.overlay",this.el,e)};this.el.classList.contains("minified")?this.minify(!1,e):this.minify(!0,e)}minify(e,t=null){e?(this.el.classList.add("minified"),document.body.classList.add("overlay-minified"),t&&t()):(this.el.classList.remove("minified"),document.body.classList.remove("overlay-minified"),t&&t())}overlayClick(e){e.target.id&&`#${e.target.id}`===this.el.id&&this.isCloseWhenClickInside&&this.hasAbilityToCloseOnBackdropClick&&this.close()}backdropClick(){this.close()}init(){if(this.createCollection(window.$hsOverlayCollection,this),this.isLayoutAffect&&this.openedBreakpoint){const e=a.getInstance(this.el,!0);a.setOpened(this.openedBreakpoint,e)}this.onOverlayClickListener=e=>this.overlayClick(e),this.el.addEventListener("click",this.onOverlayClickListener),this.toggleButtons.length&&this.buildToggleButtons(this.toggleButtons),this.toggleMinifierButtons.length&&this.buildToggleMinifierButtons(),"undefined"!=typeof window&&(window.HSAccessibilityObserver||(window.HSAccessibilityObserver=new r.default),this.setupAccessibility())}buildToggleButtons(e){e.forEach(e=>{this.el.classList.contains("opened")?e.ariaExpanded="true":e.ariaExpanded="false",this.onElementClickListener.push({el:e,fn:()=>this.elementClick()}),e.addEventListener("click",this.onElementClickListener.find(t=>t.el===e).fn)})}buildToggleMinifierButtons(){this.toggleMinifierButtons.forEach(e=>{this.el.classList.contains("minified")?e.ariaExpanded="true":e.ariaExpanded="false",this.onElementMinifierClickListener.push({el:e,fn:()=>this.elementMinifierClick()}),e.addEventListener("click",this.onElementMinifierClickListener.find(t=>t.el===e).fn)})}hideAuto(){const e=parseInt((0,n.getClassProperty)(this.el,"--auto-hide","0"));e&&(this.autoHide=setTimeout(()=>{this.close()},e))}checkTimer(){this.autoHide&&(clearTimeout(this.autoHide),this.autoHide=null)}buildBackdrop(){const e=this.el.classList.value.split(" "),t=parseInt(window.getComputedStyle(this.el).getPropertyValue("z-index")),i=this.el.getAttribute("data-overlay-backdrop-container")||!1;this.backdrop=document.createElement("div");let s=`${this.backdropClasses} ${this.backdropExtraClasses}`;const o="static"!==(0,n.getClassProperty)(this.el,"--overlay-backdrop","true"),l="false"===(0,n.getClassProperty)(this.el,"--overlay-backdrop","true");this.backdrop.id=`${this.el.id}-backdrop`,"style"in this.backdrop&&(this.backdrop.style.zIndex=""+(t-1));for(const t of e)(t.startsWith("overlay-backdrop-open:")||t.includes(":overlay-backdrop-open:"))&&(s+=` ${t}`);l||(i&&(this.backdrop=document.querySelector(i).cloneNode(!0),this.backdrop.classList.remove("hidden"),s=`${this.backdrop.classList.toString()}`,this.backdrop.classList.value=""),o&&(this.onBackdropClickListener=()=>this.backdropClick(),this.backdrop.addEventListener("click",this.onBackdropClickListener,!0)),this.backdrop.setAttribute("data-overlay-backdrop-template",""),this.backdropParent.appendChild(this.backdrop),setTimeout(()=>{this.backdrop.classList.value=s}))}destroyBackdrop(){const e=document.querySelector(`#${this.el.id}-backdrop`);e&&(this.openNextOverlay&&(e.style.transitionDuration=1.8*parseFloat(window.getComputedStyle(e).transitionDuration.replace(/[^\d.-]/g,""))+"s"),e.classList.add("opacity-0"),(0,n.afterTransition)(e,()=>{e.remove()}))}focusElement(){const e=this.el.querySelector("[autofocus]");if(!e)return!1;e.focus()}getScrollbarSize(){let e=document.createElement("div");e.style.overflow="scroll",e.style.width="100px",e.style.height="100px",document.body.appendChild(e);let t=e.offsetWidth-e.clientWidth;return document.body.removeChild(e),t}collectToggleParameters(e){let t={};return e.forEach(e=>{const i=e.getAttribute("data-overlay-options"),s=i?JSON.parse(i):{};t=Object.assign(Object.assign({},t),s)}),t}isElementVisible(){const e=window.getComputedStyle(this.el);if("none"===e.display||"hidden"===e.visibility||"0"===e.opacity)return!1;const t=this.el.getBoundingClientRect();if(0===t.width||0===t.height)return!1;let i=this.el.parentElement;for(;i;){const e=window.getComputedStyle(i);if("none"===e.display||"hidden"===e.visibility||"0"===e.opacity)return!1;i=i.parentElement}return!0}isOpened(){return this.el.classList.contains("open")&&!this.el.classList.contains(this.hiddenClass)}open(e=null){this.el.classList.contains("minified")&&this.minify(!1),this.hasDynamicZIndex&&(a.currentZIndexArray.from(t).includes(e.element.el)&&!e.element.isLayoutAffect),s=document.querySelectorAll(`[data-overlay="#${this.el.id}"]`),o="true"!==(0,n.getClassProperty)(this.el,"--body-scroll","false");if(this.lastFocusedToggle=document.activeElement,this.isClosePrev&&i)return this.openNextOverlay=!0,i.element.close().then(()=>{this.open(),this.openNextOverlay=!1});o&&(document.body.style.overflow="hidden",this.emulateScrollbarSpace&&(document.body.style.paddingRight=`${this.getScrollbarSize()}px`)),this.buildBackdrop(),this.checkTimer(),this.hideAuto(),s.forEach(e=>{e.ariaExpanded&&(e.ariaExpanded="true")}),this.el.classList.remove(this.hiddenClass),this.el.setAttribute("aria-overlay","true"),this.el.setAttribute("tabindex","-1"),setTimeout(()=>{if(this.el.classList.contains("opened"))return!1;this.el.classList.add("open","opened"),this.isLayoutAffect&&document.body.classList.add("overlay-body-open"),this.initiallyOpened||(this.el.focus(),this.el.style.outline="none"),this.initiallyOpened=!1,this.fireEvent("open",this.el),(0,n.dispatch)("open.overlay",this.el,this.el),window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,!0),this.hasAutofocus&&this.focusElement(),"function"==typeof e&&e(),this.isElementVisible()&&a.openedItemsQty++},50)}close(e=!1,t=null){this.isElementVisible()&&(a.openedItemsQty=a.openedItemsQty<=0?0:a.openedItemsQty-1),0===a.openedItemsQty&&this.isLayoutAffect&&document.body.classList.remove("overlay-body-open");const i=e=>{if(this.el.classList.contains("open"))return!1;document.querySelectorAll(`[data-overlay="#${this.el.id}"]`).forEach(e=>{e.ariaExpanded&&(e.ariaExpanded="false")}),this.el.classList.add(this.hiddenClass),this.hasDynamicZIndex&&(this.el.style.zIndex=""),this.destroyBackdrop(),this.fireEvent("close",this.el),(0,n.dispatch)("close.overlay",this.el,this.el),window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,!1),document.querySelector(".overlay.opened")||(document.body.style.overflow="",this.emulateScrollbarSpace&&(document.body.style.paddingRight="")),this.lastFocusedToggle&&(this.lastFocusedToggle.focus(),this.lastFocusedToggle=null),e(this.el),"function"==typeof t&&t(),0===a.openedItemsQty&&(document.body.classList.remove("overlay-body-open"),this.hasDynamicZIndex&&(a.currentZIndex=0))};return new Promise(t=>{this.el.classList.remove("open","opened"),this.el.removeAttribute("aria-overlay"),this.el.removeAttribute("tabindex"),this.el.style.outline="",e?i(t):(0,n.afterTransition)(this.animationTarget,()=>i(t))})}updateToggles(){const e=Array.from(document.querySelectorAll(`[data-overlay="#${this.el.id}"]`)).filter(e=>!this.toggleButtons.includes(e));e.length&&(this.toggleButtons.push(...e),this.buildToggleButtons(e)),this.toggleButtons=this.toggleButtons.filter(e=>{var t;if(document.contains(e))return!0;const i=null===(t=this.onElementClickListener)||void 0===t?void 0:t.find(t=>t.el===e);return i&&e.removeEventListener("click",i.fn),!1})}destroy(){this.el.classList.remove("open","opened",this.hiddenClass),this.isLayoutAffect&&document.body.classList.remove("overlay-body-open"),this.el.removeEventListener("click",this.onOverlayClickListener),this.onElementClickListener.length&&(this.onElementClickListener.forEach(({el:e,fn:t})=>{e.removeEventListener("click",t)}),this.onElementClickListener=null),this.backdrop&&this.backdrop.removeEventListener("click",this.onBackdropClickListener),this.backdrop&&(this.backdrop.remove(),this.backdrop=null),window.$hsOverlayCollection=window.$hsOverlayCollection.filter(({element:e})=>e.el!==this.el)}static findInCollection(e){return window.$hsOverlayCollection.find(t=>e instanceof a?t.element.el===e.el:"string"==typeof e?t.element.el===document.querySelector(e):t.element.el===e)||null}static getInstance(e,t){const i="string"==typeof e?document.querySelector(e):e,s=(null==i?void 0:i.getAttribute("data-overlay"))?i.getAttribute("data-overlay"):e,n=window.$hsOverlayCollection.find(e=>e.element.el===("string"==typeof s?document.querySelector(s):s)||e.element.el===("string"==typeof s?document.querySelector(s):s));return n?t?n:n.element.el:null}static autoInit(){window.$hsOverlayCollection||(window.$hsOverlayCollection=[]),window.$hsOverlayCollection&&(window.$hsOverlayCollection=window.$hsOverlayCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll(".overlay:not(.--prevent-on-load-init)").forEach(e=>{window.$hsOverlayCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new a(e)})}static open(e){const t=a.findInCollection(e);t&&t.element.el.classList.contains(t.element.hiddenClass)&&t.element.open()}static close(e){const t=a.findInCollection(e);t&&!t.element.el.classList.contains(t.element.hiddenClass)&&t.element.close()}static minify(e,t){const i=a.findInCollection(e);i&&i.element.minify(t)}static setOpened(e,t){document.body.clientWidth>=e?(t.element.el.classList.contains("minified")&&t.element.minify(!1),document.body.classList.add("overlay-body-open"),t.element.open()):t.element.close(!0)}setupAccessibility(){this.accessibilityComponent=window.HSAccessibilityObserver.registerComponent(this.el,{onEnter:()=>{this.isOpened()||this.open()},onEsc:()=>{this.isOpened()&&this.hasAbilityToCloseOnBackdropClick&&this.close()},onTab:()=>{var e;if(!this.isOpened()||!this.isTabAccessibilityLimited)return;const t=Array.from(this.el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')).filter(e=>!e.hidden&&"none"!==window.getComputedStyle(e).display);if(0===t.length)return;const i=this.el.querySelector(":focus"),s=i?t.indexOf(i):-1;window.event instanceof KeyboardEvent&&window.event.shiftKey?s<=0?t[t.length-1].focus():t[s-1].focus():s===t.length-1?t[0].focus():t[s+1].focus(),null===(e=window.event)||void 0===e||e.preventDefault()}},this.isOpened(),"Overlay",".overlay"),this.toggleButtons.forEach(e=>{window.HSAccessibilityObserver.registerComponent(e,{onEnter:()=>{this.isOpened()||this.open()},onEsc:()=>{this.isOpened()&&this.hasAbilityToCloseOnBackdropClick&&this.close()}},this.isOpened(),"Overlay Toggle",`[data-overlay="#${this.el.id}"]`)})}static on(e,t,i){const s=a.findInCollection(t);s&&(s.element.events[e]=i)}}a.openedItemsQty=0,a.currentZIndex=0;let h=null;const d=()=>{if(!window.$hsOverlayCollection.length||!window.$hsOverlayCollection.find(e=>e.element.moveOverlayToBody))return!1;window.$hsOverlayCollection.filter(e=>e.element.moveOverlayToBody).forEach(e=>{const t=e.element.moveOverlayToBody,i=e.element.initContainer,s=document.querySelector("body"),o=e.element.el;if(!i&&o)return!1;document.body.clientWidth<=t&&!(0,n.isDirectChild)(s,o)?s.appendChild(o):document.body.clientWidth>t&&!i.contains(o)&&i.appendChild(o)})},c=()=>{var e;(null===(e=window.$hsOverlayCollection)||void 0===e?void 0:e.length)&&window.$hsOverlayCollection.forEach(e=>{var t;const i=e.element;(null===(t=i.toggleMinifierButtons)||void 0===t?void 0:t.length)>0&&i.openedBreakpoint&&(document.body.clientWidth>=i.openedBreakpoint?document.body.classList.add("overlay-body-open"):document.body.classList.remove("overlay-body-open"))})};window.addEventListener("load",()=>{a.autoInit(),d(),c()}),window.addEventListener("resize",()=>{((e,t=150)=>{h&&clearTimeout(h),h=setTimeout(e,t)})(()=>{(()=>{if(!window.$hsOverlayCollection.length||!window.$hsOverlayCollection.find(e=>e.element.autoClose))return!1;window.$hsOverlayCollection.filter(e=>e.element.autoClose).forEach(e=>{const{autoCloseEqualityType:t,autoClose:i}=e.element;("less-than"===t?document.body.clientWidth<=i:document.body.clientWidth>=i)&&e.element.el.classList.contains("opened")?(e.element.el.classList.contains("minified")&&e.element.minify(!1),e.element.close(!0)):e.element.isLayoutAffect&&e.element.el.classList.contains("opened")&&document.body.classList.add("overlay-body-open")})})(),(()=>{if(!window.$hsOverlayCollection.length||!window.$hsOverlayCollection.find(e=>e.element.openedBreakpoint))return!1;window.$hsOverlayCollection.filter(e=>e.element.openedBreakpoint).forEach(e=>{const{openedBreakpoint:t}=e.element;document.body.clientWidth>=t?e.element.el.classList.contains("opened")||a.setOpened(t,e):e.element.el.classList.contains("opened")&&(e.element.el.classList.contains("minified")&&e.element.minify(!1),e.element.close(!0))})})()}),d(),(()=>{if(!window.$hsOverlayCollection.length||!window.$hsOverlayCollection.find(e=>e.element.el.classList.contains("opened")))return!1;window.$hsOverlayCollection.filter(e=>e.element.el.classList.contains("opened")).forEach(e=>{const t=parseInt(window.getComputedStyle(e.element.el).getPropertyValue("z-index")),i=document.querySelector(`#${e.element.el.id}-backdrop`);return!!i&&(t!==parseInt(window.getComputedStyle(i).getPropertyValue("z-index"))+1&&("style"in i&&(i.style.zIndex=""+(t-1)),void document.body.classList.add("overlay-body-open")))})})(),c()}),"undefined"!=typeof window&&(window.HSOverlay=a),t.default=a},192:function(e,t,i){ +/* + * HSFileUpload + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));"undefined"!=typeof Dropzone&&(Dropzone.autoDiscover=!1);class l extends o.default{constructor(e,t,i){var s;super(e,t,i),this.extensions={},this.el="string"==typeof e?document.querySelector(e):e;const n=this.el.getAttribute("data-file-upload"),o=n?JSON.parse(n):{};this.previewTemplate=(null===(s=this.el.querySelector("[data-file-upload-preview]"))||void 0===s?void 0:s.innerHTML)||'
\n
\n
\n \n \n \n
\n

\n .\n

\n

\n
\n
\n
\n \n
\n
\n
\n
\n
\n
\n \n 0%\n \n
\n
',this.extensions=_.merge({default:{icon:'',class:"shrink-0 size-5"},xls:{icon:'',class:"shrink-0 size-5"},doc:{icon:'',class:"shrink-0 size-5"},zip:{icon:'',class:"shrink-0 size-5"}},o.extensions),this.singleton=o.singleton,this.concatOptions=Object.assign(Object.assign({clickable:this.el.querySelector("[data-file-upload-trigger]"),previewsContainer:this.el.querySelector("[data-file-upload-previews]"),addRemoveLinks:!1,previewTemplate:this.previewTemplate,autoHideTrigger:!1},o),t),this.onReloadButtonClickListener=[],this.onTempFileInputChangeListener=[],this.init()}tempFileInputChange(e,t){var i;const s=null===(i=e.target.files)||void 0===i?void 0:i[0];if(s){const e=s;e.status=Dropzone.ADDED,e.accepted=!0,e.previewElement=t.previewElement,e.previewTemplate=t.previewTemplate,e.previewsContainer=t.previewsContainer,this.dropzone.removeFile(t),this.dropzone.addFile(e)}}reloadButtonClick(e,t){e.preventDefault(),e.stopPropagation();const i=document.createElement("input");i.type="file",this.onTempFileInputChangeListener.push({el:i,fn:e=>this.tempFileInputChange(e,t)}),i.click(),i.addEventListener("change",this.onTempFileInputChangeListener.find(e=>e.el===i).fn)}init(){this.createCollection(window.$hsFileUploadCollection,this),this.initDropzone()}initDropzone(){const e=this.el.querySelector("[data-file-upload-clear]"),t=Array.from(this.el.querySelectorAll("[data-file-upload-pseudo-trigger]"));this.dropzone=new Dropzone(this.el,this.concatOptions),this.dropzone.on("addedfile",e=>this.onAddFile(e)),this.dropzone.on("removedfile",()=>this.onRemoveFile()),this.dropzone.on("uploadprogress",(e,t)=>this.onUploadProgress(e,t)),this.dropzone.on("complete",e=>this.onComplete(e)),e&&(e.onclick=()=>{this.dropzone.files.length&&this.dropzone.removeAllFiles(!0)}),t.length&&t.forEach(e=>{e.onclick=()=>{var e,t;(null===(e=this.concatOptions)||void 0===e?void 0:e.clickable)&&(null===(t=this.concatOptions)||void 0===t?void 0:t.clickable).click()}})}destroy(){this.onTempFileInputChangeListener.forEach(e=>{e.el.removeEventListener("change",e.fn)}),this.onTempFileInputChangeListener=null,this.onReloadButtonClickListener.forEach(e=>{e.el.removeEventListener("click",e.fn)}),this.onReloadButtonClickListener=null,this.dropzone.destroy(),window.$hsFileUploadCollection=window.$hsFileUploadCollection.filter(({element:e})=>e.el!==this.el)}onAddFile(e){const{previewElement:t}=e,i=e.previewElement.querySelector("[data-file-upload-reload]");if(!t)return!1;this.singleton&&this.dropzone.files.length>1&&this.dropzone.removeFile(this.dropzone.files[0]),i&&(this.onReloadButtonClickListener.push({el:i,fn:t=>this.reloadButtonClick(t,e)}),i.addEventListener("click",this.onReloadButtonClickListener.find(e=>e.el===i).fn)),this.previewAccepted(e)}previewAccepted(e){const{previewElement:t}=e,i=this.splitFileName(e.name),s=t.querySelector("[data-file-upload-file-name]"),n=t.querySelector("[data-file-upload-file-ext]"),o=t.querySelector("[data-file-upload-file-size]"),l=t.querySelector("[data-file-upload-file-icon]"),r=this.el.querySelector("[data-file-upload-trigger]"),a=t.querySelector("[data-dz-thumbnail]"),h=t.querySelector("[data-file-upload-remove]");s&&(s.textContent=i.name),n&&(n.textContent=i.extension),o&&(o.textContent=this.formatFileSize(e.size)),a&&(e.type.includes("image/")?a.classList.remove("hidden"):this.setIcon(i.extension,l)),this.dropzone.files.length>0&&this.concatOptions.autoHideTrigger&&(r.style.display="none"),h&&(h.onclick=()=>this.dropzone.removeFile(e))}onRemoveFile(){const e=this.el.querySelector("[data-file-upload-trigger]");0===this.dropzone.files.length&&this.concatOptions.autoHideTrigger&&(e.style.display="")}onUploadProgress(e,t){const{previewElement:i}=e;if(!i)return!1;const s=i.querySelector("[data-file-upload-progress-bar]"),n=i.querySelector("[data-file-upload-progress-bar-pane]"),o=i.querySelector("[data-file-upload-progress-bar-value]"),l=Math.floor(t);s&&s.setAttribute("aria-valuenow",`${l}`),n&&(n.style.width=`${l}%`),o&&(o.innerText=`${l}`)}onComplete(e){const{previewElement:t}=e;if(!t)return!1;t.classList.add("complete")}setIcon(e,t){const i=this.createIcon(e);t.append(i)}createIcon(e){var t,i;const s=(null===(t=this.extensions[e])||void 0===t?void 0:t.icon)?(0,n.htmlToElement)(this.extensions[e].icon):(0,n.htmlToElement)(this.extensions.default.icon);return(0,n.classToClassList)((null===(i=this.extensions[e])||void 0===i?void 0:i.class)?this.extensions[e].class:this.extensions.default.class,s),s}formatFileSize(e){return e<1024?e.toFixed(2)+" B":e<1048576?(e/1024).toFixed(2)+" KB":e<1073741824?(e/1048576).toFixed(2)+" MB":e<1099511627776?(e/1073741824).toFixed(2)+" GB":(e/1099511627776).toFixed(2)+" TB"}splitFileName(e){let t=e.lastIndexOf(".");return-1==t?{name:e,extension:""}:{name:e.substring(0,t),extension:e.substring(t+1)}}static getInstance(e,t){const i=window.$hsFileUploadCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static autoInit(){window.$hsFileUploadCollection||(window.$hsFileUploadCollection=[]),window.$hsFileUploadCollection&&(window.$hsFileUploadCollection=window.$hsFileUploadCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-file-upload]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsFileUploadCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}}window.addEventListener("load",()=>{document.querySelectorAll("[data-file-upload]:not(.--prevent-on-load-init)").length&&("undefined"==typeof _&&console.error("HSFileUpload: Lodash is not available, please add it to the page."),"undefined"==typeof Dropzone&&console.error("HSFileUpload: Dropzone is not available, please add it to the page.")),"undefined"!=typeof _&&"undefined"!=typeof Dropzone&&l.autoInit()}),"undefined"!=typeof window&&(window.HSFileUpload=l),t.default=l},207:function(e,t,i){ +/* + * HSStrongPassword + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t){super(e,t),this.isOpened=!1,this.strength=0,this.passedRules=new Set;const i=e.getAttribute("data-strong-password"),s=i?JSON.parse(i):{},n=Object.assign(Object.assign({},s),t);this.target=(null==n?void 0:n.target)?"string"==typeof(null==n?void 0:n.target)?document.querySelector(n.target):n.target:null,this.hints=(null==n?void 0:n.hints)?"string"==typeof(null==n?void 0:n.hints)?document.querySelector(n.hints):n.hints:null,this.stripClasses=(null==n?void 0:n.stripClasses)||null,this.minLength=(null==n?void 0:n.minLength)||6,this.mode=(null==n?void 0:n.mode)||"default",this.popoverSpace=(null==n?void 0:n.popoverSpace)||10,this.checksExclude=(null==n?void 0:n.checksExclude)||[],this.availableChecks=["lowercase","uppercase","numbers","special-characters","min-length"].filter(e=>!this.checksExclude.includes(e)),this.specialCharactersSet=(null==n?void 0:n.specialCharactersSet)||"!\"#$%&'()*+,-./:;<=>?@[\\\\\\]^_`{|}~",this.target&&this.init()}targetInput(e){this.setStrength(e.target.value)}targetFocus(){this.isOpened=!0,this.hints.classList.remove("hidden"),this.hints.classList.add("block"),this.recalculateDirection()}targetBlur(){this.isOpened=!1,this.hints.classList.remove("block","bottom-full","top-full"),this.hints.classList.add("hidden"),this.hints.style.marginTop="",this.hints.style.marginBottom=""}targetInputSecond(){this.setWeaknessText()}targetInputThird(){this.setRulesText()}init(){this.createCollection(window.$hsStrongPasswordCollection,this),this.availableChecks.length&&this.build()}build(){this.buildStrips(),this.hints&&this.buildHints(),this.setStrength(this.target.value),this.onTargetInputListener=e=>this.targetInput(e),this.target.addEventListener("input",this.onTargetInputListener)}buildStrips(){if(this.el.innerHTML="",this.stripClasses)for(let e=0;e");(0,n.classToClassList)(this.stripClasses,e),this.el.append(e)}}buildHints(){this.weakness=this.hints.querySelector("[data-pw-strength-hint]")||null,this.rules=Array.from(this.hints.querySelectorAll("[data-pw-strength-rule]"))||null,this.rules.forEach(e=>{var t;const i=e.getAttribute("data-pw-strength-rule");(null===(t=this.checksExclude)||void 0===t?void 0:t.includes(i))&&e.remove()}),this.weakness&&this.buildWeakness(),this.rules&&this.buildRules(),"popover"===this.mode&&(this.onTargetFocusListener=()=>this.targetFocus(),this.onTargetBlurListener=()=>this.targetBlur(),this.target.addEventListener("focus",this.onTargetFocusListener),this.target.addEventListener("blur",this.onTargetBlurListener))}buildWeakness(){this.checkStrength(this.target.value),this.setWeaknessText(),this.onTargetInputSecondListener=()=>setTimeout(()=>this.targetInputSecond()),this.target.addEventListener("input",this.onTargetInputSecondListener)}buildRules(){this.setRulesText(),this.onTargetInputThirdListener=()=>setTimeout(()=>this.targetInputThird()),this.target.addEventListener("input",this.onTargetInputThirdListener)}setWeaknessText(){const e=this.weakness.getAttribute("data-pw-strength-hint"),t=JSON.parse(e);this.weakness.textContent=t[this.strength]}setRulesText(){this.rules.forEach(e=>{const t=e.getAttribute("data-pw-strength-rule");this.checkIfPassed(e,this.passedRules.has(t))})}togglePopover(){const e=this.el.querySelector(".popover");e&&e.classList.toggle("show")}checkStrength(e){const t=new Set,i={lowercase:/[a-z]+/,uppercase:/[A-Z]+/,numbers:/[0-9]+/,"special-characters":new RegExp(`[${this.specialCharactersSet}]`)};let s=0;return this.availableChecks.includes("lowercase")&&e.match(i.lowercase)&&(s+=1,t.add("lowercase")),this.availableChecks.includes("uppercase")&&e.match(i.uppercase)&&(s+=1,t.add("uppercase")),this.availableChecks.includes("numbers")&&e.match(i.numbers)&&(s+=1,t.add("numbers")),this.availableChecks.includes("special-characters")&&e.match(i["special-characters"])&&(s+=1,t.add("special-characters")),this.availableChecks.includes("min-length")&&e.length>=this.minLength&&(s+=1,t.add("min-length")),e.length||(s=0),s===this.availableChecks.length?this.el.classList.add("accepted"):this.el.classList.remove("accepted"),this.strength=s,this.passedRules=t,{strength:this.strength,rules:this.passedRules}}checkIfPassed(e,t=!1){const i=e.querySelector("[data-check]"),s=e.querySelector("[data-uncheck]");t?(e.classList.add("active"),i.classList.remove("hidden"),s.classList.add("hidden")):(e.classList.remove("active"),i.classList.add("hidden"),s.classList.remove("hidden"))}setStrength(e){const{strength:t,rules:i}=this.checkStrength(e),s={strength:t,rules:i};this.hideStrips(t),this.fireEvent("change",s),(0,n.dispatch)("change.strongPassword",this.el,s)}hideStrips(e){Array.from(this.el.children).forEach((t,i)=>{ie.el!==this.el)}static getInstance(e,t){const i=window.$hsStrongPasswordCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static autoInit(){window.$hsStrongPasswordCollection||(window.$hsStrongPasswordCollection=[]),window.$hsStrongPasswordCollection&&(window.$hsStrongPasswordCollection=window.$hsStrongPasswordCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-strong-password]:not(.--prevent-on-load-init)").forEach(e=>{if(!window.$hsStrongPasswordCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})){const t=e.getAttribute("data-strong-password"),i=t?JSON.parse(t):{};new l(e,i)}})}}window.addEventListener("load",()=>{l.autoInit()}),document.addEventListener("scroll",()=>{if(!window.$hsStrongPasswordCollection)return!1;const e=window.$hsStrongPasswordCollection.find(e=>e.element.isOpened);e&&e.element.recalculateDirection()}),"undefined"!=typeof window&&(window.HSStrongPassword=l),t.default=l},213:function(e,t,i){ +/* + * HSStepper + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t){super(e,t);const i=e.getAttribute("data-stepper"),s=i?JSON.parse(i):{},n=Object.assign(Object.assign({},s),t);this.currentIndex=(null==n?void 0:n.currentIndex)||1,this.mode=(null==n?void 0:n.mode)||"linear",this.isCompleted=void 0!==(null==n?void 0:n.isCompleted)&&(null==n?void 0:n.isCompleted),this.totalSteps=1,this.navItems=[],this.contentItems=[],this.onNavItemClickListener=[],this.init()}navItemClick(e){this.handleNavItemClick(e)}backClick(){if(this.handleBackButtonClick(),"linear"===this.mode){const e=this.navItems.find(({index:e})=>e===this.currentIndex),t=this.contentItems.find(({index:e})=>e===this.currentIndex);if(!e||!t)return;e.isCompleted&&(e.isCompleted=!1,e.isSkip=!1,e.el.classList.remove("is-valid","skipped")),t.isCompleted&&(t.isCompleted=!1,t.isSkip=!1,t.el.classList.remove("is-valid","skipped")),"linear"===this.mode&&this.currentIndex!==this.totalSteps&&(this.nextBtn&&(this.nextBtn.style.display=""),this.completeStepBtn&&(this.completeStepBtn.style.display="")),this.showSkipButton(),this.showFinishButton(),this.showCompleteStepButton()}}nextClick(){var e;if(this.fireEvent("beforeNext",this.currentIndex),(0,n.dispatch)("beforeNext.stepper",this.el,this.currentIndex),null===(e=this.getNavItem(this.currentIndex))||void 0===e?void 0:e.isProcessed)return this.disableAll(),!1;this.goToNext()}skipClick(){this.handleSkipButtonClick(),"linear"===this.mode&&this.currentIndex===this.totalSteps&&(this.nextBtn&&(this.nextBtn.style.display="none"),this.completeStepBtn&&(this.completeStepBtn.style.display="none"),this.finishBtn&&(this.finishBtn.style.display=""))}completeStepBtnClick(){this.handleCompleteStepButtonClick()}finishBtnClick(){var e;if(this.fireEvent("beforeFinish",this.currentIndex),(0,n.dispatch)("beforeFinish.stepper",this.el,this.currentIndex),null===(e=this.getNavItem(this.currentIndex))||void 0===e?void 0:e.isProcessed)return this.disableAll(),!1;this.handleFinishButtonClick()}resetBtnClick(){this.handleResetButtonClick()}init(){this.createCollection(window.$hsStepperCollection,this),this.buildNav(),this.buildContent(),this.buildButtons(),this.setTotalSteps()}getUncompletedSteps(e=!1){return this.navItems.filter(({isCompleted:t,isSkip:i})=>e?!t||i:!t&&!i)}setTotalSteps(){this.navItems.forEach(e=>{const{index:t}=e;t>this.totalSteps&&(this.totalSteps=t)})}buildNav(){this.el.querySelectorAll("[data-stepper-nav-item]").forEach(e=>this.addNavItem(e)),this.navItems.forEach(e=>this.buildNavItem(e))}buildNavItem(e){const{index:t,isDisabled:i,el:s}=e;t===this.currentIndex&&this.setCurrentNavItem(),("linear"!==this.mode||i)&&(this.onNavItemClickListener.push({el:s,fn:()=>this.navItemClick(e)}),s.addEventListener("click",this.onNavItemClickListener.find(e=>e.el===s).fn))}addNavItem(e){const{index:t,isFinal:i=!1,isCompleted:s=!1,isSkip:n=!1,isOptional:o=!1,isDisabled:l=!1,isProcessed:r=!1,isInvalid:a=!1}=JSON.parse(e.getAttribute("data-stepper-nav-item"));s&&e.classList.add("is-valid"),n&&e.classList.add("skipped"),l&&("BUTTON"!==e.tagName&&"INPUT"!==e.tagName||e.setAttribute("disabled","disabled"),e.classList.add("disabled")),a&&e.classList.add("is-invalid"),this.navItems.push({index:t,isFinal:i,isCompleted:s,isSkip:n,isOptional:o,isDisabled:l,isProcessed:r,isInvalid:a,el:e})}setCurrentNavItem(){this.navItems.forEach(e=>{const{index:t,el:i}=e;t===this.currentIndex?this.setCurrentNavItemActions(i):this.unsetCurrentNavItemActions(i)})}setCurrentNavItemActions(e){e.classList.add("active"),this.fireEvent("active",this.currentIndex),(0,n.dispatch)("active.stepper",this.el,this.currentIndex)}getNavItem(e=this.currentIndex){return this.navItems.find(({index:t})=>t===e)}setProcessedNavItemActions(e){e.isProcessed=!0,e.el.classList.add("processed")}setErrorNavItemActions(e){e.isInvalid=!0,e.el.classList.add("is-invalid")}unsetCurrentNavItemActions(e){e.classList.remove("active")}handleNavItemClick(e){const{index:t}=e;this.currentIndex=t,this.setCurrentNavItem(),this.setCurrentContentItem(),this.checkForTheFirstStep()}buildContent(){this.el.querySelectorAll("[data-stepper-content-item]").forEach(e=>this.addContentItem(e)),this.navItems.forEach(e=>this.buildContentItem(e))}buildContentItem(e){const{index:t}=e;t===this.currentIndex&&this.setCurrentContentItem()}addContentItem(e){const{index:t,isFinal:i=!1,isCompleted:s=!1,isSkip:n=!1}=JSON.parse(e.getAttribute("data-stepper-content-item"));s&&e.classList.add("is-valid"),n&&e.classList.add("skipped"),this.contentItems.push({index:t,isFinal:i,isCompleted:s,isSkip:n,el:e})}setCurrentContentItem(){if(this.isCompleted){const e=this.contentItems.find(({isFinal:e})=>e),t=this.contentItems.filter(({isFinal:e})=>!e);return e.el.style.display="",t.forEach(({el:e})=>e.style.display="none"),!1}this.contentItems.forEach(e=>{const{index:t,el:i}=e;t===this.currentIndex?this.setCurrentContentItemActions(i):this.unsetCurrentContentItemActions(i)})}hideAllContentItems(){this.contentItems.forEach(({el:e})=>e.style.display="none")}setCurrentContentItemActions(e){e.style.display=""}unsetCurrentContentItemActions(e){e.style.display="none"}disableAll(){const e=this.getNavItem(this.currentIndex);e.isInvalid=!1,e.isCompleted=!1,e.isDisabled=!1,e.el.classList.remove("is-invalid","is-valid"),this.disableButtons()}disableNavItemActions(e){e.isDisabled=!0,e.el.classList.add("disabled")}enableNavItemActions(e){e.isDisabled=!1,e.el.classList.remove("disabled")}buildButtons(){this.backBtn=this.el.querySelector("[data-stepper-back-btn]"),this.nextBtn=this.el.querySelector("[data-stepper-next-btn]"),this.skipBtn=this.el.querySelector("[data-stepper-skip-btn]"),this.completeStepBtn=this.el.querySelector("[data-stepper-complete-step-btn]"),this.finishBtn=this.el.querySelector("[data-stepper-finish-btn]"),this.resetBtn=this.el.querySelector("[data-stepper-reset-btn]"),this.buildBackButton(),this.buildNextButton(),this.buildSkipButton(),this.buildCompleteStepButton(),this.buildFinishButton(),this.buildResetButton()}buildBackButton(){this.backBtn&&(this.checkForTheFirstStep(),this.onBackClickListener=()=>this.backClick(),this.backBtn.addEventListener("click",this.onBackClickListener))}handleBackButtonClick(){1!==this.currentIndex&&("linear"===this.mode&&this.removeOptionalClasses(),this.currentIndex--,"linear"===this.mode&&this.removeOptionalClasses(),this.setCurrentNavItem(),this.setCurrentContentItem(),this.checkForTheFirstStep(),this.completeStepBtn&&this.changeTextAndDisableCompleteButtonIfStepCompleted(),this.fireEvent("back",this.currentIndex),(0,n.dispatch)("back.stepper",this.el,this.currentIndex))}checkForTheFirstStep(){1===this.currentIndex?this.setToDisabled(this.backBtn):this.setToNonDisabled(this.backBtn)}setToDisabled(e){"BUTTON"!==e.tagName&&"INPUT"!==e.tagName||e.setAttribute("disabled","disabled"),e.classList.add("disabled")}setToNonDisabled(e){"BUTTON"!==e.tagName&&"INPUT"!==e.tagName||e.removeAttribute("disabled"),e.classList.remove("disabled")}buildNextButton(){this.nextBtn&&(this.onNextClickListener=()=>this.nextClick(),this.nextBtn.addEventListener("click",this.onNextClickListener))}unsetProcessedNavItemActions(e){e.isProcessed=!1,e.el.classList.remove("processed")}handleNextButtonClick(e=!0){if(e)this.currentIndex===this.totalSteps?this.currentIndex=1:this.currentIndex++;else{const e=this.getUncompletedSteps();if(1===e.length){const{index:t}=e[0];this.currentIndex=t}else{if(this.currentIndex===this.totalSteps)return;this.currentIndex++}}"linear"===this.mode&&this.removeOptionalClasses(),this.setCurrentNavItem(),this.setCurrentContentItem(),this.checkForTheFirstStep(),this.completeStepBtn&&this.changeTextAndDisableCompleteButtonIfStepCompleted(),this.showSkipButton(),this.showFinishButton(),this.showCompleteStepButton(),this.fireEvent("next",this.currentIndex),(0,n.dispatch)("next.stepper",this.el,this.currentIndex)}removeOptionalClasses(){const e=this.navItems.find(({index:e})=>e===this.currentIndex),t=this.contentItems.find(({index:e})=>e===this.currentIndex);e.isSkip=!1,e.isInvalid=!1,e.isDisabled=!1,t.isSkip=!1,e.el.classList.remove("skipped","is-valid","is-invalid"),t.el.classList.remove("skipped","is-valid","is-invalid")}buildSkipButton(){this.skipBtn&&(this.showSkipButton(),this.onSkipClickListener=()=>this.skipClick(),this.skipBtn.addEventListener("click",this.onSkipClickListener))}setSkipItem(e){const t=this.navItems.find(({index:t})=>t===(e||this.currentIndex)),i=this.contentItems.find(({index:t})=>t===(e||this.currentIndex));t&&i&&(this.setSkipItemActions(t),this.setSkipItemActions(i))}setSkipItemActions(e){e.isSkip=!0,e.el.classList.add("skipped")}showSkipButton(){if(!this.skipBtn)return;const{isOptional:e}=this.navItems.find(({index:e})=>e===this.currentIndex);this.skipBtn.style.display=e?"":"none"}handleSkipButtonClick(){this.setSkipItem(),this.handleNextButtonClick(),this.fireEvent("skip",this.currentIndex),(0,n.dispatch)("skip.stepper",this.el,this.currentIndex)}buildCompleteStepButton(){this.completeStepBtn&&(this.completeStepBtnDefaultText=this.completeStepBtn.innerText,this.onCompleteStepBtnClickListener=()=>this.completeStepBtnClick(),this.completeStepBtn.addEventListener("click",this.onCompleteStepBtnClickListener))}changeTextAndDisableCompleteButtonIfStepCompleted(){const e=this.navItems.find(({index:e})=>e===this.currentIndex),{completedText:t}=JSON.parse(this.completeStepBtn.getAttribute("data-stepper-complete-step-btn"));e&&(e.isCompleted?(this.completeStepBtn.innerText=t||this.completeStepBtnDefaultText,this.completeStepBtn.setAttribute("disabled","disabled"),this.completeStepBtn.classList.add("disabled")):(this.completeStepBtn.innerText=this.completeStepBtnDefaultText,this.completeStepBtn.removeAttribute("disabled"),this.completeStepBtn.classList.remove("disabled")))}setCompleteItem(e){const t=this.navItems.find(({index:t})=>t===(e||this.currentIndex)),i=this.contentItems.find(({index:t})=>t===(e||this.currentIndex));t&&i&&(this.setCompleteItemActions(t),this.setCompleteItemActions(i))}setCompleteItemActions(e){e.isCompleted=!0,e.el.classList.add("is-valid")}showCompleteStepButton(){if(!this.completeStepBtn)return;1===this.getUncompletedSteps().length?this.completeStepBtn.style.display="none":this.completeStepBtn.style.display=""}handleCompleteStepButtonClick(){this.setCompleteItem(),this.fireEvent("complete",this.currentIndex),(0,n.dispatch)("complete.stepper",this.el,this.currentIndex),this.handleNextButtonClick(!1),this.showFinishButton(),this.showCompleteStepButton(),this.checkForTheFirstStep(),this.completeStepBtn&&this.changeTextAndDisableCompleteButtonIfStepCompleted(),this.showSkipButton()}buildFinishButton(){this.finishBtn&&(this.isCompleted&&this.setCompleted(),this.onFinishBtnClickListener=()=>this.finishBtnClick(),this.finishBtn.addEventListener("click",this.onFinishBtnClickListener))}setCompleted(){this.el.classList.add("completed")}unsetCompleted(){this.el.classList.remove("completed")}showFinishButton(){if(!this.finishBtn)return;1===this.getUncompletedSteps().length?this.finishBtn.style.display="":this.finishBtn.style.display="none"}handleFinishButtonClick(){const e=this.getUncompletedSteps(),t=this.getUncompletedSteps(!0),{el:i}=this.contentItems.find(({isFinal:e})=>e);e.length&&e.forEach(({index:e})=>this.setCompleteItem(e)),this.currentIndex=this.totalSteps,this.setCurrentNavItem(),this.hideAllContentItems();const s=this.navItems.find(({index:e})=>e===this.currentIndex);(s?s.el:null).classList.remove("active"),i.style.display="block",this.backBtn&&(this.backBtn.style.display="none"),this.nextBtn&&(this.nextBtn.style.display="none"),this.skipBtn&&(this.skipBtn.style.display="none"),this.completeStepBtn&&(this.completeStepBtn.style.display="none"),this.finishBtn&&(this.finishBtn.style.display="none"),this.resetBtn&&(this.resetBtn.style.display=""),t.length<=1&&(this.isCompleted=!0,this.setCompleted()),this.fireEvent("finish",this.currentIndex),(0,n.dispatch)("finish.stepper",this.el,this.currentIndex)}buildResetButton(){this.resetBtn&&(this.onResetBtnClickListener=()=>this.resetBtnClick(),this.resetBtn.addEventListener("click",this.onResetBtnClickListener))}handleResetButtonClick(){this.backBtn&&(this.backBtn.style.display=""),this.nextBtn&&(this.nextBtn.style.display=""),this.completeStepBtn&&(this.completeStepBtn.style.display="",this.completeStepBtn.innerText=this.completeStepBtnDefaultText,this.completeStepBtn.removeAttribute("disabled"),this.completeStepBtn.classList.remove("disabled")),this.resetBtn&&(this.resetBtn.style.display="none"),this.navItems.forEach(e=>{const{el:t}=e;e.isSkip=!1,e.isCompleted=!1,this.unsetCurrentNavItemActions(t),t.classList.remove("is-valid","skipped")}),this.contentItems.forEach(e=>{const{el:t}=e;e.isSkip=!1,e.isCompleted=!1,this.unsetCurrentContentItemActions(t),t.classList.remove("is-valid","skipped")}),this.currentIndex=1,this.unsetCompleted(),this.isCompleted=!1,this.showSkipButton(),this.setCurrentNavItem(),this.setCurrentContentItem(),this.showFinishButton(),this.showCompleteStepButton(),this.checkForTheFirstStep(),this.fireEvent("reset",this.currentIndex),(0,n.dispatch)("reset.stepper",this.el,this.currentIndex)}setProcessedNavItem(e){const t=this.getNavItem(e);t&&this.setProcessedNavItemActions(t)}unsetProcessedNavItem(e){const t=this.getNavItem(e);t&&this.unsetProcessedNavItemActions(t)}goToNext(){"linear"===this.mode&&this.setCompleteItem(),this.handleNextButtonClick("linear"!==this.mode),"linear"===this.mode&&this.currentIndex===this.totalSteps&&(this.nextBtn&&(this.nextBtn.style.display="none"),this.completeStepBtn&&(this.completeStepBtn.style.display="none"))}goToFinish(){this.handleFinishButtonClick()}disableButtons(){this.backBtn&&this.setToDisabled(this.backBtn),this.nextBtn&&this.setToDisabled(this.nextBtn)}enableButtons(){this.backBtn&&this.setToNonDisabled(this.backBtn),this.nextBtn&&this.setToNonDisabled(this.nextBtn)}setErrorNavItem(e){const t=this.getNavItem(e);t&&this.setErrorNavItemActions(t)}destroy(){this.el.classList.remove("completed"),this.el.querySelectorAll("[data-stepper-nav-item]").forEach(e=>{e.classList.remove("active","is-valid","skipped","disabled","is-invalid"),"BUTTON"!==e.tagName&&"INPUT"!==e.tagName||e.removeAttribute("disabled")}),this.el.querySelectorAll("[data-stepper-content-item]").forEach(e=>{e.classList.remove("is-valid","skipped")}),this.backBtn&&this.backBtn.classList.remove("disabled"),this.nextBtn&&this.nextBtn.classList.remove("disabled"),this.completeStepBtn&&this.completeStepBtn.classList.remove("disabled"),this.backBtn&&(this.backBtn.style.display=""),this.nextBtn&&(this.nextBtn.style.display=""),this.skipBtn&&(this.skipBtn.style.display=""),this.finishBtn&&(this.finishBtn.style.display="none"),this.resetBtn&&(this.resetBtn.style.display="none"),this.onNavItemClickListener.length&&this.onNavItemClickListener.forEach(({el:e,fn:t})=>{e.removeEventListener("click",t)}),this.backBtn&&this.backBtn.removeEventListener("click",this.onBackClickListener),this.nextBtn&&this.nextBtn.removeEventListener("click",this.onNextClickListener),this.skipBtn&&this.skipBtn.removeEventListener("click",this.onSkipClickListener),this.completeStepBtn&&this.completeStepBtn.removeEventListener("click",this.onCompleteStepBtnClickListener),this.finishBtn&&this.finishBtn.removeEventListener("click",this.onFinishBtnClickListener),this.resetBtn&&this.resetBtn.removeEventListener("click",this.onResetBtnClickListener),window.$hsStepperCollection=window.$hsStepperCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsStepperCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsStepperCollection||(window.$hsStepperCollection=[]),window.$hsStepperCollection&&(window.$hsStepperCollection=window.$hsStepperCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-stepper]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsStepperCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}}window.addEventListener("load",()=>{l.autoInit()}),"undefined"!=typeof window&&(window.HSStepper=l),t.default=l},234:function(e,t,i){ +/* + * HSTogglePassword + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t){super(e,t);const i=e.getAttribute("data-toggle-password"),s=i?JSON.parse(i):{},o=Object.assign(Object.assign({},s),t),l=[];if((null==o?void 0:o.target)&&"string"==typeof(null==o?void 0:o.target)){(null==o?void 0:o.target.split(",")).forEach(e=>{l.push(document.querySelector(e))})}else(null==o?void 0:o.target)&&"object"==typeof(null==o?void 0:o.target)?o.target.forEach(e=>l.push(document.querySelector(e))):o.target.forEach(e=>l.push(e));this.target=l,this.isShown=!!this.el.hasAttribute("type")&&this.el.checked,this.eventType=(0,n.isFormElement)(this.el)?"change":"click",this.isMultiple=this.target.length>1&&!!this.el.closest("[data-toggle-password-group]"),this.target&&this.init()}elementAction(){this.isShown?this.hide():this.show(),this.fireEvent("toggle",this.target),(0,n.dispatch)("toggle.toggle-select",this.el,this.target)}init(){this.createCollection(window.$hsTogglePasswordCollection,this),this.isShown?this.show():this.hide(),this.onElementActionListener=()=>this.elementAction(),this.el.addEventListener(this.eventType,this.onElementActionListener)}getMultipleToggles(){const e=this.el.closest("[data-toggle-password-group]").querySelectorAll("[data-toggle-password]"),t=[];return e.forEach(e=>{t.push(l.getInstance(e))}),t}show(){if(this.isMultiple){this.getMultipleToggles().forEach(e=>!!e&&(e.isShown=!0)),this.el.closest("[data-toggle-password-group]").classList.add("active")}else this.isShown=!0,this.el.classList.add("active");this.target.forEach(e=>{e.type="text"})}hide(){if(this.isMultiple){this.getMultipleToggles().forEach(e=>!!e&&(e.isShown=!1)),this.el.closest("[data-toggle-password-group]").classList.remove("active")}else this.isShown=!1,this.el.classList.remove("active");this.target.forEach(e=>{e.type="password"})}destroy(){this.isMultiple?this.el.closest("[data-toggle-password-group]").classList.remove("active"):this.el.classList.remove("active"),this.target.forEach(e=>{e.type="password"}),this.el.removeEventListener(this.eventType,this.onElementActionListener),this.isShown=!1,window.$hsTogglePasswordCollection=window.$hsTogglePasswordCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsTogglePasswordCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsTogglePasswordCollection||(window.$hsTogglePasswordCollection=[]),window.$hsTogglePasswordCollection&&(window.$hsTogglePasswordCollection=window.$hsTogglePasswordCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-toggle-password]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsTogglePasswordCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}}window.addEventListener("load",()=>{l.autoInit()}),"undefined"!=typeof window&&(window.HSTogglePassword=l),t.default=l},287:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0});t.default=class{constructor(e,t,i){this.el=e,this.options=t,this.events=i,this.el=e,this.options=t,this.events={}}createCollection(e,t){var i;e.push({id:(null===(i=null==t?void 0:t.el)||void 0===i?void 0:i.id)||e.length+1,element:t})}fireEvent(e,t=null){if(this.events.hasOwnProperty(e))return this.events[e](t)}on(e,t){this.events[e]=t}}},294:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0});t.default=class{constructor(){this.components=[],this.currentlyOpenedComponents=[],this.activeComponent=null,this.allowedKeybindings=new Set(["Escape","Enter"," ","Space","ArrowDown","ArrowUp","ArrowLeft","ArrowRight","Tab","Home","End"]),this.initGlobalListeners()}initGlobalListeners(){document.addEventListener("keydown",e=>this.handleGlobalKeydown(e)),document.addEventListener("focusin",e=>this.handleGlobalFocusin(e))}isAllowedKeybinding(e){return!!this.allowedKeybindings.has(e.key)||!(1!==e.key.length||!/^[a-zA-Z]$/.test(e.key)||e.metaKey||e.ctrlKey||e.altKey||e.shiftKey)}getActiveComponent(e){if(!e)return null;const t=this.components.filter(t=>t.wrapper.contains(e)||t.context&&t.context.contains(e));if(0===t.length)return null;if(1===t.length)return t[0];let i=null,s=Number.MAX_SAFE_INTEGER;for(const n of t){let t=0,o=e;for(;o&&o!==n.wrapper&&o!==n.context;)t++,o=o.parentElement;te.wrapper===t&&e!==this.activeComponent);if(e)return e;t=t.parentElement}return null}registerComponent(e,t,i=!0,s="",n="",o){const l={wrapper:e,handlers:t,isOpened:i,name:s,selector:n,context:o,isRegistered:!0};return this.components.push(l),l}updateComponentState(e,t){e.isOpened=t,t?this.currentlyOpenedComponents.includes(e)||this.currentlyOpenedComponents.push(e):this.currentlyOpenedComponents=this.currentlyOpenedComponents.filter(t=>t!==e)}unregisterComponent(e){this.components=this.components.filter(t=>t!==e),this.currentlyOpenedComponents=this.currentlyOpenedComponents.filter(t=>t!==e)}addAllowedKeybinding(e){this.allowedKeybindings.add(e)}removeAllowedKeybinding(e){this.allowedKeybindings.delete(e)}getAllowedKeybindings(){return Array.from(this.allowedKeybindings)}}},302:function(e,t,i){ +/* + * HSCarousel + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287)),l=i(917);class r extends o.default{constructor(e,t){var i,s,n,o,l;super(e,t);const r=e.getAttribute("data-carousel"),a=r?JSON.parse(r):{},h=Object.assign(Object.assign({},a),t);this.currentIndex=h.currentIndex||0,this.loadingClasses=h.loadingClasses?`${h.loadingClasses}`.split(","):null,this.dotsItemClasses=h.dotsItemClasses?h.dotsItemClasses:null,this.isAutoHeight=void 0!==h.isAutoHeight&&h.isAutoHeight,this.isAutoPlay=void 0!==h.isAutoPlay&&h.isAutoPlay,this.isCentered=void 0!==h.isCentered&&h.isCentered,this.isDraggable=void 0!==h.isDraggable&&h.isDraggable,this.isInfiniteLoop=void 0!==h.isInfiniteLoop&&h.isInfiniteLoop,this.isRTL=void 0!==h.isRTL&&h.isRTL,this.isSnap=void 0!==h.isSnap&&h.isSnap,this.hasSnapSpacers=void 0===h.hasSnapSpacers||h.hasSnapSpacers,this.speed=h.speed||4e3,this.updateDelay=h.updateDelay||0,this.slidesQty=h.slidesQty||1,this.loadingClassesRemove=(null===(i=this.loadingClasses)||void 0===i?void 0:i[0])?this.loadingClasses[0].split(" "):"opacity-0",this.loadingClassesAdd=(null===(s=this.loadingClasses)||void 0===s?void 0:s[1])?this.loadingClasses[1].split(" "):"",this.afterLoadingClassesAdd=(null===(n=this.loadingClasses)||void 0===n?void 0:n[2])?this.loadingClasses[2].split(" "):"",this.container=this.el.querySelector(".carousel")||null,this.inner=this.el.querySelector(".carousel-body")||null,this.slides=this.el.querySelectorAll(".carousel-slide")||[],this.prev=this.el.querySelector(".carousel-prev")||null,this.next=this.el.querySelector(".carousel-next")||null,this.dots=this.el.querySelector(".carousel-pagination")||null,this.info=this.el.querySelector(".carousel-info")||null,this.infoTotal=(null===(o=null==this?void 0:this.info)||void 0===o?void 0:o.querySelector(".carousel-info-total"))||null,this.infoCurrent=(null===(l=null==this?void 0:this.info)||void 0===l?void 0:l.querySelector(".carousel-info-current"))||null,this.sliderWidth=this.el.getBoundingClientRect().width,this.isDragging=!1,this.dragStartX=null,this.initialTranslateX=null,this.touchX={start:0,end:0},this.touchY={start:0,end:0},this.resizeContainer=document.querySelector("body"),this.resizeContainerWidth=0,this.init()}setIsSnap(){const e=this.container.getBoundingClientRect(),t=e.left+e.width/2;let i=null,s=null,n=1/0;Array.from(this.inner.children).forEach(e=>{const s=e.getBoundingClientRect(),o=this.inner.getBoundingClientRect(),l=s.left+s.width/2-o.left,r=Math.abs(t-(o.left+l));re===i)),this.setIndex(s),this.dots&&this.setCurrentDot()}prevClick(){this.goToPrev(),this.isAutoPlay&&(this.resetTimer(),this.setTimer())}nextClick(){this.goToNext(),this.isAutoPlay&&(this.resetTimer(),this.setTimer())}containerScroll(){clearTimeout(this.isScrolling),this.isScrolling=setTimeout(()=>{this.setIsSnap()},100)}elementTouchStart(e){this.touchX.start=e.changedTouches[0].screenX,this.touchY.start=e.changedTouches[0].screenY}elementTouchEnd(e){this.touchX.end=e.changedTouches[0].screenX,this.touchY.end=e.changedTouches[0].screenY,this.detectDirection()}innerMouseDown(e){this.handleDragStart(e)}innerTouchStart(e){this.handleDragStart(e)}documentMouseMove(e){this.handleDragMove(e)}documentTouchMove(e){this.handleDragMove(e)}documentMouseUp(){this.handleDragEnd()}documentTouchEnd(){this.handleDragEnd()}dotClick(e){this.goTo(e),this.isAutoPlay&&(this.resetTimer(),this.setTimer())}init(){this.createCollection(window.$hsCarouselCollection,this),this.inner&&(this.calculateWidth(),this.isDraggable&&!this.isSnap&&this.initDragHandling()),this.prev&&(this.onPrevClickListener=()=>this.prevClick(),this.prev.addEventListener("click",this.onPrevClickListener)),this.next&&(this.onNextClickListener=()=>this.nextClick(),this.next.addEventListener("click",this.onNextClickListener)),this.dots&&this.initDots(),this.info&&this.buildInfo(),this.slides.length&&(this.addCurrentClass(),this.isInfiniteLoop||this.addDisabledClass(),this.isAutoPlay&&this.autoPlay()),setTimeout(()=>{this.isSnap&&this.setIsSnap(),this.loadingClassesRemove&&("string"==typeof this.loadingClassesRemove?this.inner.classList.remove(this.loadingClassesRemove):this.inner.classList.remove(...this.loadingClassesRemove)),this.loadingClassesAdd&&("string"==typeof this.loadingClassesAdd?this.inner.classList.add(this.loadingClassesAdd):this.inner.classList.add(...this.loadingClassesAdd)),this.inner&&this.afterLoadingClassesAdd&&setTimeout(()=>{"string"==typeof this.afterLoadingClassesAdd?this.inner.classList.add(this.afterLoadingClassesAdd):this.inner.classList.add(...this.afterLoadingClassesAdd)})},400),this.isSnap&&(this.onContainerScrollListener=()=>this.containerScroll(),this.container.addEventListener("scroll",this.onContainerScrollListener)),this.el.classList.add("init"),this.isSnap||(this.onElementTouchStartListener=e=>this.elementTouchStart(e),this.onElementTouchEndListener=e=>this.elementTouchEnd(e),this.el.addEventListener("touchstart",this.onElementTouchStartListener),this.el.addEventListener("touchend",this.onElementTouchEndListener)),this.observeResize()}initDragHandling(){const e=this.inner;this.onInnerMouseDownListener=e=>this.innerMouseDown(e),this.onInnerTouchStartListener=e=>this.innerTouchStart(e),this.onDocumentMouseMoveListener=e=>this.documentMouseMove(e),this.onDocumentTouchMoveListener=e=>this.documentTouchMove(e),this.onDocumentMouseUpListener=()=>this.documentMouseUp(),this.onDocumentTouchEndListener=()=>this.documentTouchEnd(),e&&(e.addEventListener("mousedown",this.onInnerMouseDownListener),e.addEventListener("touchstart",this.onInnerTouchStartListener,{passive:!0}),document.addEventListener("mousemove",this.onDocumentMouseMoveListener),document.addEventListener("touchmove",this.onDocumentTouchMoveListener,{passive:!1}),document.addEventListener("mouseup",this.onDocumentMouseUpListener),document.addEventListener("touchend",this.onDocumentTouchEndListener))}getTranslateXValue(){var e;const t=window.getComputedStyle(this.inner).transform;if("none"!==t){const i=null===(e=t.match(/matrix.*\((.+)\)/))||void 0===e?void 0:e[1].split(", ");if(i){let e=parseFloat(6===i.length?i[4]:i[12]);return this.isRTL&&(e=-e),isNaN(e)||0===e?0:-e}}return 0}removeClickEventWhileDragging(e){e.preventDefault()}handleDragStart(e){e.preventDefault(),this.isDragging=!0,this.dragStartX=this.getEventX(e),this.initialTranslateX=this.isRTL?this.getTranslateXValue():-this.getTranslateXValue(),this.inner.classList.add("dragging")}handleDragMove(e){if(!this.isDragging)return;this.inner.querySelectorAll("a:not(.prevented-click)").forEach(e=>{e.classList.add("prevented-click"),e.addEventListener("click",this.removeClickEventWhileDragging)});let t=this.getEventX(e)-this.dragStartX;this.isRTL&&(t=-t);const i=this.initialTranslateX+t;this.setTranslate((()=>{let e=this.sliderWidth*this.slides.length/this.getCurrentSlidesQty()-this.sliderWidth;const t=this.sliderWidth,s=(t-t/this.getCurrentSlidesQty())/2,n=this.isCentered?s:0;this.isCentered&&(e+=s);const o=-e;return this.isRTL?ie?o:-i:i>n?n:i<-e?o:i})())}handleDragEnd(){if(!this.isDragging)return;this.isDragging=!1;const e=this.sliderWidth/this.getCurrentSlidesQty(),t=this.getTranslateXValue();let i=Math.round(t/e);this.isRTL&&(i=Math.round(t/e)),this.inner.classList.remove("dragging"),setTimeout(()=>{this.calculateTransform(i),this.dots&&this.setCurrentDot(),this.dragStartX=null,this.initialTranslateX=null,this.inner.querySelectorAll("a.prevented-click").forEach(e=>{e.classList.remove("prevented-click"),e.removeEventListener("click",this.removeClickEventWhileDragging)})})}getEventX(e){return e instanceof MouseEvent?e.clientX:e.touches[0].clientX}getCurrentSlidesQty(){if("object"==typeof this.slidesQty){const e=document.body.clientWidth;let t=0;return Object.keys(this.slidesQty).forEach(i=>{e>=(typeof i+1=="number"?this.slidesQty[i]:l.BREAKPOINTS[i])&&(t=this.slidesQty[i])}),t}return this.slidesQty}buildSnapSpacers(){const e=this.inner.querySelector(".snap-before"),t=this.inner.querySelector(".snap-after");e&&e.remove(),t&&t.remove();const i=this.sliderWidth,s=i/2-i/this.getCurrentSlidesQty()/2,o=(0,n.htmlToElement)(`
`),l=(0,n.htmlToElement)(`
`);this.inner.prepend(o),this.inner.appendChild(l)}initDots(){this.el.querySelectorAll(".carousel-pagination-item").length?this.setDots():this.buildDots(),this.dots&&this.setCurrentDot()}buildDots(){this.dots.innerHTML="";const e=!this.isCentered&&this.slidesQty?this.slides.length-(this.getCurrentSlidesQty()-1):this.slides.length;for(let t=0;t{const i=e.getAttribute("data-carousel-pagination-item-target");this.singleDotEvents(e,i?+i:t)})}goToCurrentDot(){const e=this.dots,t=e.getBoundingClientRect(),i=e.scrollLeft,s=e.scrollTop,n=e.clientWidth,o=e.clientHeight,l=this.dotsItems[this.currentIndex],r=l.getBoundingClientRect(),a=r.left-t.left+i,h=a+l.clientWidth,d=r.top-t.top+s,c=d+l.clientHeight;let u=i,p=s;(ai+n)&&(u=h-n),(ds+o)&&(p=c-o),e.scrollTo({left:u,top:p,behavior:"smooth"})}buildInfo(){this.infoTotal&&this.setInfoTotal(),this.infoCurrent&&this.setInfoCurrent()}setInfoTotal(){this.infoTotal.innerText=`${this.slides.length}`}setInfoCurrent(){this.infoCurrent.innerText=`${this.currentIndex+1}`}buildSingleDot(e){const t=(0,n.htmlToElement)("");return this.dotsItemClasses&&(0,n.classToClassList)(this.dotsItemClasses,t),this.singleDotEvents(t,e),t}singleDotEvents(e,t){this.onDotClickListener=()=>this.dotClick(t),e.addEventListener("click",this.onDotClickListener)}observeResize(){new ResizeObserver((0,n.debounce)(e=>{for(let t of e){const e=t.contentRect.width;e!==this.resizeContainerWidth&&(this.recalculateWidth(),this.dots&&this.initDots(),this.addCurrentClass(),this.resizeContainerWidth=e)}},this.updateDelay)).observe(this.resizeContainer)}calculateWidth(){this.isSnap||(this.inner.style.width=this.sliderWidth*this.slides.length/this.getCurrentSlidesQty()+"px"),this.slides.forEach(e=>{e.style.width=this.sliderWidth/this.getCurrentSlidesQty()+"px"}),this.calculateTransform()}addCurrentClass(){if(this.isSnap){const e=Math.floor(this.getCurrentSlidesQty()/2);for(let t=0;t=this.currentIndex-e?i.classList.add("active"):i.classList.remove("active")}}else{const e=this.isCentered?this.currentIndex+this.getCurrentSlidesQty()+(this.getCurrentSlidesQty()-1):this.currentIndex+this.getCurrentSlidesQty();this.slides.forEach((t,i)=>{i>=this.currentIndex&&i{let i=!1;const s=Math.floor(this.getCurrentSlidesQty()/2);i=this.isSnap&&!this.hasSnapSpacers?t===(this.getCurrentSlidesQty()%2==0?this.currentIndex-s+1:this.currentIndex-s):t===this.currentIndex,i?e.classList.add("active"):e.classList.remove("active")};this.dotsItems?this.dotsItems.forEach((t,i)=>e(t,i)):this.dots.querySelectorAll(":scope > *").forEach((t,i)=>e(t,i))}setElementToDisabled(e){e.classList.add("disabled"),"BUTTON"!==e.tagName&&"INPUT"!==e.tagName||e.setAttribute("disabled","disabled")}unsetElementToDisabled(e){e.classList.remove("disabled"),"BUTTON"!==e.tagName&&"INPUT"!==e.tagName||e.removeAttribute("disabled")}addDisabledClass(){if(!this.prev||!this.next)return!1;const e=getComputedStyle(this.inner).getPropertyValue("gap"),t=Math.floor(this.getCurrentSlidesQty()/2);let i=0,s=0,n=!1,o=!1;this.isSnap?(i=this.currentIndex,s=this.hasSnapSpacers?this.slides.length-1:this.slides.length-t-1,n=this.hasSnapSpacers?0===i:this.getCurrentSlidesQty()%2==0?i-t<0:i-t===0,o=i>=s&&this.container.scrollLeft+this.container.clientWidth+(parseFloat(e)||0)>=this.container.scrollWidth):(i=this.currentIndex,s=this.isCentered?this.slides.length-this.getCurrentSlidesQty()+(this.getCurrentSlidesQty()-1):this.slides.length-this.getCurrentSlidesQty(),n=0===i,o=i>=s),n?(this.unsetElementToDisabled(this.next),this.setElementToDisabled(this.prev)):o?(this.unsetElementToDisabled(this.prev),this.setElementToDisabled(this.next)):(this.unsetElementToDisabled(this.prev),this.unsetElementToDisabled(this.next))}autoPlay(){this.setTimer()}setTimer(){this.timer=setInterval(()=>{this.currentIndex===this.slides.length-1?this.goTo(0):this.goToNext()},this.speed)}resetTimer(){clearInterval(this.timer)}detectDirection(){const e=this.touchX.end-this.touchX.start,t=this.touchY.end-this.touchY.start,i=Math.abs(e),s=Math.abs(t);if(i<30||i0:e<0;this.isInfiniteLoop?n?this.goToNext():this.goToPrev():(n&&this.currentIndex0&&this.goToPrev())}calculateTransform(e){void 0!==e&&(this.currentIndex=e);const t=this.sliderWidth,i=t/this.getCurrentSlidesQty();let s=this.currentIndex*i;if(this.isSnap&&!this.isCentered&&this.container.scrollLeftt&&(this.container.scrollLeft=this.container.scrollWidth),this.isCentered&&!this.isSnap){const e=(t-i)/2;if(0===this.currentIndex)s=-e;else if(this.currentIndex>=this.slides.length-this.getCurrentSlidesQty()+(this.getCurrentSlidesQty()-1)){s=this.slides.length*i-t+e}else s=this.currentIndex*i-e}this.isSnap||this.setTransform(s),this.isAutoHeight&&(this.inner.style.height=`${this.slides[this.currentIndex].clientHeight}px`),this.dotsItems&&this.goToCurrentDot(),this.addCurrentClass(),this.isInfiniteLoop||this.addDisabledClass(),this.isSnap&&this.hasSnapSpacers&&this.buildSnapSpacers(),this.infoCurrent&&this.setInfoCurrent()}setTransform(e){this.slides.length>this.getCurrentSlidesQty()?this.inner.style.transform=this.isRTL?`translate(${e}px, 0px)`:`translate(${-e}px, 0px)`:this.inner.style.transform="translate(0px, 0px)"}setTranslate(e){this.inner.style.transform=this.isRTL?`translate(${-e}px, 0px)`:`translate(${e}px, 0px)`}setIndex(e){this.currentIndex=e,this.addCurrentClass(),this.isInfiniteLoop||this.addDisabledClass()}recalculateWidth(){this.sliderWidth=this.inner.parentElement.getBoundingClientRect().width,this.calculateWidth(),this.sliderWidth!==this.inner.parentElement.getBoundingClientRect().width&&this.recalculateWidth()}goToPrev(){if(this.currentIndex>0?this.currentIndex--:this.currentIndex=this.slides.length-this.getCurrentSlidesQty(),this.fireEvent("update",this.currentIndex),this.isSnap){const e=this.sliderWidth/this.getCurrentSlidesQty();this.container.scrollBy({left:Math.max(-this.container.scrollLeft,-e),behavior:"smooth"}),this.addCurrentClass(),this.isInfiniteLoop||this.addDisabledClass()}else this.calculateTransform();this.dots&&this.setCurrentDot()}goToNext(){const e=this.isCentered?this.slides.length-this.getCurrentSlidesQty()+(this.getCurrentSlidesQty()-1):this.slides.length-this.getCurrentSlidesQty();if(this.currentIndexthis.currentIndex?t-this.currentIndex:this.currentIndex-t,s=t>this.currentIndex?-e*i:e*i;this.container.scrollBy({left:s,behavior:"smooth"}),this.addCurrentClass(),this.isInfiniteLoop||this.addDisabledClass()}else this.calculateTransform();this.dots&&this.setCurrentDot()}destroy(){var e,t;if(this.loadingClassesAdd&&("string"==typeof this.loadingClassesAdd?this.inner.classList.remove(this.loadingClassesAdd):this.inner.classList.remove(...this.loadingClassesAdd)),this.inner&&this.afterLoadingClassesAdd&&setTimeout(()=>{"string"==typeof this.afterLoadingClassesAdd?this.inner.classList.remove(this.afterLoadingClassesAdd):this.inner.classList.remove(...this.afterLoadingClassesAdd)}),this.el.classList.remove("init"),this.inner.classList.remove("dragging"),this.slides.forEach(e=>e.classList.remove("active")),(null===(e=null==this?void 0:this.dotsItems)||void 0===e?void 0:e.length)&&this.dotsItems.forEach(e=>e.classList.remove("active")),this.prev.classList.remove("disabled"),this.next.classList.remove("disabled"),this.inner.style.width="",this.slides.forEach(e=>e.style.width=""),this.isSnap||(this.inner.style.transform=""),this.isAutoHeight&&(this.inner.style.height=""),this.prev.removeEventListener("click",this.onPrevClickListener),this.next.removeEventListener("click",this.onNextClickListener),this.container.removeEventListener("scroll",this.onContainerScrollListener),this.el.removeEventListener("touchstart",this.onElementTouchStartListener),this.el.removeEventListener("touchend",this.onElementTouchEndListener),this.inner.removeEventListener("mousedown",this.onInnerMouseDownListener),this.inner.removeEventListener("touchstart",this.onInnerTouchStartListener),document.removeEventListener("mousemove",this.onDocumentMouseMoveListener),document.removeEventListener("touchmove",this.onDocumentTouchMoveListener),document.removeEventListener("mouseup",this.onDocumentMouseUpListener),document.removeEventListener("touchend",this.onDocumentTouchEndListener),this.inner.querySelectorAll("a:not(.prevented-click)").forEach(e=>{e.classList.remove("prevented-click"),e.removeEventListener("click",this.removeClickEventWhileDragging)}),(null===(t=null==this?void 0:this.dotsItems)||void 0===t?void 0:t.length)||this.dots.querySelectorAll(":scope > *").length){((null==this?void 0:this.dotsItems)||this.dots.querySelectorAll(":scope > *")).forEach(e=>e.removeEventListener("click",this.onDotClickListener)),this.dots.innerHTML=null}this.inner.querySelector(".snap-before").remove(),this.inner.querySelector(".snap-after").remove(),this.dotsItems=null,this.isDragging=!1,this.dragStartX=null,this.initialTranslateX=null,window.$hsCarouselCollection=window.$hsCarouselCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsCarouselCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsCarouselCollection||(window.$hsCarouselCollection=[]),window.$hsCarouselCollection&&(window.$hsCarouselCollection=window.$hsCarouselCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-carousel]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsCarouselCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new r(e)})}}window.addEventListener("load",()=>{r.autoInit()}),"undefined"!=typeof window&&(window.HSCarousel=r),t.default=r},306:function(e,t,i){ +/* + * HSAccordion + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t,i){super(e,t,i),this.toggle=this.el.querySelector(".accordion-toggle")||null,this.content=this.el.querySelector(".accordion-content")||null,this.group=this.el.closest(".accordion")||null,this.update(),this.isToggleStopPropagated=(0,n.stringToBoolean)((0,n.getClassProperty)(this.toggle,"--stop-propagation","false")||"false"),this.keepOneOpen=!!this.group&&(0,n.stringToBoolean)((0,n.getClassProperty)(this.group,"--keep-one-open","false")||"false"),this.toggle&&this.content&&this.init()}init(){this.createCollection(window.$hsAccordionCollection,this),this.onToggleClickListener=e=>this.toggleClick(e),this.toggle.addEventListener("click",this.onToggleClickListener)}toggleClick(e){if(this.el.classList.contains("active")&&this.keepOneOpen)return!1;this.isToggleStopPropagated&&e.stopPropagation(),this.el.classList.contains("active")?this.hide():this.show()}show(){var e;if(this.group&&!this.isAlwaysOpened&&this.group.querySelector(":scope > .accordion-item.active")&&this.group.querySelector(":scope > .accordion-item.active")!==this.el){window.$hsAccordionCollection.find(e=>e.element.el===this.group.querySelector(":scope > .accordion-item.active")).element.hide()}if(this.el.classList.contains("active"))return!1;this.el.classList.add("active"),(null===(e=null==this?void 0:this.toggle)||void 0===e?void 0:e.ariaExpanded)&&(this.toggle.ariaExpanded="true"),this.fireEvent("beforeOpen",this.el),(0,n.dispatch)("beforeOpen.accordion.item",this.el,this.el),this.content.style.display="block",this.content.style.height="0",setTimeout(()=>{this.content.style.height=`${this.content.scrollHeight}px`,(0,n.afterTransition)(this.content,()=>{this.content.style.display="block",this.content.style.height="",this.fireEvent("open",this.el),(0,n.dispatch)("open.accordion.item",this.el,this.el)})})}hide(){var e;if(!this.el.classList.contains("active"))return!1;this.el.classList.remove("active"),(null===(e=null==this?void 0:this.toggle)||void 0===e?void 0:e.ariaExpanded)&&(this.toggle.ariaExpanded="false"),this.fireEvent("beforeClose",this.el),(0,n.dispatch)("beforeClose.accordion.item",this.el,this.el),this.content.style.height=`${this.content.scrollHeight}px`,setTimeout(()=>{this.content.style.height="0"}),(0,n.afterTransition)(this.content,()=>{this.content.style.display="none",this.content.style.height="",this.fireEvent("close",this.el),(0,n.dispatch)("close.accordion.item",this.el,this.el)})}update(){if(this.group=this.el.closest(".accordion")||null,!this.group)return!1;this.isAlwaysOpened=this.group.hasAttribute("data-accordion-always-open")||!1,window.$hsAccordionCollection.map(e=>(e.id===this.el.id&&(e.element.group=this.group,e.element.isAlwaysOpened=this.isAlwaysOpened),e))}destroy(){var e;(null===(e=null==l?void 0:l.selectable)||void 0===e?void 0:e.length)&&l.selectable.forEach(e=>{e.listeners.forEach(({el:e,listener:t})=>{e.removeEventListener("click",t)})}),this.onToggleClickListener&&this.toggle.removeEventListener("click",this.onToggleClickListener),this.toggle=null,this.content=null,this.group=null,this.onToggleClickListener=null,window.$hsAccordionCollection=window.$hsAccordionCollection.filter(({element:e})=>e.el!==this.el)}static findInCollection(e){return window.$hsAccordionCollection.find(t=>e instanceof l?t.element.el===e.el:"string"==typeof e?t.element.el===document.querySelector(e):t.element.el===e)||null}static autoInit(){window.$hsAccordionCollection||(window.$hsAccordionCollection=[]),window.$hsAccordionCollection&&(window.$hsAccordionCollection=window.$hsAccordionCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll(".accordion-item:not(.--prevent-on-load-init)").forEach(e=>{window.$hsAccordionCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}static getInstance(e,t){const i=window.$hsAccordionCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static show(e){const t=l.findInCollection(e);t&&"block"!==t.element.content.style.display&&t.element.show()}static hide(e){const t=l.findInCollection(e),i=t?window.getComputedStyle(t.element.content):null;t&&"none"!==i.display&&t.element.hide()}static treeView(){if(!document.querySelectorAll(".accordion-treeview-root").length)return!1;this.selectable=[],document.querySelectorAll(".accordion-treeview-root").forEach(e=>{const t=null==e?void 0:e.getAttribute("data-accordion-options"),i=t?JSON.parse(t):{};this.selectable.push({el:e,options:Object.assign({},i),listeners:[]})}),this.selectable.length&&this.selectable.forEach(e=>{const{el:t}=e;t.querySelectorAll(".accordion-selectable").forEach(t=>{const i=i=>this.onSelectableClick(i,e,t);t.addEventListener("click",i),e.listeners.push({el:t,listener:i})})})}static toggleSelected(e,t){t.classList.contains("selected")?t.classList.remove("selected"):(e.el.querySelectorAll(".accordion-selectable").forEach(e=>e.classList.remove("selected")),t.classList.add("selected"))}static on(e,t,i){const s=l.findInCollection(t);s&&(s.element.events[e]=i)}}l.onSelectableClick=(e,t,i)=>{e.stopPropagation(),l.toggleSelected(t,i)},window.addEventListener("load",()=>{l.autoInit(),document.querySelectorAll(".accordion-treeview-root").length&&l.treeView()}),"undefined"!=typeof window&&(window.HSAccordion=l),t.default=l},319:(e,t,i)=>{ +/* + * HSStaticMethods + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +Object.defineProperty(t,"__esModule",{value:!0});const s=i(806),n=i(393),o={getClassProperty:s.getClassProperty,afterTransition:s.afterTransition,autoInit(e="all"){"all"===e?n.COLLECTIONS.forEach(({fn:e})=>{null==e||e.autoInit()}):n.COLLECTIONS.forEach(({key:t,fn:i})=>{e.includes(t)&&(null==i||i.autoInit())})},cleanCollection(e="all"){"all"===e?n.COLLECTIONS.forEach(({collection:e})=>{window[e]instanceof Array&&(window[e]=[])}):n.COLLECTIONS.forEach(({key:t,collection:i})=>{e.includes(t)&&window[i]instanceof Array&&(window[i]=[])})}};"undefined"!=typeof window&&(window.HSStaticMethods=o),t.default=o},393:function(e,t,i){ +/* + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.COLLECTIONS=void 0;const n=s(i(12)),o=s(i(306)),l=s(i(302)),r=s(i(763)),a=s(i(75));let h,d,c;const u=s(i(753)),p=s(i(86)),m=s(i(164)),g=s(i(594)),v=s(i(113)),f=s(i(881)),w=s(i(956)),y=s(i(213)),b=s(i(207)),C=s(i(944)),S=s(i(126)),L=s(i(234)),I=s(i(487)),E=s(i(782));if("undefined"!=typeof window){try{"undefined"!=typeof DataTable&&"undefined"!=typeof jQuery&&(h=i(792).default)}catch(e){console.warn("HSDataTable: Required dependencies not found"),h=null}try{"undefined"!=typeof _&&"undefined"!=typeof Dropzone&&(d=i(192).default)}catch(e){console.warn("HSFileUpload: Required dependencies not found"),d=null}try{"undefined"!=typeof noUiSlider&&(c=i(977).default)}catch(e){console.warn("HSRangeSlider: Required dependencies not found"),c=null}}t.COLLECTIONS=[{key:"copy-markup",fn:n.default,collection:"$hsCopyMarkupCollection"},{key:"accordion",fn:o.default,collection:"$hsAccordionCollection"},{key:"carousel",fn:l.default,collection:"$hsCarouselCollection"},{key:"collapse",fn:r.default,collection:"$hsCollapseCollection"},{key:"combobox",fn:a.default,collection:"$hsComboBoxCollection"},...h?[{key:"datatable",fn:h,collection:"$hsDataTableCollection"}]:[],{key:"dropdown",fn:u.default,collection:"$hsDropdownCollection"},...d?[{key:"file-upload",fn:d,collection:"$hsFileUploadCollection"}]:[],{key:"input-number",fn:p.default,collection:"$hsInputNumberCollection"},{key:"overlay",fn:m.default,collection:"$hsOverlayCollection"},{key:"pin-input",fn:g.default,collection:"$hsPinInputCollection"},...c?[{key:"range-slider",fn:c,collection:"$hsRangeSliderCollection"}]:[],{key:"remove-element",fn:v.default,collection:"$hsRemoveElementCollection"},{key:"scrollspy",fn:f.default,collection:"$hsScrollspyCollection"},{key:"select",fn:w.default,collection:"$hsSelectCollection"},{key:"stepper",fn:y.default,collection:"$hsStepperCollection"},{key:"strong-password",fn:b.default,collection:"$hsStrongPasswordCollection"},{key:"tabs",fn:C.default,collection:"$hsTabsCollection"},{key:"toggle-count",fn:S.default,collection:"$hsToggleCountCollection"},{key:"toggle-password",fn:L.default,collection:"$hsTogglePasswordCollection"},{key:"tooltip",fn:I.default,collection:"$hsTooltipCollection"},{key:"tree-view",fn:E.default,collection:"$hsTreeViewCollection"}]},487:function(e,t,i){ +/* + * HSTooltip + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(949),o=i(806),l=s(i(287)),r=i(917);class a extends l.default{constructor(e,t,i){super(e,t,i),this.cleanupAutoUpdate=null,this.el&&(this.toggle=this.el.querySelector(".tooltip-toggle")||this.el,this.content=this.el.querySelector(".tooltip-content"),this.eventMode=(0,o.getClassProperty)(this.el,"--trigger")||"hover",this.preventFloatingUI=(0,o.getClassProperty)(this.el,"--prevent-popper","false"),this.placement=(0,o.getClassProperty)(this.el,"--placement"),this.interaction=(0,o.getClassProperty)(this.el,"--interaction","false"),this.strategy=(0,o.getClassProperty)(this.el,"--strategy"),this.scope=(0,o.getClassProperty)(this.el,"--scope")||"parent"),this.el&&this.toggle&&this.content&&this.init()}toggleClick(){this.click()}toggleMouseEnter(){this.enter()}toggleMouseLeave(){this.leave()}toggleHandle(){this.hide(),this.toggle.removeEventListener("click",this.onToggleHandleListener,!0),this.toggle.removeEventListener("blur",this.onToggleHandleListener,!0)}init(){this.createCollection(window.$hsTooltipCollection,this),this.onToggleFocusListener=()=>this.enter(),this.onToggleBlurListener=()=>this.hide(),this.toggle.addEventListener("focus",this.onToggleFocusListener),this.toggle.addEventListener("blur",this.onToggleBlurListener),"click"===this.eventMode?(this.onToggleClickListener=()=>this.toggleClick(),this.toggle.addEventListener("click",this.onToggleClickListener)):"hover"===this.eventMode&&(this.onToggleMouseEnterListener=()=>this.toggleMouseEnter(),this.onToggleMouseLeaveListener=()=>this.toggleMouseLeave(),this.toggle.addEventListener("mouseenter",this.onToggleMouseEnterListener),this.toggle.addEventListener("mouseleave",this.onToggleMouseLeaveListener)),"false"===this.preventFloatingUI&&this.buildFloatingUI()}enter(){this._show()}leave(){this.hide()}click(){var e;if(this.el.classList.contains("show"))this.hide();else if(this._show(),"true"===this.interaction){const t=e=>{this.content&&this.content.contains(e.target)&&e.stopPropagation()};null===(e=this.content)||void 0===e||e.addEventListener("click",t);const i=e=>{var t;(null===(t=this.content)||void 0===t?void 0:t.contains(e.target))||this.el.contains(e.target)||this.hide()};document.body.addEventListener("click",i)}else this.onToggleHandleListener=()=>{setTimeout(()=>this.toggleHandle())},this.toggle.addEventListener("click",this.onToggleHandleListener,!0),this.toggle.addEventListener("blur",this.onToggleHandleListener,!0)}buildFloatingUI(){var e;"window"===this.scope&&document.body.appendChild(this.content);const t=null!==(e=r.POSITIONS[this.placement])&&void 0!==e?e:"top",i=new Set(["top","top-start","top-end","bottom","bottom-start","bottom-end"]).has(t),s="false"===this.preventFloatingUI,o=s&&i,l=s,a=[(0,n.offset)(0),...o?[(0,n.shift)()]:[],...l?[(0,n.flip)()]:[]];(0,n.computePosition)(this.toggle,this.content,{placement:r.POSITIONS[this.placement]||"top",strategy:this.strategy||"fixed",middleware:a}).then(({x:e,y:t})=>{Object.assign(this.content.style,{position:this.strategy||"fixed",left:`${e}px`,top:`${t}px`})}),this.cleanupAutoUpdate=(0,n.autoUpdate)(this.toggle,this.content,()=>{(0,n.computePosition)(this.toggle,this.content,{placement:r.POSITIONS[this.placement]||"top",strategy:this.strategy||"fixed",middleware:a}).then(({x:e,y:t})=>{Object.assign(this.content.style,{left:`${e}px`,top:`${t}px`})})})}_show(){this.content.classList.remove("hidden"),"window"===this.scope&&this.content.classList.add("show"),"false"!==this.preventFloatingUI||this.cleanupAutoUpdate||this.buildFloatingUI(),setTimeout(()=>{this.el.classList.add("show"),this.fireEvent("show",this.el),(0,o.dispatch)("show.tooltip",this.el,this.el)})}show(){"click"===this.eventMode?this.click():this.enter(),this.toggle.focus(),this.toggle.style.outline="none"}hide(){this.el.classList.remove("show"),"window"===this.scope&&this.content.classList.remove("show"),"false"===this.preventFloatingUI&&this.cleanupAutoUpdate&&(this.cleanupAutoUpdate(),this.cleanupAutoUpdate=null),this.fireEvent("hide",this.el),(0,o.dispatch)("hide.tooltip",this.el,this.el),(0,o.afterTransition)(this.content,()=>{if(this.el.classList.contains("show"))return!1;this.content.classList.add("hidden"),this.toggle.style.outline=""})}destroy(){this.el.classList.remove("show"),this.content.classList.add("hidden"),this.toggle.removeEventListener("focus",this.onToggleFocusListener),this.toggle.removeEventListener("blur",this.onToggleBlurListener),"click"===this.eventMode?this.toggle.removeEventListener("click",this.onToggleClickListener):"hover"===this.eventMode&&(this.toggle.removeEventListener("mouseenter",this.onToggleMouseEnterListener),this.toggle.removeEventListener("mouseleave",this.onToggleMouseLeaveListener)),this.toggle.removeEventListener("click",this.onToggleHandleListener,!0),this.toggle.removeEventListener("blur",this.onToggleHandleListener,!0),this.cleanupAutoUpdate&&(this.cleanupAutoUpdate(),this.cleanupAutoUpdate=null),window.$hsTooltipCollection=window.$hsTooltipCollection.filter(({element:e})=>e.el!==this.el)}static findInCollection(e){return window.$hsTooltipCollection.find(t=>e instanceof a?t.element.el===e.el:"string"==typeof e?t.element.el===document.querySelector(e):t.element.el===e)||null}static getInstance(e,t=!1){const i=window.$hsTooltipCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static autoInit(){window.$hsTooltipCollection||(window.$hsTooltipCollection=[]),window.$hsTooltipCollection&&(window.$hsTooltipCollection=window.$hsTooltipCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll(".tooltip:not(.--prevent-on-load-init)").forEach(e=>{window.$hsTooltipCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new a(e)})}static show(e){const t=a.findInCollection(e);t&&t.element.show()}static hide(e){const t=a.findInCollection(e);t&&t.element.hide()}static on(e,t,i){const s=a.findInCollection(t);s&&(s.element.events[e]=i)}}window.addEventListener("load",()=>{a.autoInit()}),"undefined"!=typeof window&&(window.HSTooltip=a),t.default=a},594:function(e,t,i){ +/* + * HSPinInput + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{elementInput(e,t){this.onInput(e,t)}elementPaste(e){this.onPaste(e)}elementKeydown(e,t){this.onKeydown(e,t)}elementFocusin(e){this.onFocusIn(e)}elementFocusout(e){this.onFocusOut(e)}constructor(e,t){super(e,t);const i=e.getAttribute("data-pin-input"),s=i?JSON.parse(i):{},n=Object.assign(Object.assign({},s),t);this.items=this.el.querySelectorAll("[data-pin-input-item]"),this.currentItem=null,this.currentValue=new Array(this.items.length).fill(""),this.placeholders=[],this.availableCharsRE=new RegExp((null==n?void 0:n.availableCharsRE)||"^[a-zA-Z0-9]+$"),this.onElementInputListener=[],this.onElementPasteListener=[],this.onElementKeydownListener=[],this.onElementFocusinListener=[],this.onElementFocusoutListener=[],this.init()}init(){this.createCollection(window.$hsPinInputCollection,this),this.items.length&&this.build()}build(){this.buildInputItems()}buildInputItems(){this.items.forEach((e,t)=>{this.placeholders.push(e.getAttribute("placeholder")||""),e.hasAttribute("autofocus")&&this.onFocusIn(t),this.onElementInputListener.push({el:e,fn:e=>this.elementInput(e,t)}),this.onElementPasteListener.push({el:e,fn:e=>this.elementPaste(e)}),this.onElementKeydownListener.push({el:e,fn:e=>this.elementKeydown(e,t)}),this.onElementFocusinListener.push({el:e,fn:()=>this.elementFocusin(t)}),this.onElementFocusoutListener.push({el:e,fn:()=>this.elementFocusout(t)}),e.addEventListener("input",this.onElementInputListener.find(t=>t.el===e).fn),e.addEventListener("paste",this.onElementPasteListener.find(t=>t.el===e).fn),e.addEventListener("keydown",this.onElementKeydownListener.find(t=>t.el===e).fn),e.addEventListener("focusin",this.onElementFocusinListener.find(t=>t.el===e).fn),e.addEventListener("focusout",this.onElementFocusoutListener.find(t=>t.el===e).fn)})}checkIfNumber(e){return e.match(this.availableCharsRE)}autoFillAll(e){Array.from(e).forEach((e,t)=>{if(!(null==this?void 0:this.items[t]))return!1;this.items[t].value=e,this.items[t].dispatchEvent(new Event("input",{bubbles:!0}))})}setCurrentValue(){this.currentValue=Array.from(this.items).map(e=>e.value)}toggleCompleted(){this.currentValue.includes("")?this.el.classList.remove("active"):this.el.classList.add("active")}onInput(e,t){const i=e.target.value;if(this.currentItem=e.target,this.currentItem.value="",this.currentItem.value=i[i.length-1],!this.checkIfNumber(this.currentItem.value))return this.currentItem.value=this.currentValue[t]||"",!1;if(this.setCurrentValue(),this.currentItem.value){if(t0&&this.items[t-1].focus()}onKeydown(e,t){"Backspace"===e.key&&t>0&&(""===this.items[t].value?(this.items[t-1].value="",this.items[t-1].focus()):this.items[t].value=""),this.setCurrentValue(),this.toggleCompleted()}onFocusIn(e){this.items[e].setAttribute("placeholder","")}onFocusOut(e){this.items[e].setAttribute("placeholder",this.placeholders[e])}onPaste(e){e.preventDefault(),this.items.forEach(t=>{document.activeElement===t&&this.autoFillAll(e.clipboardData.getData("text"))})}destroy(){this.el.classList.remove("active"),this.items.length&&this.items.forEach(e=>{e.removeEventListener("input",this.onElementInputListener.find(t=>t.el===e).fn),e.removeEventListener("paste",this.onElementPasteListener.find(t=>t.el===e).fn),e.removeEventListener("keydown",this.onElementKeydownListener.find(t=>t.el===e).fn),e.removeEventListener("focusin",this.onElementFocusinListener.find(t=>t.el===e).fn),e.removeEventListener("focusout",this.onElementFocusoutListener.find(t=>t.el===e).fn)}),this.items=null,this.currentItem=null,this.currentValue=null,window.$hsPinInputCollection=window.$hsPinInputCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsPinInputCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsPinInputCollection||(window.$hsPinInputCollection=[]),window.$hsPinInputCollection&&(window.$hsPinInputCollection=window.$hsPinInputCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-pin-input]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsPinInputCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}}window.addEventListener("load",()=>{l.autoInit()}),"undefined"!=typeof window&&(window.HSPinInput=l),t.default=l},753:function(e,t,i){ +/* + * HSDropdown + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=i(949),l=s(i(287)),r=s(i(294)),a=i(917);class h extends l.default{constructor(e,t,i){super(e,t,i),this.longPressTimer=null,this.openedViaKeyboard=!1,this.onTouchStartListener=null,this.onTouchEndListener=null,this.toggle=this.el.querySelector(":scope > .dropdown-toggle")||this.el.querySelector(":scope > .dropdown-toggle-wrapper > .dropdown-toggle")||this.el.children[0],this.closers=Array.from(this.el.querySelectorAll(":scope .dropdown-close"))||null,this.menu=this.el.querySelector(":scope > .dropdown-menu"),this.eventMode=(0,n.getClassProperty)(this.el,"--trigger","click"),this.closeMode=(0,n.getClassProperty)(this.el,"--auto-close","true"),this.hasAutofocus=(0,n.stringToBoolean)((0,n.getClassProperty)(this.el,"--has-autofocus","true")||"true"),this.autofocusOnKeyboardOnly=(0,n.stringToBoolean)((0,n.getClassProperty)(this.el,"--autofocus-on-keyboard-only","true")||"true"),this.animationInProcess=!1,this.onCloserClickListener=[],this.toggle&&this.menu&&this.init()}elementMouseEnter(){this.onMouseEnterHandler()}elementMouseLeave(){this.onMouseLeaveHandler()}toggleClick(e){this.onClickHandler(e)}toggleContextMenu(e){e.preventDefault(),this.onContextMenuHandler(e)}handleTouchStart(e){this.longPressTimer=window.setTimeout(()=>{e.preventDefault();const t=e.touches[0],i=new MouseEvent("contextmenu",{bubbles:!0,cancelable:!0,view:window,clientX:t.clientX,clientY:t.clientY});this.toggle&&this.toggle.dispatchEvent(i)},400)}handleTouchEnd(e){this.longPressTimer&&(clearTimeout(this.longPressTimer),this.longPressTimer=null)}closerClick(){this.close()}init(){if(this.createCollection(window.$hsDropdownCollection,this),this.toggle.disabled)return!1;this.toggle&&this.buildToggle(),this.menu&&this.buildMenu(),this.closers&&this.buildClosers(),(0,n.isIOS)()||(0,n.isIpadOS)()||(this.onElementMouseEnterListener=()=>this.elementMouseEnter(),this.onElementMouseLeaveListener=()=>this.elementMouseLeave(),this.el.addEventListener("mouseenter",this.onElementMouseEnterListener),this.el.addEventListener("mouseleave",this.onElementMouseLeaveListener)),"undefined"!=typeof window&&(window.HSAccessibilityObserver||(window.HSAccessibilityObserver=new r.default),this.setupAccessibility())}resizeHandler(){this.eventMode=(0,n.getClassProperty)(this.el,"--trigger","click"),this.closeMode=(0,n.getClassProperty)(this.el,"--auto-close","true"),this.hasAutofocus=(0,n.stringToBoolean)((0,n.getClassProperty)(this.el,"--has-autofocus","true")||"true"),this.autofocusOnKeyboardOnly=(0,n.stringToBoolean)((0,n.getClassProperty)(this.el,"--autofocus-on-keyboard-only","true")||"true")}isOpen(){return this.el.classList.contains("open")&&!this.menu.classList.contains("hidden")}buildToggle(){var e;(null===(e=null==this?void 0:this.toggle)||void 0===e?void 0:e.ariaExpanded)&&(this.el.classList.contains("open")?this.toggle.ariaExpanded="true":this.toggle.ariaExpanded="false"),"contextmenu"===this.eventMode?(this.onToggleContextMenuListener=e=>this.toggleContextMenu(e),this.onTouchStartListener=this.handleTouchStart.bind(this),this.onTouchEndListener=this.handleTouchEnd.bind(this),this.toggle.addEventListener("contextmenu",this.onToggleContextMenuListener),this.toggle.addEventListener("touchstart",this.onTouchStartListener,{passive:!1}),this.toggle.addEventListener("touchend",this.onTouchEndListener),this.toggle.addEventListener("touchmove",this.onTouchEndListener)):(this.onToggleClickListener=e=>this.toggleClick(e),this.toggle.addEventListener("click",this.onToggleClickListener))}buildMenu(){this.menu.role=this.menu.getAttribute("role")||"menu",this.menu.tabIndex=-1;const e=this.menu.querySelectorAll('[role="menuitemcheckbox"]'),t=this.menu.querySelectorAll('[role="menuitemradio"]');e.forEach(e=>e.addEventListener("click",()=>this.selectCheckbox(e))),t.forEach(e=>e.addEventListener("click",()=>this.selectRadio(e))),this.menu.addEventListener("click",e=>{const t=e.target;"INPUT"===t.tagName||"TEXTAREA"===t.tagName||"SELECT"===t.tagName||"BUTTON"===t.tagName||"A"===t.tagName||t.closest("button")||t.closest("a")||t.closest("input")||t.closest("textarea")||t.closest("select")||this.menu.focus()})}buildClosers(){this.closers.forEach(e=>{this.onCloserClickListener.push({el:e,fn:()=>this.closerClick()}),e.addEventListener("click",this.onCloserClickListener.find(t=>t.el===e).fn)})}getScrollbarSize(){let e=document.createElement("div");e.style.overflow="scroll",e.style.width="100px",e.style.height="100px",document.body.appendChild(e);let t=e.offsetWidth-e.clientWidth;return document.body.removeChild(e),t}onContextMenuHandler(e){const t={getBoundingClientRect:(()=>new DOMRect,()=>new DOMRect(e.clientX,e.clientY,0,0))};h.closeCurrentlyOpened(),this.el.classList.contains("open")&&!this.menu.classList.contains("hidden")?(this.close(),document.body.style.overflow="",document.body.style.paddingRight=""):(document.body.style.overflow="hidden",document.body.style.paddingRight=`${this.getScrollbarSize()}px`,this.open(t))}onClickHandler(e){var t;if("hover"===this.eventMode&&window.matchMedia("(hover: hover)").matches&&"mouse"===e.pointerType){const i=e.currentTarget;return"A"===i.tagName&&i.hasAttribute("href")&&"#"!==i.getAttribute("href")||(e.preventDefault(),e.stopPropagation(),null===(t=e.stopImmediatePropagation)||void 0===t||t.call(e)),!1}this.el.classList.contains("open")&&!this.menu.classList.contains("hidden")?this.close():this.open()}onMouseEnterHandler(){if("hover"!==this.eventMode)return!1;(!this.el._floatingUI||this.el._floatingUI&&!this.el.classList.contains("open"))&&this.forceClearState(),!this.el.classList.contains("open")&&this.menu.classList.contains("hidden")&&this.open()}onMouseLeaveHandler(){if("hover"!==this.eventMode)return!1;this.el.classList.contains("open")&&!this.menu.classList.contains("hidden")&&this.close()}destroyFloatingUI(){const e=(window.getComputedStyle(this.el).getPropertyValue("--scope")||"").trim();this.menu.classList.remove("block"),this.menu.classList.add("hidden"),this.menu.style.inset=null,this.menu.style.position=null,this.el&&this.el._floatingUI&&(this.el._floatingUI.destroy(),this.el._floatingUI=null),"window"===e&&this.el.appendChild(this.menu),this.animationInProcess=!1}focusElement(){const e=this.menu.querySelector("[autofocus]");if(e)return e.focus(),!0;const t=this.menu.querySelectorAll('a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden]), .dropdown-item');if(t.length>0){return t[0].focus(),!0}return!1}setupFloatingUI(e){const t=e||this.el,i=window.getComputedStyle(this.el),s=(i.getPropertyValue("--placement")||"").trim(),n=(i.getPropertyValue("--flip")||"true").trim(),l=(i.getPropertyValue("--strategy")||"fixed").trim(),r=(i.getPropertyValue("--offset")||"6").trim(),h=(i.getPropertyValue("--gpu-acceleration")||"true").trim(),d=(window.getComputedStyle(this.el).getPropertyValue("--adaptive")||"adaptive").replace(" ",""),c=(i.getPropertyValue("--mega-menu")||"false").trim(),u=l,p=parseInt(r,10),m=a.POSITIONS[s]||"bottom-start",g=[..."true"===n?[(0,o.flip)()]:[],(0,o.offset)(p)],v={placement:m,strategy:u,middleware:g},f=e=>{const t=this.menu.getBoundingClientRect(),i=window.innerWidth-(window.innerWidth-document.documentElement.clientWidth);return e+t.width>i&&(e=i-t.width),e<0&&(e=0),e},w=()=>{(0,o.computePosition)(t,this.menu,v).then(({x:e,y:t,placement:i})=>{const s="true"===c?f(e):e;"absolute"===u&&"none"===d?Object.assign(this.menu.style,{position:u,margin:"0"}):"absolute"===u?Object.assign(this.menu.style,{position:u,transform:`translate3d(${e}px, ${t}px, 0px)`,margin:"0"}):"true"===h?Object.assign(this.menu.style,{position:u,left:"",top:"",inset:"0px auto auto 0px",margin:"0",transform:`translate3d(${"adaptive"===d?s:0}px, ${t}px, 0)`}):Object.assign(this.menu.style,{position:u,left:`${e}px`,top:`${t}px`,transform:""}),this.menu.setAttribute("data-placement",i)})};w();return{update:w,destroy:(0,o.autoUpdate)(t,this.menu,w)}}selectCheckbox(e){e.ariaChecked="true"===e.ariaChecked?"false":"true"}selectRadio(e){if("true"===e.ariaChecked)return!1;const t=e.closest(".group").querySelectorAll('[role="menuitemradio"]');Array.from(t).filter(t=>t!==e).forEach(e=>{e.ariaChecked="false"}),e.ariaChecked="true"}calculateFloatingUIPosition(e){const t=this.setupFloatingUI(e),i=this.menu.getAttribute("data-placement");return t.update(),t.destroy(),i}open(e,t=!1){if(this.el.classList.contains("open")||this.animationInProcess)return!1;this.openedViaKeyboard=t,this.animationInProcess=!0,this.menu.style.cssText="";const i=e||this.el,s=window.getComputedStyle(this.el),o=(s.getPropertyValue("--scope")||"").trim(),l=(s.getPropertyValue("--strategy")||"fixed").trim();"window"===o&&document.body.appendChild(this.menu),"static"!==l&&(this.el._floatingUI=this.setupFloatingUI(i)),this.menu.style.margin=null,this.menu.classList.remove("hidden"),this.menu.classList.add("block"),setTimeout(()=>{var e;(null===(e=null==this?void 0:this.toggle)||void 0===e?void 0:e.ariaExpanded)&&(this.toggle.ariaExpanded="true"),this.el.classList.add("open"),window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,!0),"window"===o&&this.menu.classList.add("open"),this.animationInProcess=!1,!this.hasAutofocus||this.autofocusOnKeyboardOnly&&!this.openedViaKeyboard||this.focusElement(),this.fireEvent("open",this.el),(0,n.dispatch)("open.dropdown",this.el,this.el)})}close(e=!0){if(this.animationInProcess||!this.el.classList.contains("open"))return!1;const t=(window.getComputedStyle(this.el).getPropertyValue("--scope")||"").trim();if(this.animationInProcess=!0,"window"===t&&this.menu.classList.remove("open"),window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,!1),e){const e=this.el.querySelector("[data-dropdown-transition]")||this.menu;let t=!1;const i=()=>{t||(t=!0,this.destroyFloatingUI())};(0,n.afterTransition)(e,i);const s=window.getComputedStyle(e).getPropertyValue("transition-duration"),o=1e3*parseFloat(s)||150;setTimeout(i,o+50)}else this.destroyFloatingUI();(()=>{var e;this.menu.style.margin=null,(null===(e=null==this?void 0:this.toggle)||void 0===e?void 0:e.ariaExpanded)&&(this.toggle.ariaExpanded="false"),this.el.classList.remove("open"),this.openedViaKeyboard=!1,this.fireEvent("close",this.el),(0,n.dispatch)("close.dropdown",this.el,this.el)})()}forceClearState(){this.destroyFloatingUI(),this.menu.style.margin=null,this.el.classList.remove("open"),this.menu.classList.add("hidden"),this.openedViaKeyboard=!1}destroy(){(0,n.isIOS)()||(0,n.isIpadOS)()||(this.el.removeEventListener("mouseenter",this.onElementMouseEnterListener),this.el.removeEventListener("mouseleave",()=>this.onElementMouseLeaveListener),this.onElementMouseEnterListener=null,this.onElementMouseLeaveListener=null),"contextmenu"===this.eventMode?(this.toggle&&(this.toggle.removeEventListener("contextmenu",this.onToggleContextMenuListener),this.toggle.removeEventListener("touchstart",this.onTouchStartListener),this.toggle.removeEventListener("touchend",this.onTouchEndListener),this.toggle.removeEventListener("touchmove",this.onTouchEndListener)),this.onToggleContextMenuListener=null,this.onTouchStartListener=null,this.onTouchEndListener=null):(this.toggle&&this.toggle.removeEventListener("click",this.onToggleClickListener),this.onToggleClickListener=null),this.closers.length&&(this.closers.forEach(e=>{e.removeEventListener("click",this.onCloserClickListener.find(t=>t.el===e).fn)}),this.onCloserClickListener=null),this.el.classList.remove("open"),this.destroyFloatingUI(),window.$hsDropdownCollection=window.$hsDropdownCollection.filter(({element:e})=>e.el!==this.el)}static findInCollection(e){return window.$hsDropdownCollection.find(t=>e instanceof h?t.element.el===e.el:"string"==typeof e?t.element.el===document.querySelector(e):t.element.el===e)||null}static getInstance(e,t){const i=window.$hsDropdownCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){if(!window.$hsDropdownCollection){window.$hsDropdownCollection=[],window.addEventListener("click",e=>{const t=e.target;h.closeCurrentlyOpened(t)});let e=window.innerWidth;window.addEventListener("resize",()=>{window.innerWidth!==e&&(e=innerWidth,h.closeCurrentlyOpened(null,!1))})}window.$hsDropdownCollection&&(window.$hsDropdownCollection=window.$hsDropdownCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll(".dropdown:not(.--prevent-on-load-init)").forEach(e=>{window.$hsDropdownCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new h(e)})}static open(e,t=!1){const i=h.findInCollection(e);i&&i.element.menu.classList.contains("hidden")&&i.element.open(void 0,t)}static close(e){const t=h.findInCollection(e);t&&!t.element.menu.classList.contains("hidden")&&t.element.close()}static closeCurrentlyOpened(e=null,t=!0){const i=e&&e.closest(".dropdown")&&e.closest(".dropdown").parentElement.closest(".dropdown")?e.closest(".dropdown").parentElement.closest(".dropdown"):null;let s=i?window.$hsDropdownCollection.filter(e=>e.element.el.classList.contains("open")&&e.element.menu.closest(".dropdown").parentElement.closest(".dropdown")===i):window.$hsDropdownCollection.filter(e=>e.element.el.classList.contains("open"));if(e){const t=e.closest(".dropdown");if(t)"inside"===(0,n.getClassPropertyAlt)(t,"--auto-close")&&(s=s.filter(e=>e.element.el!==t));else{const t=e.closest(".dropdown-menu");if(t){const e=window.$hsDropdownCollection.find(e=>e.element.menu===t);e&&"inside"===(0,n.getClassPropertyAlt)(e.element.el,"--auto-close")&&(s=s.filter(t=>t.element.el!==e.element.el))}}}e&&e.closest(".dropdown")&&"outside"===(0,n.getClassPropertyAlt)(e.closest(".dropdown"),"--auto-close")&&(s=s.filter(t=>t.element.el==e.closest(".dropdown")),s.forEach(e=>e.element.close(t))),s&&s.forEach(e=>{if("false"===e.element.closeMode||"outside"===e.element.closeMode)return!1;e.element.close(t)}),s&&s.forEach(e=>{if("contextmenu"!==(0,n.getClassPropertyAlt)(e.element.el,"--trigger"))return!1;document.body.style.overflow="",document.body.style.paddingRight=""})}setupAccessibility(){this.accessibilityComponent=window.HSAccessibilityObserver.registerComponent(this.el,{onEnter:()=>{var e;const t=document.activeElement;if(this.isOpen()){if("menuitemcheckbox"===t.getAttribute("role"))return this.selectCheckbox(t),void this.close();if("menuitemradio"===t.getAttribute("role"))return this.selectRadio(t),void this.close();if("A"===t.tagName||"BUTTON"===t.tagName||(null===(e=t.getAttribute("role"))||void 0===e?void 0:e.startsWith("menuitem")))return t.click(),void("A"===t.tagName&&t.hasAttribute("href")&&setTimeout(()=>this.close(),0))}else if(t.closest(".dropdown-toggle"))return void this.open(void 0,!0)},onSpace:()=>{this.isOpened()||this.open(void 0,!0)},onEsc:()=>{this.isOpened()&&(this.close(),this.toggle&&this.toggle.focus())},onArrow:e=>{if(!e.metaKey)switch(e.key){case"ArrowDown":this.isOpened()?this.focusMenuItem("next"):this.open(void 0,!0);break;case"ArrowUp":this.isOpened()&&this.focusMenuItem("prev");break;case"ArrowRight":this.onArrowX(e,"right");break;case"ArrowLeft":this.onArrowX(e,"left")}},onHome:()=>{this.isOpened()&&this.onStartEnd(!0)},onEnd:()=>{this.isOpened()&&this.onStartEnd(!1)},onTab:()=>{this.isOpened()&&this.close()},onFirstLetter:e=>{this.isOpened()&&this.onFirstLetter(e)}},this.isOpened(),"Dropdown",".dropdown",this.menu)}onFirstLetter(e){var t;if(!this.isOpened()||!this.menu)return;const i=this.menu.querySelectorAll('a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden]), .dropdown-item');if(0===i.length)return;const s=Array.from(i).indexOf(document.activeElement);for(let n=1;n<=i.length;n++){const o=(s+n)%i.length;if(((null===(t=i[o].textContent)||void 0===t?void 0:t.trim().toLowerCase())||"").startsWith(e.toLowerCase()))return void i[o].focus()}i[0].focus()}onArrowX(e,t){if(!this.isOpened())return;e.preventDefault(),e.stopImmediatePropagation();const i=this.menu.querySelectorAll('a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden]), .dropdown-item');if(!i.length)return;const s=Array.from(i).indexOf(document.activeElement);let n=-1;n="right"===t?(s+1)%i.length:s>0?s-1:i.length-1,i[n].focus()}onStartEnd(e=!0){if(!this.isOpened())return;const t=this.menu.querySelectorAll('a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden]), .dropdown-item');if(!t.length)return;t[e?0:t.length-1].focus()}focusMenuItem(e){const t=this.menu.querySelectorAll('a:not([hidden]), button:not([hidden]), [role="menuitem"]:not([hidden]), .dropdown-item');if(!t.length)return;const i=Array.from(t).indexOf(document.activeElement);t["next"===e?(i+1)%t.length:(i-1+t.length)%t.length].focus()}static on(e,t,i){const s=h.findInCollection(t);s&&(s.element.events[e]=i)}isOpened(){return this.isOpen()}containsElement(e){return this.el.contains(e)}}window.addEventListener("load",()=>{h.autoInit()}),window.addEventListener("resize",()=>{window.$hsDropdownCollection||(window.$hsDropdownCollection=[]),window.$hsDropdownCollection.forEach(e=>e.element.resizeHandler())}),"undefined"!=typeof window&&(window.HSDropdown=h),t.default=h},763:function(e,t,i){ +/* + * HSCollapse + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287)),l=s(i(753));class r extends o.default{constructor(e,t,i){super(e,t,i),this.contentId=this.el.dataset.collapse,this.content=document.querySelector(this.contentId),this.animationInProcess=!1,this.content&&this.init()}elementClick(){this.content.classList.contains("open")?this.hide():this.show()}init(){var e;this.createCollection(window.$hsCollapseCollection,this),this.onElementClickListener=()=>this.elementClick(),(null===(e=null==this?void 0:this.el)||void 0===e?void 0:e.ariaExpanded)&&(this.el.classList.contains("open")?this.el.ariaExpanded="true":this.el.ariaExpanded="false"),this.el.addEventListener("click",this.onElementClickListener)}hideAllMegaMenuItems(){this.content.querySelectorAll(".mega-menu-content.block").forEach(e=>{e.classList.remove("block"),e.classList.add("hidden")})}closeDropdowns(){if(!this.content)return;this.content.querySelectorAll(".dropdown").forEach(e=>{try{const t=l.default.getInstance(e,!0);if(!(null==t?void 0:t.element))return;e instanceof HTMLElement&&e.classList.contains("open")&&t.element.close(!1)}catch(e){console.warn("Error closing dropdown:",e)}})}show(){var e;if(this.animationInProcess||this.el.classList.contains("open"))return!1;this.animationInProcess=!0,this.el.classList.add("open"),(null===(e=null==this?void 0:this.el)||void 0===e?void 0:e.ariaExpanded)&&(this.el.ariaExpanded="true"),this.content.classList.add("open"),this.content.classList.remove("hidden"),this.content.style.height="0",setTimeout(()=>{this.content.style.height=`${this.content.scrollHeight}px`,this.fireEvent("beforeOpen",this.el),(0,n.dispatch)("beforeOpen.collapse",this.el,this.el)}),(0,n.afterTransition)(this.content,()=>{this.content.style.height="",this.fireEvent("open",this.el),(0,n.dispatch)("open.collapse",this.el,this.el),this.animationInProcess=!1})}hide(){var e;if(this.animationInProcess||!this.el.classList.contains("open"))return!1;this.animationInProcess=!0,this.el.classList.remove("open"),(null===(e=null==this?void 0:this.el)||void 0===e?void 0:e.ariaExpanded)&&(this.el.ariaExpanded="false"),this.content.style.height=`${this.content.scrollHeight}px`,setTimeout(()=>{this.content.style.height="0"}),this.content.classList.remove("open"),(0,n.afterTransition)(this.content,()=>{this.content.classList.add("hidden"),this.content.style.height="",this.fireEvent("hide",this.el),(0,n.dispatch)("hide.collapse",this.el,this.el),this.animationInProcess=!1}),this.content.querySelectorAll(".mega-menu-content.block").length&&this.hideAllMegaMenuItems(),this.closeDropdowns()}destroy(){this.el.removeEventListener("click",this.onElementClickListener),this.content=null,this.animationInProcess=!1,window.$hsCollapseCollection=window.$hsCollapseCollection.filter(({element:e})=>e.el!==this.el)}static findInCollection(e){return window.$hsCollapseCollection.find(t=>e instanceof r?t.element.el===e.el:"string"==typeof e?t.element.el===document.querySelector(e):t.element.el===e)||null}static getInstance(e,t=!1){const i=window.$hsCollapseCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static autoInit(){window.$hsCollapseCollection||(window.$hsCollapseCollection=[]),window.$hsCollapseCollection&&(window.$hsCollapseCollection=window.$hsCollapseCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll(".collapse-toggle:not(.--prevent-on-load-init)").forEach(e=>{window.$hsCollapseCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new r(e)})}static show(e){const t=r.findInCollection(e);t&&t.element.content.classList.contains("hidden")&&t.element.show()}static hide(e){const t=r.findInCollection(e);t&&!t.element.content.classList.contains("hidden")&&t.element.hide()}static on(e,t,i){const s=r.findInCollection(t);s&&(s.element.events[e]=i)}}window.addEventListener("load",()=>{r.autoInit()}),"undefined"!=typeof window&&(window.HSCollapse=r),t.default=r},782:function(e,t,i){ +/* + * HSTreeView + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t,i){super(e,t,i),this.items=[];const s=e.getAttribute("data-tree-view"),n=s?JSON.parse(s):{},o=Object.assign(Object.assign({},n),t);this.controlBy=(null==o?void 0:o.controlBy)||"button",this.autoSelectChildren=(null==o?void 0:o.autoSelectChildren)||!1,this.isIndeterminate=(null==o?void 0:o.isIndeterminate)||!0,this.onElementClickListener=[],this.onControlChangeListener=[],this.init()}elementClick(e,t,i){if(e.stopPropagation(),t.classList.contains("disabled"))return!1;e.metaKey||e.shiftKey||this.unselectItem(i),this.selectItem(t,i),this.fireEvent("click",{el:t,data:i}),(0,n.dispatch)("click.treeView",this.el,{el:t,data:i})}controlChange(e,t){this.autoSelectChildren?(this.selectItem(e,t),t.isDir&&this.selectChildren(e,t),this.toggleParent(e)):this.selectItem(e,t)}init(){this.createCollection(window.$hsTreeViewCollection,this),l.group+=1,this.initItems()}initItems(){this.el.querySelectorAll("[data-tree-view-item]").forEach((e,t)=>{var i,s;const n=JSON.parse(e.getAttribute("data-tree-view-item"));e.id||(e.id=`tree-view-item-${l.group}-${t}`);const o=Object.assign(Object.assign({},n),{id:null!==(i=n.id)&&void 0!==i?i:e.id,path:this.getPath(e),isSelected:null!==(s=n.isSelected)&&void 0!==s&&s});this.items.push(o),"checkbox"===this.controlBy?this.controlByCheckbox(e,o):this.controlByButton(e,o)})}controlByButton(e,t){this.onElementClickListener.push({el:e,fn:i=>this.elementClick(i,e,t)}),e.addEventListener("click",this.onElementClickListener.find(t=>t.el===e).fn)}controlByCheckbox(e,t){const i=e.querySelector(`input[value="${t.value}"]`);i&&(this.onControlChangeListener.push({el:i,fn:()=>this.controlChange(e,t)}),i.addEventListener("change",this.onControlChangeListener.find(e=>e.el===i).fn))}getItem(e){return this.items.find(t=>t.id===e)}getPath(e){var t;const i=[];let s=e.closest("[data-tree-view-item]");for(;s;){const e=JSON.parse(s.getAttribute("data-tree-view-item"));i.push(e.value),s=null===(t=s.parentElement)||void 0===t?void 0:t.closest("[data-tree-view-item]")}return i.reverse().join("/")}unselectItem(e=null){let t=this.getSelectedItems();e&&(t=t.filter(t=>t.id!==e.id)),t.length&&t.forEach(e=>{document.querySelector(`#${e.id}`).classList.remove("selected"),this.changeItemProp(e.id,"isSelected",!1)})}selectItem(e,t){t.isSelected?(e.classList.remove("selected"),this.changeItemProp(t.id,"isSelected",!1)):(e.classList.add("selected"),this.changeItemProp(t.id,"isSelected",!0))}selectChildren(e,t){const i=e.querySelectorAll("[data-tree-view-item]");Array.from(i).filter(e=>!e.classList.contains("disabled")).forEach(e=>{const i=e.id?this.getItem(e.id):null;if(!i)return!1;t.isSelected?(e.classList.add("selected"),this.changeItemProp(i.id,"isSelected",!0)):(e.classList.remove("selected"),this.changeItemProp(i.id,"isSelected",!1));const s=this.getItem(e.id),n=e.querySelector(`input[value="${s.value}"]`);this.isIndeterminate&&(n.indeterminate=!1),s.isSelected?n.checked=!0:n.checked=!1})}toggleParent(e){var t,i;let s=null===(t=e.parentElement)||void 0===t?void 0:t.closest("[data-tree-view-item]");for(;s;){const e=s.querySelectorAll("[data-tree-view-item]:not(.disabled)"),t=JSON.parse(s.getAttribute("data-tree-view-item")),n=s.querySelector(`input[value="${t.value}"]`);let o=!1,l=0;e.forEach(e=>{const t=this.getItem(e.id);t.isSelected&&(l+=1),t.isSelected||(o=!0)}),o?(s.classList.remove("selected"),this.changeItemProp(s.id,"isSelected",!1),n.checked=!1):(s.classList.add("selected"),this.changeItemProp(s.id,"isSelected",!0),n.checked=!0),this.isIndeterminate&&(l>0&&l{const t=document.querySelector(`#${e.id}`);return e.path!==this.getPath(t)&&(e.path=this.getPath(t)),e})}getSelectedItems(){return this.items.filter(e=>e.isSelected)}changeItemProp(e,t,i){this.items.map(s=>(s.id===e&&(s[t]=i),s))}destroy(){this.onElementClickListener.forEach(({el:e,fn:t})=>{e.removeEventListener("click",t)}),this.onControlChangeListener.length&&this.onElementClickListener.forEach(({el:e,fn:t})=>{e.removeEventListener("change",t)}),this.unselectItem(),this.items=[],window.$hsTreeViewCollection=window.$hsTreeViewCollection.filter(({element:e})=>e.el!==this.el),l.group-=1}static findInCollection(e){return window.$hsTreeViewCollection.find(t=>e instanceof l?t.element.el===e.el:"string"==typeof e?t.element.el===document.querySelector(e):t.element.el===e)||null}static getInstance(e,t){const i=window.$hsTreeViewCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static autoInit(){window.$hsTreeViewCollection||(window.$hsTreeViewCollection=[]),window.$hsTreeViewCollection&&(window.$hsTreeViewCollection=window.$hsTreeViewCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-tree-view]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsTreeViewCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}static on(e,t,i){const s=l.findInCollection(t);s&&(s.element.events[e]=i)}}l.group=0,window.addEventListener("load",()=>{l.autoInit()}),"undefined"!=typeof window&&(window.HSTreeView=l),t.default=l},792:function(e,t,i){ +/* + * HSDataTable + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t,i){var s,n,o,l,r,a,h,d,c,u,p,m,g,v,f,w,y,b,C,S,L;super(e,t,i),this.el="string"==typeof e?document.querySelector(e):e;const I=[];Array.from(this.el.querySelectorAll("thead th, thead td")).forEach((e,t)=>{e.classList.contains("--exclude-from-ordering")&&I.push({targets:t,orderable:!1})});const E=this.el.getAttribute("data-datatable"),T=E?JSON.parse(E):{};this.concatOptions=Object.assign(Object.assign({searching:!0,lengthChange:!1,order:[],columnDefs:[...I]},T),t),this.table=this.el.querySelector("table"),this.searches=null!==(s=Array.from(this.el.querySelectorAll("[data-datatable-search]")))&&void 0!==s?s:null,this.pageEntitiesList=null!==(n=Array.from(this.el.querySelectorAll("[data-datatable-page-entities]")))&&void 0!==n?n:null,this.pagingList=null!==(o=Array.from(this.el.querySelectorAll("[data-datatable-paging]")))&&void 0!==o?o:null,this.pagingPagesList=null!==(l=Array.from(this.el.querySelectorAll("[data-datatable-paging-pages]")))&&void 0!==l?l:null,this.pagingPrevList=null!==(r=Array.from(this.el.querySelectorAll("[data-datatable-paging-prev]")))&&void 0!==r?r:null,this.pagingNextList=null!==(a=Array.from(this.el.querySelectorAll("[data-datatable-paging-next]")))&&void 0!==a?a:null,this.infoList=null!==(h=Array.from(this.el.querySelectorAll("[data-datatable-info]")))&&void 0!==h?h:null,(null===(d=this.concatOptions)||void 0===d?void 0:d.rowSelectingOptions)&&(this.rowSelectingAll=null!==(g=(null===(u=null===(c=this.concatOptions)||void 0===c?void 0:c.rowSelectingOptions)||void 0===u?void 0:u.selectAllSelector)?document.querySelector(null===(m=null===(p=this.concatOptions)||void 0===p?void 0:p.rowSelectingOptions)||void 0===m?void 0:m.selectAllSelector):document.querySelector("[data-datatable-row-selecting-all]"))&&void 0!==g?g:null),(null===(v=this.concatOptions)||void 0===v?void 0:v.rowSelectingOptions)&&(this.rowSelectingIndividual=null!==(y=null===(w=null===(f=this.concatOptions)||void 0===f?void 0:f.rowSelectingOptions)||void 0===w?void 0:w.individualSelector)&&void 0!==y?y:"[data-datatable-row-selecting-individual]"),this.pageEntitiesList.length&&(this.concatOptions.pageLength=parseInt(this.pageEntitiesList[0].value)),this.maxPagesToShow=3,this.isRowSelecting=!!(null===(b=this.concatOptions)||void 0===b?void 0:b.rowSelectingOptions),this.pageBtnClasses=null!==(L=null===(S=null===(C=this.concatOptions)||void 0===C?void 0:C.pagingOptions)||void 0===S?void 0:S.pageBtnClasses)&&void 0!==L?L:null,this.onSearchInputListener=[],this.onPageEntitiesChangeListener=[],this.onSinglePagingClickListener=[],this.onPagingPrevClickListener=[],this.onPagingNextClickListener=[],this.init()}init(){this.createCollection(window.$hsDataTableCollection,this),this.initTable(),this.searches.length&&this.initSearch(),this.pageEntitiesList.length&&this.initPageEntities(),this.pagingList.length&&this.initPaging(),this.pagingPagesList.length&&this.buildPagingPages(),this.pagingPrevList.length&&this.initPagingPrev(),this.pagingNextList.length&&this.initPagingNext(),this.infoList.length&&this.initInfo(),this.isRowSelecting&&this.initRowSelecting()}initTable(){this.dataTable=new DataTable(this.table,this.concatOptions),this.isRowSelecting&&this.triggerChangeEventToRow(),this.dataTable.on("draw",()=>{this.isRowSelecting&&this.updateSelectAllCheckbox(),this.isRowSelecting&&this.triggerChangeEventToRow(),this.updateInfo(),this.pagingPagesList.forEach(e=>this.updatePaging(e))})}searchInput(e){this.onSearchInput(e.target.value)}pageEntitiesChange(e){this.onEntitiesChange(parseInt(e.target.value),e.target)}pagingPrevClick(){this.onPrevClick()}pagingNextClick(){this.onNextClick()}rowSelectingAllChange(){this.onSelectAllChange()}singlePagingClick(e){this.onPageClick(e)}initSearch(){this.searches.forEach(e=>{this.onSearchInputListener.push({el:e,fn:(0,n.debounce)(e=>this.searchInput(e))}),e.addEventListener("input",this.onSearchInputListener.find(t=>t.el===e).fn)})}onSearchInput(e){this.dataTable.search(e).draw()}initPageEntities(){this.pageEntitiesList.forEach(e=>{this.onPageEntitiesChangeListener.push({el:e,fn:e=>this.pageEntitiesChange(e)}),e.addEventListener("change",this.onPageEntitiesChangeListener.find(t=>t.el===e).fn)})}onEntitiesChange(e,t){const i=this.pageEntitiesList.filter(e=>e!==t);i.length&&i.forEach(t=>{if(window.HSSelect){const i=window.HSSelect.getInstance(t,!0);i&&i.element.setValue(`${e}`)}else t.value=`${e}`}),this.dataTable.page.len(e).draw()}initInfo(){this.infoList.forEach(e=>{this.initInfoFrom(e),this.initInfoTo(e),this.initInfoLength(e)})}initInfoFrom(e){var t;const i=null!==(t=e.querySelector("[data-datatable-info-from]"))&&void 0!==t?t:null,{start:s}=this.dataTable.page.info();i&&(i.innerText=`${s+1}`)}initInfoTo(e){var t;const i=null!==(t=e.querySelector("[data-datatable-info-to]"))&&void 0!==t?t:null,{end:s}=this.dataTable.page.info();i&&(i.innerText=`${s}`)}initInfoLength(e){var t;const i=null!==(t=e.querySelector("[data-datatable-info-length]"))&&void 0!==t?t:null,{recordsTotal:s}=this.dataTable.page.info();i&&(i.innerText=`${s}`)}updateInfo(){this.initInfo()}initPaging(){this.pagingList.forEach(e=>this.hidePagingIfSinglePage(e))}hidePagingIfSinglePage(e){const{pages:t}=this.dataTable.page.info();t<2?(e.classList.add("hidden"),e.style.display="none"):(e.classList.remove("hidden"),e.style.display="")}initPagingPrev(){this.pagingPrevList.forEach(e=>{this.onPagingPrevClickListener.push({el:e,fn:()=>this.pagingPrevClick()}),e.addEventListener("click",this.onPagingPrevClickListener.find(t=>t.el===e).fn)})}onPrevClick(){this.dataTable.page("previous").draw("page")}disablePagingArrow(e,t){t?(e.classList.add("disabled"),e.setAttribute("disabled","disabled")):(e.classList.remove("disabled"),e.removeAttribute("disabled"))}initPagingNext(){this.pagingNextList.forEach(e=>{this.onPagingNextClickListener.push({el:e,fn:()=>this.pagingNextClick()}),e.addEventListener("click",this.onPagingNextClickListener.find(t=>t.el===e).fn)})}onNextClick(){this.dataTable.page("next").draw("page")}buildPagingPages(){this.pagingPagesList.forEach(e=>this.updatePaging(e))}updatePaging(e){const{page:t,pages:i,length:s}=this.dataTable.page.info(),o=this.dataTable.rows({search:"applied"}).count(),l=Math.ceil(o/s),r=t+1;let a=Math.max(1,r-Math.floor(this.maxPagesToShow/2)),h=Math.min(l,a+(this.maxPagesToShow-1));h-a+11&&(this.buildPagingPage(1,e),a>2&&e.appendChild((0,n.htmlToElement)('...')));for(let t=a;t<=h;t++)this.buildPagingPage(t,e);h...')),this.buildPagingPage(l,e)),this.pagingPrevList.forEach(e=>this.disablePagingArrow(e,0===t)),this.pagingNextList.forEach(e=>this.disablePagingArrow(e,t===i-1)),this.pagingList.forEach(e=>this.hidePagingIfSinglePage(e))}buildPagingPage(e,t){const{page:i}=this.dataTable.page.info(),s=(0,n.htmlToElement)('');s.innerText=`${e}`,s.setAttribute("data-page",`${e}`),this.pageBtnClasses&&(0,n.classToClassList)(this.pageBtnClasses,s),i===e-1&&s.classList.add("active"),this.onSinglePagingClickListener.push({el:s,fn:()=>this.singlePagingClick(e)}),s.addEventListener("click",this.onSinglePagingClickListener.find(e=>e.el===s).fn),t.append(s)}onPageClick(e){this.dataTable.page(e-1).draw("page")}initRowSelecting(){this.onRowSelectingAllChangeListener=()=>this.rowSelectingAllChange(),this.rowSelectingAll.addEventListener("change",this.onRowSelectingAllChangeListener)}triggerChangeEventToRow(){this.table.querySelectorAll(`tbody ${this.rowSelectingIndividual}`).forEach(e=>{e.addEventListener("change",()=>{this.updateSelectAllCheckbox()})})}onSelectAllChange(){let e=this.rowSelectingAll.checked;Array.from(this.dataTable.rows({page:"current",search:"applied"}).nodes()).forEach(t=>{const i=t.querySelector(this.rowSelectingIndividual);i&&(i.checked=e)}),this.updateSelectAllCheckbox()}updateSelectAllCheckbox(){if(!this.dataTable.rows({search:"applied"}).count())return this.rowSelectingAll.checked=!1,!1;let e=!0;Array.from(this.dataTable.rows({page:"current",search:"applied"}).nodes()).forEach(t=>{const i=t.querySelector(this.rowSelectingIndividual);if(i&&!i.checked)return e=!1,!1}),this.rowSelectingAll.checked=e}destroy(){this.searches&&this.onSearchInputListener.forEach(({el:e,fn:t})=>e.removeEventListener("click",t)),this.pageEntitiesList&&this.onPageEntitiesChangeListener.forEach(({el:e,fn:t})=>e.removeEventListener("change",t)),this.pagingPagesList.length&&(this.onSinglePagingClickListener.forEach(({el:e,fn:t})=>e.removeEventListener("click",t)),this.pagingPagesList.forEach(e=>e.innerHTML="")),this.pagingPrevList.length&&this.onPagingPrevClickListener.forEach(({el:e,fn:t})=>e.removeEventListener("click",t)),this.pagingNextList.length&&this.onPagingNextClickListener.forEach(({el:e,fn:t})=>e.removeEventListener("click",t)),this.rowSelectingAll&&this.rowSelectingAll.removeEventListener("change",this.onRowSelectingAllChangeListener),this.dataTable.destroy(),this.rowSelectingAll=null,this.rowSelectingIndividual=null,window.$hsDataTableCollection=window.$hsDataTableCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsDataTableCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static autoInit(){window.$hsDataTableCollection||(window.$hsDataTableCollection=[]),window.$hsDataTableCollection&&(window.$hsDataTableCollection=window.$hsDataTableCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-datatable]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsDataTableCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}}window.addEventListener("load",()=>{document.querySelectorAll("[data-datatable]:not(.--prevent-on-load-init)").length&&("undefined"==typeof jQuery&&console.error("HSDataTable: jQuery is not available, please add it to the page."),"undefined"==typeof DataTable&&console.error("HSDataTable: DataTable is not available, please add it to the page.")),"undefined"!=typeof DataTable&&"undefined"!=typeof jQuery&&l.autoInit()}),"undefined"!=typeof window&&(window.HSDataTable=l),t.default=l},800:function(e,t,i){ +/* + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0}),t.HSRangeSlider=t.HSFileUpload=t.HSDataTable=t.HSStaticMethods=t.HSTreeView=t.HSTooltip=t.HSTogglePassword=t.HSToggleCount=t.HSTabs=t.HSStrongPassword=t.HSStepper=t.HSSelect=t.HSScrollspy=t.HSRemoveElement=t.HSPinInput=t.HSOverlay=t.HSInputNumber=t.HSDropdown=t.HSComboBox=t.HSCollapse=t.HSCarousel=t.HSAccordion=t.HSCopyMarkup=void 0;const n=s(i(294));let o,l,r;"undefined"!=typeof window&&(window.HSAccessibilityObserver=new n.default);var a=i(12);Object.defineProperty(t,"HSCopyMarkup",{enumerable:!0,get:function(){return s(a).default}});var h=i(306);Object.defineProperty(t,"HSAccordion",{enumerable:!0,get:function(){return s(h).default}});var d=i(302);Object.defineProperty(t,"HSCarousel",{enumerable:!0,get:function(){return s(d).default}});var c=i(763);Object.defineProperty(t,"HSCollapse",{enumerable:!0,get:function(){return s(c).default}});var u=i(75);Object.defineProperty(t,"HSComboBox",{enumerable:!0,get:function(){return s(u).default}});var p=i(753);Object.defineProperty(t,"HSDropdown",{enumerable:!0,get:function(){return s(p).default}});var m=i(86);Object.defineProperty(t,"HSInputNumber",{enumerable:!0,get:function(){return s(m).default}});var g=i(164);Object.defineProperty(t,"HSOverlay",{enumerable:!0,get:function(){return s(g).default}});var v=i(594);Object.defineProperty(t,"HSPinInput",{enumerable:!0,get:function(){return s(v).default}});var f=i(113);Object.defineProperty(t,"HSRemoveElement",{enumerable:!0,get:function(){return s(f).default}});var w=i(881);Object.defineProperty(t,"HSScrollspy",{enumerable:!0,get:function(){return s(w).default}});var y=i(956);Object.defineProperty(t,"HSSelect",{enumerable:!0,get:function(){return s(y).default}});var b=i(213);Object.defineProperty(t,"HSStepper",{enumerable:!0,get:function(){return s(b).default}});var C=i(207);Object.defineProperty(t,"HSStrongPassword",{enumerable:!0,get:function(){return s(C).default}});var S=i(944);Object.defineProperty(t,"HSTabs",{enumerable:!0,get:function(){return s(S).default}});var L=i(126);Object.defineProperty(t,"HSToggleCount",{enumerable:!0,get:function(){return s(L).default}});var I=i(234);Object.defineProperty(t,"HSTogglePassword",{enumerable:!0,get:function(){return s(I).default}});var E=i(487);Object.defineProperty(t,"HSTooltip",{enumerable:!0,get:function(){return s(E).default}});var T=i(782);Object.defineProperty(t,"HSTreeView",{enumerable:!0,get:function(){return s(T).default}});var x=i(319);if(Object.defineProperty(t,"HSStaticMethods",{enumerable:!0,get:function(){return s(x).default}}),"undefined"!=typeof window){try{"undefined"!=typeof DataTable&&"undefined"!=typeof jQuery&&(t.HSDataTable=o=i(792).default)}catch(e){console.warn("HSDataTable: Required dependencies not found"),t.HSDataTable=o=null}try{"undefined"!=typeof _&&"undefined"!=typeof Dropzone&&(t.HSFileUpload=l=i(192).default)}catch(e){console.warn("HSFileUpload: Required dependencies not found"),t.HSFileUpload=l=null}try{"undefined"!=typeof noUiSlider&&(t.HSRangeSlider=r=i(977).default)}catch(e){console.warn("HSRangeSlider: Required dependencies not found"),t.HSRangeSlider=r=null}}},806:function(e,t){ +/* + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +Object.defineProperty(t,"__esModule",{value:!0}),t.stringToBoolean=t.menuSearchHistory=t.isScrollable=t.isParentOrElementHidden=t.isJson=t.isIpadOS=t.isIOS=t.isFormElement=t.isFocused=t.isEnoughSpace=t.isDirectChild=t.htmlToElement=t.getZIndex=t.getHighestZIndex=t.getClassPropertyAlt=t.getClassProperty=t.dispatch=t.debounce=t.classToClassList=t.afterTransition=void 0;t.stringToBoolean=e=>"true"===e;t.getClassProperty=(e,t,i="")=>(window.getComputedStyle(e).getPropertyValue(t)||i).replace(" ","");t.getClassPropertyAlt=(e,t,i="")=>{let s="";return e.classList.forEach(e=>{e.includes(t)&&(s=e)}),s.match(/:(.*)]/)?s.match(/:(.*)]/)[1]:i};const i=e=>window.getComputedStyle(e).getPropertyValue("z-index");t.getZIndex=i;t.getHighestZIndex=e=>{let t=Number.NEGATIVE_INFINITY;return e.forEach(e=>{let s=i(e);"auto"!==s&&(s=parseInt(s,10),s>t&&(t=s))}),t};t.isDirectChild=(e,t)=>{const i=e.children;for(let e=0;e{const o=t.getBoundingClientRect(),l=n?n.getBoundingClientRect():null,r=window.innerHeight,a=l?o.top-l.top:o.top,h=(n?l.bottom:r)-o.bottom,d=e.clientHeight+s;return"bottom"===i?h>=d:"top"===i?a>=d:a>=d||h>=d};t.isFocused=e=>document.activeElement===e;t.isFormElement=e=>e instanceof HTMLInputElement||e instanceof HTMLTextAreaElement||e instanceof HTMLSelectElement;t.isIOS=()=>!!/iPad|iPhone|iPod/.test(navigator.platform)||navigator.maxTouchPoints&&navigator.maxTouchPoints>2&&/MacIntel/.test(navigator.platform);t.isIpadOS=()=>navigator.maxTouchPoints&&navigator.maxTouchPoints>2&&/MacIntel/.test(navigator.platform);t.isJson=e=>{if("string"!=typeof e)return!1;const t=e.trim()[0],i=e.trim().slice(-1);if("{"===t&&"}"===i||"["===t&&"]"===i)try{return JSON.parse(e),!0}catch(e){return!1}return!1};const s=e=>{if(!e)return!1;return"none"===window.getComputedStyle(e).display||s(e.parentElement)};t.isParentOrElementHidden=s;t.isScrollable=e=>{const t=window.getComputedStyle(e),i=t.overflowY,s=t.overflowX,n=("scroll"===i||"auto"===i)&&e.scrollHeight>e.clientHeight,o=("scroll"===s||"auto"===s)&&e.scrollWidth>e.clientWidth;return n||o};t.debounce=(e,t=200)=>{let i;return(...s)=>{clearTimeout(i),i=setTimeout(()=>{e.apply(this,s)},t)}};t.dispatch=(e,t,i=null)=>{const s=new CustomEvent(e,{detail:{payload:i},bubbles:!0,cancelable:!0,composed:!1});t.dispatchEvent(s)};t.afterTransition=(e,t)=>{const i=()=>{t(),e.removeEventListener("transitionend",i,!0)},s=window.getComputedStyle(e),n=s.getPropertyValue("transition-duration");"none"!==s.getPropertyValue("transition-property")&&parseFloat(n)>0?e.addEventListener("transitionend",i,!0):t()};t.htmlToElement=e=>{const t=document.createElement("template");return e=e.trim(),t.innerHTML=e,t.content.firstChild};t.classToClassList=(e,t,i=" ",s="add")=>{e.split(i).forEach(e=>{e.trim()&&("add"===s?t.classList.add(e):t.classList.remove(e))})};const n={historyIndex:-1,addHistory(e){this.historyIndex=e},existsInHistory(e){return e>this.historyIndex},clearHistory(){this.historyIndex=-1}};t.menuSearchHistory=n},881:function(e,t,i){ +/* + * HSScrollspy + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287));class l extends o.default{constructor(e,t={}){super(e,t),this.isScrollingDown=!1,this.lastScrollTop=0;const i=e.getAttribute("data-scrollspy-options"),s=i?JSON.parse(i):{},n=Object.assign(Object.assign({},s),t);this.ignoreScrollUp=void 0!==n.ignoreScrollUp&&n.ignoreScrollUp,this.links=this.el.querySelectorAll("[href]"),this.sections=[],this.scrollableId=this.el.getAttribute("data-scrollspy-scrollable-parent"),this.scrollable=this.scrollableId?document.querySelector(this.scrollableId):document,this.onLinkClickListener=[],this.init()}scrollableScroll(e){const t=this.scrollable instanceof HTMLElement?this.scrollable.scrollTop:window.scrollY;this.isScrollingDown=t>this.lastScrollTop,this.lastScrollTop=t<=0?0:t,Array.from(this.sections).forEach(t=>{if(!t.getAttribute("id"))return!1;this.update(e,t)})}init(){this.createCollection(window.$hsScrollspyCollection,this),this.links.forEach(e=>{this.sections.push(this.scrollable.querySelector(e.getAttribute("href")))}),this.onScrollableScrollListener=e=>this.scrollableScroll(e),this.scrollable.addEventListener("scroll",this.onScrollableScrollListener),this.links.forEach(e=>{this.onLinkClickListener.push({el:e,fn:t=>this.linkClick(t,e)}),e.addEventListener("click",this.onLinkClickListener.find(t=>t.el===e).fn)})}determineScrollDirection(e){const t=this.el.querySelector("a.active");if(!t)return!0;const i=Array.from(this.links).indexOf(t),s=Array.from(this.links).indexOf(e);return-1===s||s>i}linkClick(e,t){e.preventDefault();const i=t.getAttribute("href");if(!i||"javascript:;"===i)return;(i?document.querySelector(i):null)&&(this.isScrollingDown=this.determineScrollDirection(t),this.scrollTo(t))}update(e,t){const i=parseInt((0,n.getClassProperty)(this.el,"--scrollspy-offset","0")),s=parseInt((0,n.getClassProperty)(t,"--scrollspy-offset"))||i,o=e.target===document?0:parseInt(String(e.target.getBoundingClientRect().top)),l=parseInt(String(t.getBoundingClientRect().top))-s-o,r=t.offsetHeight;if(this.ignoreScrollUp||this.isScrollingDown?l<=0&&l+r>0:l<=0&&le.classList.remove("active"));const e=this.el.querySelector(`[href="#${t.getAttribute("id")}"]`);if(e){e.classList.add("active");const t=e.closest("[data-scrollspy-group]");if(t){const e=t.querySelector("[href]");e&&e.classList.add("active")}}this.fireEvent("afterScroll",e),(0,n.dispatch)("afterScroll.scrollspy",e,this.el)}}scrollTo(e){const t=e.getAttribute("href"),i=document.querySelector(t),s=parseInt((0,n.getClassProperty)(this.el,"--scrollspy-offset","0")),o=parseInt((0,n.getClassProperty)(i,"--scrollspy-offset"))||s,l=this.scrollable===document?0:this.scrollable.offsetTop,r=i.offsetTop-o-l,a=this.scrollable===document?window:this.scrollable,h=()=>{window.history.replaceState(null,null,e.getAttribute("href")),"scrollTo"in a&&a.scrollTo({top:r,left:0,behavior:"smooth"})},d=this.fireEvent("beforeScroll",this.el);(0,n.dispatch)("beforeScroll.scrollspy",this.el,this.el),d instanceof Promise?d.then(()=>h()):h()}destroy(){this.el.querySelector("[href].active").classList.remove("active"),this.scrollable.removeEventListener("scroll",this.onScrollableScrollListener),this.onLinkClickListener.length&&this.onLinkClickListener.forEach(({el:e,fn:t})=>{e.removeEventListener("click",t)}),window.$hsScrollspyCollection=window.$hsScrollspyCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t=!1){const i=window.$hsScrollspyCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static autoInit(){window.$hsScrollspyCollection||(window.$hsScrollspyCollection=[]),window.$hsScrollspyCollection&&(window.$hsScrollspyCollection=window.$hsScrollspyCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-scrollspy]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsScrollspyCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new l(e)})}}window.addEventListener("load",()=>{l.autoInit()}),"undefined"!=typeof window&&(window.HSScrollspy=l),t.default=l},917:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BREAKPOINTS=t.COMBO_BOX_ACCESSIBILITY_KEY_SET=t.SELECT_ACCESSIBILITY_KEY_SET=t.TABS_ACCESSIBILITY_KEY_SET=t.OVERLAY_ACCESSIBILITY_KEY_SET=t.DROPDOWN_ACCESSIBILITY_KEY_SET=t.POSITIONS=void 0,t.POSITIONS={auto:"auto","auto-start":"auto-start","auto-end":"auto-end",top:"top","top-start":"top-start","top-end":"top-end",bottom:"bottom","bottom-start":"bottom-start","bottom-end":"bottom-end",right:"right","right-start":"right-start","right-end":"right-end",left:"left","left-start":"left-start","left-end":"left-end"},t.DROPDOWN_ACCESSIBILITY_KEY_SET=["Escape","ArrowUp","ArrowDown","ArrowRight","ArrowLeft","Home","End","Enter"],t.OVERLAY_ACCESSIBILITY_KEY_SET=["Escape","Tab"],t.TABS_ACCESSIBILITY_KEY_SET=["ArrowUp","ArrowLeft","ArrowDown","ArrowRight","Home","End"],t.SELECT_ACCESSIBILITY_KEY_SET=["ArrowUp","ArrowLeft","ArrowDown","ArrowRight","Home","End","Escape","Enter","Space","Tab"],t.COMBO_BOX_ACCESSIBILITY_KEY_SET=["ArrowUp","ArrowLeft","ArrowDown","ArrowRight","Home","End","Escape","Enter"],t.BREAKPOINTS={xs:0,sm:640,md:768,lg:1024,xl:1280,"2xl":1536}},944:function(e,t,i){ +/* + * HSTabs + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=i(806),o=s(i(287)),l=s(i(294)),r=i(917);class a extends o.default{constructor(e,t,i){var s,n;super(e,t,i);const o=e.getAttribute("data-tabs"),l=o?JSON.parse(o):{},a=Object.assign(Object.assign({},l),t);this.eventType=null!==(s=a.eventType)&&void 0!==s?s:"click",this.preventNavigationResolution="number"==typeof a.preventNavigationResolution?a.preventNavigationResolution:r.BREAKPOINTS[a.preventNavigationResolution]||null,this.toggles=this.el.querySelectorAll("[data-tab]"),this.extraToggleId=this.el.getAttribute("data-tab-select"),this.extraToggle=this.extraToggleId?document.querySelector(this.extraToggleId):null,this.current=Array.from(this.toggles).find(e=>e.classList.contains("active")),this.currentContentId=(null===(n=this.current)||void 0===n?void 0:n.getAttribute("data-tab"))||null,this.currentContent=this.currentContentId?document.querySelector(this.currentContentId):null,this.prev=null,this.prevContentId=null,this.prevContent=null,this.onToggleHandler=[],this.init()}toggle(e){this.open(e)}extraToggleChange(e){this.change(e)}init(){this.createCollection(window.$hsTabsCollection,this),this.toggles.forEach(e=>{const t=t=>{"click"===this.eventType&&this.preventNavigationResolution&&document.body.clientWidth<=+this.preventNavigationResolution&&t.preventDefault(),this.toggle(e)},i=e=>{this.preventNavigationResolution&&document.body.clientWidth<=+this.preventNavigationResolution&&e.preventDefault()};this.onToggleHandler.push({el:e,fn:t,preventClickFn:i}),"click"===this.eventType?e.addEventListener("click",t):(e.addEventListener("mouseenter",t),e.addEventListener("click",i))}),this.extraToggle&&(this.onExtraToggleChangeListener=e=>this.extraToggleChange(e),this.extraToggle.addEventListener("change",this.onExtraToggleChangeListener)),"undefined"!=typeof window&&(window.HSAccessibilityObserver||(window.HSAccessibilityObserver=new l.default),this.setupAccessibility())}open(e){var t,i,s,o,l;this.prev=this.current,this.prevContentId=this.currentContentId,this.prevContent=this.currentContent,this.current=e,this.currentContentId=e.getAttribute("data-tab"),this.currentContent=this.currentContentId?document.querySelector(this.currentContentId):null,(null===(t=null==this?void 0:this.prev)||void 0===t?void 0:t.ariaSelected)&&(this.prev.ariaSelected="false"),null===(i=this.prev)||void 0===i||i.classList.remove("active"),null===(s=this.prevContent)||void 0===s||s.classList.add("hidden"),(null===(o=null==this?void 0:this.current)||void 0===o?void 0:o.ariaSelected)&&(this.current.ariaSelected="true"),this.current.classList.add("active"),null===(l=this.currentContent)||void 0===l||l.classList.remove("hidden"),this.fireEvent("change",{el:e,prev:this.prevContentId,current:this.currentContentId,tabsId:this.el.id}),(0,n.dispatch)("change.tab",e,{el:e,prev:this.prevContentId,current:this.currentContentId,tabsId:this.el.id})}change(e){const t=document.querySelector(`[data-tab="${e.target.value}"]`);t&&("hover"===this.eventType?t.dispatchEvent(new Event("mouseenter")):t.click())}setupAccessibility(){this.accessibilityComponent=window.HSAccessibilityObserver.registerComponent(this.el,{onArrow:e=>{if(e.metaKey)return;const t="true"===this.el.getAttribute("data-tabs-vertical")||"vertical"===this.el.getAttribute("aria-orientation");switch(e.key){case t?"ArrowUp":"ArrowLeft":this.onArrow(!0);break;case t?"ArrowDown":"ArrowRight":this.onArrow(!1);break;case"Home":this.onStartEnd(!0);break;case"End":this.onStartEnd(!1)}}},!0,"Tabs",'[role="tablist"]')}onArrow(e=!0){const t=e?Array.from(this.toggles).reverse():Array.from(this.toggles),i=t.find(e=>document.activeElement===e);let s=t.findIndex(e=>e===i);s=s+1{var t;const i=null===(t=this.onToggleHandler)||void 0===t?void 0:t.find(({el:t})=>t===e);i&&("click"===this.eventType?e.removeEventListener("click",i.fn):(e.removeEventListener("mouseenter",i.fn),e.removeEventListener("click",i.preventClickFn)))}),this.onToggleHandler=[],this.extraToggle&&this.extraToggle.removeEventListener("change",this.onExtraToggleChangeListener),"undefined"!=typeof window&&window.HSAccessibilityObserver&&window.HSAccessibilityObserver.unregisterComponent(this.accessibilityComponent),window.$hsTabsCollection=window.$hsTabsCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t){const i=window.$hsTabsCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsTabsCollection||(window.$hsTabsCollection=[]),window.$hsTabsCollection&&(window.$hsTabsCollection=window.$hsTabsCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll('[role="tablist"]:not(select):not(.--prevent-on-load-init)').forEach(e=>{window.$hsTabsCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new a(e)})}static open(e){const t=window.$hsTabsCollection.find(t=>Array.from(t.element.toggles).includes("string"==typeof e?document.querySelector(e):e)),i=t?Array.from(t.element.toggles).find(t=>t===("string"==typeof e?document.querySelector(e):e)):null;i&&!i.classList.contains("active")&&t.element.open(i)}static on(e,t,i){const s=window.$hsTabsCollection.find(e=>Array.from(e.element.toggles).includes("string"==typeof t?document.querySelector(t):t));s&&(s.element.events[e]=i)}}window.addEventListener("load",()=>{a.autoInit()}),"undefined"!=typeof window&&(window.HSTabs=a),t.default=a},949:(e,t,i)=>{i.r(t),i.d(t,{arrow:()=>Ne,autoPlacement:()=>Pe,autoUpdate:()=>ke,computePosition:()=>Fe,detectOverflow:()=>Ae,flip:()=>$e,getOverflowAncestors:()=>he,hide:()=>De,inline:()=>He,limitShift:()=>qe,offset:()=>Oe,platform:()=>Te,shift:()=>Be,size:()=>Me});const s=["top","right","bottom","left"],n=["start","end"],o=s.reduce((e,t)=>e.concat(t,t+"-"+n[0],t+"-"+n[1]),[]),l=Math.min,r=Math.max,a=Math.round,h=Math.floor,d=e=>({x:e,y:e}),c={left:"right",right:"left",bottom:"top",top:"bottom"},u={start:"end",end:"start"};function p(e,t,i){return r(e,l(t,i))}function m(e,t){return"function"==typeof e?e(t):e}function g(e){return e.split("-")[0]}function v(e){return e.split("-")[1]}function f(e){return"x"===e?"y":"x"}function w(e){return"y"===e?"height":"width"}const y=new Set(["top","bottom"]);function b(e){return y.has(g(e))?"y":"x"}function C(e){return f(b(e))}function S(e,t,i){void 0===i&&(i=!1);const s=v(e),n=C(e),o=w(n);let l="x"===n?s===(i?"end":"start")?"right":"left":"start"===s?"bottom":"top";return t.reference[o]>t.floating[o]&&(l=A(l)),[l,A(l)]}function L(e){return e.replace(/start|end/g,e=>u[e])}const I=["left","right"],E=["right","left"],T=["top","bottom"],x=["bottom","top"];function k(e,t,i,s){const n=v(e);let o=function(e,t,i){switch(e){case"top":case"bottom":return i?t?E:I:t?I:E;case"left":case"right":return t?T:x;default:return[]}}(g(e),"start"===i,s);return n&&(o=o.map(e=>e+"-"+n),t&&(o=o.concat(o.map(L)))),o}function A(e){return e.replace(/left|right|bottom|top/g,e=>c[e])}function O(e){return"number"!=typeof e?function(e){return{top:0,right:0,bottom:0,left:0,...e}}(e):{top:e,right:e,bottom:e,left:e}}function P(e){const{x:t,y:i,width:s,height:n}=e;return{width:s,height:n,top:i,left:t,right:t+s,bottom:i+n,x:t,y:i}}function B(e,t,i){let{reference:s,floating:n}=e;const o=b(t),l=C(t),r=w(l),a=g(t),h="y"===o,d=s.x+s.width/2-n.width/2,c=s.y+s.height/2-n.height/2,u=s[r]/2-n[r]/2;let p;switch(a){case"top":p={x:d,y:s.y-n.height};break;case"bottom":p={x:d,y:s.y+s.height};break;case"right":p={x:s.x+s.width,y:c};break;case"left":p={x:s.x-n.width,y:c};break;default:p={x:s.x,y:s.y}}switch(v(t)){case"start":p[l]-=u*(i&&h?-1:1);break;case"end":p[l]+=u*(i&&h?-1:1)}return p}async function $(e,t){var i;void 0===t&&(t={});const{x:s,y:n,platform:o,rects:l,elements:r,strategy:a}=e,{boundary:h="clippingAncestors",rootBoundary:d="viewport",elementContext:c="floating",altBoundary:u=!1,padding:p=0}=m(t,e),g=O(p),v=r[u?"floating"===c?"reference":"floating":c],f=P(await o.getClippingRect({element:null==(i=await(null==o.isElement?void 0:o.isElement(v)))||i?v:v.contextElement||await(null==o.getDocumentElement?void 0:o.getDocumentElement(r.floating)),boundary:h,rootBoundary:d,strategy:a})),w="floating"===c?{x:s,y:n,width:l.floating.width,height:l.floating.height}:l.reference,y=await(null==o.getOffsetParent?void 0:o.getOffsetParent(r.floating)),b=await(null==o.isElement?void 0:o.isElement(y))&&await(null==o.getScale?void 0:o.getScale(y))||{x:1,y:1},C=P(o.convertOffsetParentRelativeRectToViewportRelativeRect?await o.convertOffsetParentRelativeRectToViewportRelativeRect({elements:r,rect:w,offsetParent:y,strategy:a}):w);return{top:(f.top-C.top+g.top)/b.y,bottom:(C.bottom-f.bottom+g.bottom)/b.y,left:(f.left-C.left+g.left)/b.x,right:(C.right-f.right+g.right)/b.x}}function M(e,t){return{top:e.top-t.height,right:e.right-t.width,bottom:e.bottom-t.height,left:e.left-t.width}}function D(e){return s.some(t=>e[t]>=0)}function N(e){const t=l(...e.map(e=>e.left)),i=l(...e.map(e=>e.top));return{x:t,y:i,width:r(...e.map(e=>e.right))-t,height:r(...e.map(e=>e.bottom))-i}}const H=new Set(["left","top"]);function q(){return"undefined"!=typeof window}function F(e){return V(e)?(e.nodeName||"").toLowerCase():"#document"}function R(e){var t;return(null==e||null==(t=e.ownerDocument)?void 0:t.defaultView)||window}function _(e){var t;return null==(t=(V(e)?e.ownerDocument:e.document)||window.document)?void 0:t.documentElement}function V(e){return!!q()&&(e instanceof Node||e instanceof R(e).Node)}function U(e){return!!q()&&(e instanceof Element||e instanceof R(e).Element)}function W(e){return!!q()&&(e instanceof HTMLElement||e instanceof R(e).HTMLElement)}function j(e){return!(!q()||"undefined"==typeof ShadowRoot)&&(e instanceof ShadowRoot||e instanceof R(e).ShadowRoot)}const z=new Set(["inline","contents"]);function Q(e){const{overflow:t,overflowX:i,overflowY:s,display:n}=oe(e);return/auto|scroll|overlay|hidden|clip/.test(t+s+i)&&!z.has(n)}const K=new Set(["table","td","th"]);function J(e){return K.has(F(e))}const Z=[":popover-open",":modal"];function X(e){return Z.some(t=>{try{return e.matches(t)}catch(e){return!1}})}const Y=["transform","translate","scale","rotate","perspective"],G=["transform","translate","scale","rotate","perspective","filter"],ee=["paint","layout","strict","content"];function te(e){const t=ie(),i=U(e)?oe(e):e;return Y.some(e=>!!i[e]&&"none"!==i[e])||!!i.containerType&&"normal"!==i.containerType||!t&&!!i.backdropFilter&&"none"!==i.backdropFilter||!t&&!!i.filter&&"none"!==i.filter||G.some(e=>(i.willChange||"").includes(e))||ee.some(e=>(i.contain||"").includes(e))}function ie(){return!("undefined"==typeof CSS||!CSS.supports)&&CSS.supports("-webkit-backdrop-filter","none")}const se=new Set(["html","body","#document"]);function ne(e){return se.has(F(e))}function oe(e){return R(e).getComputedStyle(e)}function le(e){return U(e)?{scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}:{scrollLeft:e.scrollX,scrollTop:e.scrollY}}function re(e){if("html"===F(e))return e;const t=e.assignedSlot||e.parentNode||j(e)&&e.host||_(e);return j(t)?t.host:t}function ae(e){const t=re(e);return ne(t)?e.ownerDocument?e.ownerDocument.body:e.body:W(t)&&Q(t)?t:ae(t)}function he(e,t,i){var s;void 0===t&&(t=[]),void 0===i&&(i=!0);const n=ae(e),o=n===(null==(s=e.ownerDocument)?void 0:s.body),l=R(n);if(o){const e=de(l);return t.concat(l,l.visualViewport||[],Q(n)?n:[],e&&i?he(e):[])}return t.concat(n,he(n,[],i))}function de(e){return e.parent&&Object.getPrototypeOf(e.parent)?e.frameElement:null}function ce(e){const t=oe(e);let i=parseFloat(t.width)||0,s=parseFloat(t.height)||0;const n=W(e),o=n?e.offsetWidth:i,l=n?e.offsetHeight:s,r=a(i)!==o||a(s)!==l;return r&&(i=o,s=l),{width:i,height:s,$:r}}function ue(e){return U(e)?e:e.contextElement}function pe(e){const t=ue(e);if(!W(t))return d(1);const i=t.getBoundingClientRect(),{width:s,height:n,$:o}=ce(t);let l=(o?a(i.width):i.width)/s,r=(o?a(i.height):i.height)/n;return l&&Number.isFinite(l)||(l=1),r&&Number.isFinite(r)||(r=1),{x:l,y:r}}const me=d(0);function ge(e){const t=R(e);return ie()&&t.visualViewport?{x:t.visualViewport.offsetLeft,y:t.visualViewport.offsetTop}:me}function ve(e,t,i,s){void 0===t&&(t=!1),void 0===i&&(i=!1);const n=e.getBoundingClientRect(),o=ue(e);let l=d(1);t&&(s?U(s)&&(l=pe(s)):l=pe(e));const r=function(e,t,i){return void 0===t&&(t=!1),!(!i||t&&i!==R(e))&&t}(o,i,s)?ge(o):d(0);let a=(n.left+r.x)/l.x,h=(n.top+r.y)/l.y,c=n.width/l.x,u=n.height/l.y;if(o){const e=R(o),t=s&&U(s)?R(s):s;let i=e,n=de(i);for(;n&&s&&t!==i;){const e=pe(n),t=n.getBoundingClientRect(),s=oe(n),o=t.left+(n.clientLeft+parseFloat(s.paddingLeft))*e.x,l=t.top+(n.clientTop+parseFloat(s.paddingTop))*e.y;a*=e.x,h*=e.y,c*=e.x,u*=e.y,a+=o,h+=l,i=R(n),n=de(i)}}return P({width:c,height:u,x:a,y:h})}function fe(e,t){const i=le(e).scrollLeft;return t?t.left+i:ve(_(e)).left+i}function we(e,t){const i=e.getBoundingClientRect();return{x:i.left+t.scrollLeft-fe(e,i),y:i.top+t.scrollTop}}const ye=new Set(["absolute","fixed"]);function be(e,t,i){let s;if("viewport"===t)s=function(e,t){const i=R(e),s=_(e),n=i.visualViewport;let o=s.clientWidth,l=s.clientHeight,r=0,a=0;if(n){o=n.width,l=n.height;const e=ie();(!e||e&&"fixed"===t)&&(r=n.offsetLeft,a=n.offsetTop)}const h=fe(s);if(h<=0){const e=s.ownerDocument,t=e.body,i=getComputedStyle(t),n="CSS1Compat"===e.compatMode&&parseFloat(i.marginLeft)+parseFloat(i.marginRight)||0,l=Math.abs(s.clientWidth-t.clientWidth-n);l<=25&&(o-=l)}else h<=25&&(o+=h);return{width:o,height:l,x:r,y:a}}(e,i);else if("document"===t)s=function(e){const t=_(e),i=le(e),s=e.ownerDocument.body,n=r(t.scrollWidth,t.clientWidth,s.scrollWidth,s.clientWidth),o=r(t.scrollHeight,t.clientHeight,s.scrollHeight,s.clientHeight);let l=-i.scrollLeft+fe(e);const a=-i.scrollTop;return"rtl"===oe(s).direction&&(l+=r(t.clientWidth,s.clientWidth)-n),{width:n,height:o,x:l,y:a}}(_(e));else if(U(t))s=function(e,t){const i=ve(e,!0,"fixed"===t),s=i.top+e.clientTop,n=i.left+e.clientLeft,o=W(e)?pe(e):d(1);return{width:e.clientWidth*o.x,height:e.clientHeight*o.y,x:n*o.x,y:s*o.y}}(t,i);else{const i=ge(e);s={x:t.x-i.x,y:t.y-i.y,width:t.width,height:t.height}}return P(s)}function Ce(e,t){const i=re(e);return!(i===t||!U(i)||ne(i))&&("fixed"===oe(i).position||Ce(i,t))}function Se(e,t,i){const s=W(t),n=_(t),o="fixed"===i,l=ve(e,!0,o,t);let r={scrollLeft:0,scrollTop:0};const a=d(0);function h(){a.x=fe(n)}if(s||!s&&!o)if(("body"!==F(t)||Q(n))&&(r=le(t)),s){const e=ve(t,!0,o,t);a.x=e.x+t.clientLeft,a.y=e.y+t.clientTop}else n&&h();o&&!s&&n&&h();const c=!n||s||o?d(0):we(n,r);return{x:l.left+r.scrollLeft-a.x-c.x,y:l.top+r.scrollTop-a.y-c.y,width:l.width,height:l.height}}function Le(e){return"static"===oe(e).position}function Ie(e,t){if(!W(e)||"fixed"===oe(e).position)return null;if(t)return t(e);let i=e.offsetParent;return _(e)===i&&(i=i.ownerDocument.body),i}function Ee(e,t){const i=R(e);if(X(e))return i;if(!W(e)){let t=re(e);for(;t&&!ne(t);){if(U(t)&&!Le(t))return t;t=re(t)}return i}let s=Ie(e,t);for(;s&&J(s)&&Le(s);)s=Ie(s,t);return s&&ne(s)&&Le(s)&&!te(s)?i:s||function(e){let t=re(e);for(;W(t)&&!ne(t);){if(te(t))return t;if(X(t))return null;t=re(t)}return null}(e)||i}const Te={convertOffsetParentRelativeRectToViewportRelativeRect:function(e){let{elements:t,rect:i,offsetParent:s,strategy:n}=e;const o="fixed"===n,l=_(s),r=!!t&&X(t.floating);if(s===l||r&&o)return i;let a={scrollLeft:0,scrollTop:0},h=d(1);const c=d(0),u=W(s);if((u||!u&&!o)&&(("body"!==F(s)||Q(l))&&(a=le(s)),W(s))){const e=ve(s);h=pe(s),c.x=e.x+s.clientLeft,c.y=e.y+s.clientTop}const p=!l||u||o?d(0):we(l,a);return{width:i.width*h.x,height:i.height*h.y,x:i.x*h.x-a.scrollLeft*h.x+c.x+p.x,y:i.y*h.y-a.scrollTop*h.y+c.y+p.y}},getDocumentElement:_,getClippingRect:function(e){let{element:t,boundary:i,rootBoundary:s,strategy:n}=e;const o=[..."clippingAncestors"===i?X(t)?[]:function(e,t){const i=t.get(e);if(i)return i;let s=he(e,[],!1).filter(e=>U(e)&&"body"!==F(e)),n=null;const o="fixed"===oe(e).position;let l=o?re(e):e;for(;U(l)&&!ne(l);){const t=oe(l),i=te(l);i||"fixed"!==t.position||(n=null),(o?!i&&!n:!i&&"static"===t.position&&n&&ye.has(n.position)||Q(l)&&!i&&Ce(e,l))?s=s.filter(e=>e!==l):n=t,l=re(l)}return t.set(e,s),s}(t,this._c):[].concat(i),s],a=o[0],h=o.reduce((e,i)=>{const s=be(t,i,n);return e.top=r(s.top,e.top),e.right=l(s.right,e.right),e.bottom=l(s.bottom,e.bottom),e.left=r(s.left,e.left),e},be(t,a,n));return{width:h.right-h.left,height:h.bottom-h.top,x:h.left,y:h.top}},getOffsetParent:Ee,getElementRects:async function(e){const t=this.getOffsetParent||Ee,i=this.getDimensions,s=await i(e.floating);return{reference:Se(e.reference,await t(e.floating),e.strategy),floating:{x:0,y:0,width:s.width,height:s.height}}},getClientRects:function(e){return Array.from(e.getClientRects())},getDimensions:function(e){const{width:t,height:i}=ce(e);return{width:t,height:i}},getScale:pe,isElement:U,isRTL:function(e){return"rtl"===oe(e).direction}};function xe(e,t){return e.x===t.x&&e.y===t.y&&e.width===t.width&&e.height===t.height}function ke(e,t,i,s){void 0===s&&(s={});const{ancestorScroll:n=!0,ancestorResize:o=!0,elementResize:a="function"==typeof ResizeObserver,layoutShift:d="function"==typeof IntersectionObserver,animationFrame:c=!1}=s,u=ue(e),p=n||o?[...u?he(u):[],...he(t)]:[];p.forEach(e=>{n&&e.addEventListener("scroll",i,{passive:!0}),o&&e.addEventListener("resize",i)});const m=u&&d?function(e,t){let i,s=null;const n=_(e);function o(){var e;clearTimeout(i),null==(e=s)||e.disconnect(),s=null}return function a(d,c){void 0===d&&(d=!1),void 0===c&&(c=1),o();const u=e.getBoundingClientRect(),{left:p,top:m,width:g,height:v}=u;if(d||t(),!g||!v)return;const f={rootMargin:-h(m)+"px "+-h(n.clientWidth-(p+g))+"px "+-h(n.clientHeight-(m+v))+"px "+-h(p)+"px",threshold:r(0,l(1,c))||1};let w=!0;function y(t){const s=t[0].intersectionRatio;if(s!==c){if(!w)return a();s?a(!1,s):i=setTimeout(()=>{a(!1,1e-7)},1e3)}1!==s||xe(u,e.getBoundingClientRect())||a(),w=!1}try{s=new IntersectionObserver(y,{...f,root:n.ownerDocument})}catch(e){s=new IntersectionObserver(y,f)}s.observe(e)}(!0),o}(u,i):null;let g,v=-1,f=null;a&&(f=new ResizeObserver(e=>{let[s]=e;s&&s.target===u&&f&&(f.unobserve(t),cancelAnimationFrame(v),v=requestAnimationFrame(()=>{var e;null==(e=f)||e.observe(t)})),i()}),u&&!c&&f.observe(u),f.observe(t));let w=c?ve(e):null;return c&&function t(){const s=ve(e);w&&!xe(w,s)&&i();w=s,g=requestAnimationFrame(t)}(),i(),()=>{var e;p.forEach(e=>{n&&e.removeEventListener("scroll",i),o&&e.removeEventListener("resize",i)}),null==m||m(),null==(e=f)||e.disconnect(),f=null,c&&cancelAnimationFrame(g)}}const Ae=$,Oe=function(e){return void 0===e&&(e=0),{name:"offset",options:e,async fn(t){var i,s;const{x:n,y:o,placement:l,middlewareData:r}=t,a=await async function(e,t){const{placement:i,platform:s,elements:n}=e,o=await(null==s.isRTL?void 0:s.isRTL(n.floating)),l=g(i),r=v(i),a="y"===b(i),h=H.has(l)?-1:1,d=o&&a?-1:1,c=m(t,e);let{mainAxis:u,crossAxis:p,alignmentAxis:f}="number"==typeof c?{mainAxis:c,crossAxis:0,alignmentAxis:null}:{mainAxis:c.mainAxis||0,crossAxis:c.crossAxis||0,alignmentAxis:c.alignmentAxis};return r&&"number"==typeof f&&(p="end"===r?-1*f:f),a?{x:p*d,y:u*h}:{x:u*h,y:p*d}}(t,e);return l===(null==(i=r.offset)?void 0:i.placement)&&null!=(s=r.arrow)&&s.alignmentOffset?{}:{x:n+a.x,y:o+a.y,data:{...a,placement:l}}}}},Pe=function(e){return void 0===e&&(e={}),{name:"autoPlacement",options:e,async fn(t){var i,s,n;const{rects:l,middlewareData:r,placement:a,platform:h,elements:d}=t,{crossAxis:c=!1,alignment:u,allowedPlacements:p=o,autoAlignment:f=!0,...w}=m(e,t),y=void 0!==u||p===o?function(e,t,i){return(e?[...i.filter(t=>v(t)===e),...i.filter(t=>v(t)!==e)]:i.filter(e=>g(e)===e)).filter(i=>!e||v(i)===e||!!t&&L(i)!==i)}(u||null,f,p):p,b=await $(t,w),C=(null==(i=r.autoPlacement)?void 0:i.index)||0,I=y[C];if(null==I)return{};const E=S(I,l,await(null==h.isRTL?void 0:h.isRTL(d.floating)));if(a!==I)return{reset:{placement:y[0]}};const T=[b[g(I)],b[E[0]],b[E[1]]],x=[...(null==(s=r.autoPlacement)?void 0:s.overflows)||[],{placement:I,overflows:T}],k=y[C+1];if(k)return{data:{index:C+1,overflows:x},reset:{placement:k}};const A=x.map(e=>{const t=v(e.placement);return[e.placement,t&&c?e.overflows.slice(0,2).reduce((e,t)=>e+t,0):e.overflows[0],e.overflows]}).sort((e,t)=>e[1]-t[1]),O=(null==(n=A.filter(e=>e[2].slice(0,v(e[0])?2:3).every(e=>e<=0))[0])?void 0:n[0])||A[0][0];return O!==a?{data:{index:C+1,overflows:x},reset:{placement:O}}:{}}}},Be=function(e){return void 0===e&&(e={}),{name:"shift",options:e,async fn(t){const{x:i,y:s,placement:n}=t,{mainAxis:o=!0,crossAxis:l=!1,limiter:r={fn:e=>{let{x:t,y:i}=e;return{x:t,y:i}}},...a}=m(e,t),h={x:i,y:s},d=await $(t,a),c=b(g(n)),u=f(c);let v=h[u],w=h[c];if(o){const e="y"===u?"bottom":"right";v=p(v+d["y"===u?"top":"left"],v,v-d[e])}if(l){const e="y"===c?"bottom":"right";w=p(w+d["y"===c?"top":"left"],w,w-d[e])}const y=r.fn({...t,[u]:v,[c]:w});return{...y,data:{x:y.x-i,y:y.y-s,enabled:{[u]:o,[c]:l}}}}}},$e=function(e){return void 0===e&&(e={}),{name:"flip",options:e,async fn(t){var i,s;const{placement:n,middlewareData:o,rects:l,initialPlacement:r,platform:a,elements:h}=t,{mainAxis:d=!0,crossAxis:c=!0,fallbackPlacements:u,fallbackStrategy:p="bestFit",fallbackAxisSideDirection:v="none",flipAlignment:f=!0,...w}=m(e,t);if(null!=(i=o.arrow)&&i.alignmentOffset)return{};const y=g(n),C=b(r),I=g(r)===r,E=await(null==a.isRTL?void 0:a.isRTL(h.floating)),T=u||(I||!f?[A(r)]:function(e){const t=A(e);return[L(e),t,L(t)]}(r)),x="none"!==v;!u&&x&&T.push(...k(r,f,v,E));const O=[r,...T],P=await $(t,w),B=[];let M=(null==(s=o.flip)?void 0:s.overflows)||[];if(d&&B.push(P[y]),c){const e=S(n,l,E);B.push(P[e[0]],P[e[1]])}if(M=[...M,{placement:n,overflows:B}],!B.every(e=>e<=0)){var D,N;const e=((null==(D=o.flip)?void 0:D.index)||0)+1,t=O[e];if(t){if(!("alignment"===c&&C!==b(t))||M.every(e=>b(e.placement)!==C||e.overflows[0]>0))return{data:{index:e,overflows:M},reset:{placement:t}}}let i=null==(N=M.filter(e=>e.overflows[0]<=0).sort((e,t)=>e.overflows[1]-t.overflows[1])[0])?void 0:N.placement;if(!i)switch(p){case"bestFit":{var H;const e=null==(H=M.filter(e=>{if(x){const t=b(e.placement);return t===C||"y"===t}return!0}).map(e=>[e.placement,e.overflows.filter(e=>e>0).reduce((e,t)=>e+t,0)]).sort((e,t)=>e[1]-t[1])[0])?void 0:H[0];e&&(i=e);break}case"initialPlacement":i=r}if(n!==i)return{reset:{placement:i}}}return{}}}},Me=function(e){return void 0===e&&(e={}),{name:"size",options:e,async fn(t){var i,s;const{placement:n,rects:o,platform:a,elements:h}=t,{apply:d=()=>{},...c}=m(e,t),u=await $(t,c),p=g(n),f=v(n),w="y"===b(n),{width:y,height:C}=o.floating;let S,L;"top"===p||"bottom"===p?(S=p,L=f===(await(null==a.isRTL?void 0:a.isRTL(h.floating))?"start":"end")?"left":"right"):(L=p,S="end"===f?"top":"bottom");const I=C-u.top-u.bottom,E=y-u.left-u.right,T=l(C-u[S],I),x=l(y-u[L],E),k=!t.middlewareData.shift;let A=T,O=x;if(null!=(i=t.middlewareData.shift)&&i.enabled.x&&(O=E),null!=(s=t.middlewareData.shift)&&s.enabled.y&&(A=I),k&&!f){const e=r(u.left,0),t=r(u.right,0),i=r(u.top,0),s=r(u.bottom,0);w?O=y-2*(0!==e||0!==t?e+t:r(u.left,u.right)):A=C-2*(0!==i||0!==s?i+s:r(u.top,u.bottom))}await d({...t,availableWidth:O,availableHeight:A});const P=await a.getDimensions(h.floating);return y!==P.width||C!==P.height?{reset:{rects:!0}}:{}}}},De=function(e){return void 0===e&&(e={}),{name:"hide",options:e,async fn(t){const{rects:i}=t,{strategy:s="referenceHidden",...n}=m(e,t);switch(s){case"referenceHidden":{const e=M(await $(t,{...n,elementContext:"reference"}),i.reference);return{data:{referenceHiddenOffsets:e,referenceHidden:D(e)}}}case"escaped":{const e=M(await $(t,{...n,altBoundary:!0}),i.floating);return{data:{escapedOffsets:e,escaped:D(e)}}}default:return{}}}}},Ne=e=>({name:"arrow",options:e,async fn(t){const{x:i,y:s,placement:n,rects:o,platform:r,elements:a,middlewareData:h}=t,{element:d,padding:c=0}=m(e,t)||{};if(null==d)return{};const u=O(c),g={x:i,y:s},f=C(n),y=w(f),b=await r.getDimensions(d),S="y"===f,L=S?"top":"left",I=S?"bottom":"right",E=S?"clientHeight":"clientWidth",T=o.reference[y]+o.reference[f]-g[f]-o.floating[y],x=g[f]-o.reference[f],k=await(null==r.getOffsetParent?void 0:r.getOffsetParent(d));let A=k?k[E]:0;A&&await(null==r.isElement?void 0:r.isElement(k))||(A=a.floating[E]||o.floating[y]);const P=T/2-x/2,B=A/2-b[y]/2-1,$=l(u[L],B),M=l(u[I],B),D=$,N=A-b[y]-M,H=A/2-b[y]/2+P,q=p(D,H,N),F=!h.arrow&&null!=v(n)&&H!==q&&o.reference[y]/2-(He.y-t.y),i=[];let s=null;for(let e=0;es.height/2?i.push([n]):i[i.length-1].push(n),s=n}return i.map(e=>P(N(e)))}(u),v=P(N(u)),f=O(h);const w=await o.getElementRects({reference:{getBoundingClientRect:function(){if(2===p.length&&p[0].left>p[1].right&&null!=d&&null!=c)return p.find(e=>d>e.left-f.left&&de.top-f.top&&c=2){if("y"===b(i)){const e=p[0],t=p[p.length-1],s="top"===g(i),n=e.top,o=t.bottom,l=s?e.left:t.left,r=s?e.right:t.right;return{top:n,bottom:o,left:l,right:r,width:r-l,height:o-n,x:l,y:n}}const e="left"===g(i),t=r(...p.map(e=>e.right)),s=l(...p.map(e=>e.left)),n=p.filter(i=>e?i.left===s:i.right===t),o=n[0].top,a=n[n.length-1].bottom;return{top:o,bottom:a,left:s,right:t,width:t-s,height:a-o,x:s,y:o}}return v}},floating:s.floating,strategy:a});return n.reference.x!==w.reference.x||n.reference.y!==w.reference.y||n.reference.width!==w.reference.width||n.reference.height!==w.reference.height?{reset:{rects:w}}:{}}}},qe=function(e){return void 0===e&&(e={}),{options:e,fn(t){const{x:i,y:s,placement:n,rects:o,middlewareData:l}=t,{offset:r=0,mainAxis:a=!0,crossAxis:h=!0}=m(e,t),d={x:i,y:s},c=b(n),u=f(c);let p=d[u],v=d[c];const w=m(r,t),y="number"==typeof w?{mainAxis:w,crossAxis:0}:{mainAxis:0,crossAxis:0,...w};if(a){const e="y"===u?"height":"width",t=o.reference[u]-o.floating[e]+y.mainAxis,i=o.reference[u]+o.reference[e]-y.mainAxis;pi&&(p=i)}if(h){var C,S;const e="y"===u?"width":"height",t=H.has(g(n)),i=o.reference[c]-o.floating[e]+(t&&(null==(C=l.offset)?void 0:C[c])||0)+(t?0:y.crossAxis),s=o.reference[c]+o.reference[e]+(t?0:(null==(S=l.offset)?void 0:S[c])||0)-(t?y.crossAxis:0);vs&&(v=s)}return{[u]:p,[c]:v}}}},Fe=(e,t,i)=>{const s=new Map,n={platform:Te,...i},o={...n.platform,_c:s};return(async(e,t,i)=>{const{placement:s="bottom",strategy:n="absolute",middleware:o=[],platform:l}=i,r=o.filter(Boolean),a=await(null==l.isRTL?void 0:l.isRTL(t));let h=await l.getElementRects({reference:e,floating:t,strategy:n}),{x:d,y:c}=B(h,s,a),u=s,p={},m=0;for(let i=0;i",this.searchNoResultText=(null==h?void 0:h.searchNoResultText)||"No results found",this.searchNoResultClasses=(null==h?void 0:h.searchNoResultClasses)||"block advance-select-option",this.optionAllowEmptyOption=void 0!==(null==h?void 0:h.optionAllowEmptyOption)&&(null==h?void 0:h.optionAllowEmptyOption),this.optionTemplate=(null==h?void 0:h.optionTemplate)||null,this.optionTag=(null==h?void 0:h.optionTag)||null,this.optionClasses=(null==h?void 0:h.optionClasses)||null,this.extraMarkup=(null==h?void 0:h.extraMarkup)||null,this.descriptionClasses=(null==h?void 0:h.descriptionClasses)||null,this.iconClasses=(null==h?void 0:h.iconClasses)||null,this.isAddTagOnEnter=null===(o=null==h?void 0:h.isAddTagOnEnter)||void 0===o||o,this.isSelectedOptionOnTop=null!==(l=null==h?void 0:h.isSelectedOptionOnTop)&&void 0!==l&&l,this.animationInProcess=!1,this.selectOptions=[],this.remoteOptions=[],this.tagsInputHelper=null,this.disabledObserver=new MutationObserver(e=>{e.some(e=>"disabled"===e.attributeName)&&this.setDisabledState(this.el.hasAttribute("disabled"))}),this.disabledObserver.observe(this.el,{attributes:!0,attributeFilter:["disabled"]}),this.init()}wrapperClick(e){e.target.closest("[data-select-dropdown]")||e.target.closest("[data-tag-value]")||this.tagsInput.focus()}toggleClick(){if(this.isDisabled)return!1;this.toggleFn()}tagsInputFocus(){this._isOpened||this.open()}tagsInputInput(){this.calculateInputWidth()}tagsInputInputSecond(e){this.apiUrl||this.searchOptions(e.target.value)}tagsInputKeydown(e){if("Enter"===e.key&&this.isAddTagOnEnter){const t=e.target.value;if(this.selectOptions.find(e=>e.val===t))return!1;this.addSelectOption(t,t),this.buildOption(t,t),this.buildOriginalOption(t,t),this.dropdown.querySelector(`[data-value="${t}"]`).click(),this.resetTagsInputField()}}searchInput(e){const t=e.target.value;this.apiUrl?this.remoteSearch(t):this.searchOptions(t)}setValue(e){if(this.value=e,this.clearSelections(),Array.isArray(e))if("tags"===this.mode){this.unselectMultipleItems(),this.selectMultipleItems(),this.selectedItems=[];this.wrapper.querySelectorAll("[data-tag-value]").forEach(e=>e.remove()),this.setTagsItems(),this.reassignTagsInputPlaceholder(this.hasValue()?"":this.placeholder)}else this.toggleTextWrapper.innerHTML=this.hasValue()?this.stringFromValue():this.placeholder,this.unselectMultipleItems(),this.selectMultipleItems();else this.setToggleTitle(),this.toggle.querySelector("[data-icon]")&&this.setToggleIcon(),this.toggle.querySelector("[data-title]")&&this.setToggleTitle(),this.selectSingleItem()}setDisabledState(e){this.isDisabled=e;const t="tags"===this.mode?this.wrapper:this.toggle;null==t||t.classList.toggle("disabled",e),e&&this.isOpened()&&this.close()}hasValue(){return this.isMultiple?Array.isArray(this.value)&&this.value.length>0&&this.value.some(e=>null!=e&&""!==e):null!==this.value&&void 0!==this.value&&""!==this.value}init(){this.createCollection(window.$hsSelectCollection,this),this.build(),"undefined"!=typeof window&&(window.HSAccessibilityObserver||(window.HSAccessibilityObserver=new a.default),this.setupAccessibility())}build(){if(this.el.style.display="none",this.el.children&&Array.from(this.el.children).filter(e=>this.optionAllowEmptyOption||!this.optionAllowEmptyOption&&e.value&&""!==e.value).forEach(e=>{const t=e.getAttribute("data-select-option");this.selectOptions=[...this.selectOptions,{title:e.textContent,val:e.value,disabled:e.disabled,options:"undefined"!==t?JSON.parse(t):null}]}),this.optionAllowEmptyOption&&!this.value&&(this.value=""),this.isMultiple){const e=Array.from(this.el.children).filter(e=>e.selected),t=[];e.forEach(e=>{t.push(e.value)}),this.value=t}this.buildWrapper(),"tags"===this.mode?this.buildTags():this.buildToggle(),this.buildDropdown(),this.extraMarkup&&this.buildExtraMarkup()}buildWrapper(){this.wrapper=document.createElement("div"),this.wrapper.classList.add("advance-select","relative"),this.setDisabledState(this.isDisabled),"tags"===this.mode&&(this.onWrapperClickListener=e=>this.wrapperClick(e),this.wrapper.addEventListener("click",this.onWrapperClickListener)),this.wrapperClasses&&(0,o.classToClassList)(this.wrapperClasses,this.wrapper),this.el.before(this.wrapper),this.wrapper.append(this.el)}buildExtraMarkup(){const e=e=>{const t=(0,o.htmlToElement)(e);return this.wrapper.append(t),t},t=e=>{e.classList.contains("--prevent-click")||e.addEventListener("click",e=>{e.stopPropagation(),this.isDisabled||this.toggleFn()})};if(Array.isArray(this.extraMarkup))this.extraMarkup.forEach(i=>{const s=e(i);t(s)});else{const i=e(this.extraMarkup);t(i)}}buildToggle(){var e,t;let i,s;this.toggleTextWrapper=document.createElement("span"),this.toggleTextWrapper.classList.add("truncate"),this.toggle=(0,o.htmlToElement)(this.toggleTag||"
"),i=this.toggle.querySelector("[data-icon]"),s=this.toggle.querySelector("[data-title]"),!this.isMultiple&&i&&this.setToggleIcon(),!this.isMultiple&&s&&this.setToggleTitle(),this.isMultiple?this.toggleTextWrapper.innerHTML=this.hasValue()?this.stringFromValue():this.placeholder:this.toggleTextWrapper.innerHTML=(null===(e=this.getItemByValue(this.value))||void 0===e?void 0:e.title)||this.placeholder,s||this.toggle.append(this.toggleTextWrapper),this.toggleClasses&&(0,o.classToClassList)(this.toggleClasses,this.toggle),this.isDisabled&&this.toggle.classList.add("disabled"),this.wrapper&&this.wrapper.append(this.toggle),(null===(t=this.toggle)||void 0===t?void 0:t.ariaExpanded)&&(this._isOpened?this.toggle.ariaExpanded="true":this.toggle.ariaExpanded="false"),this.onToggleClickListener=()=>this.toggleClick(),this.toggle.addEventListener("click",this.onToggleClickListener)}setToggleIcon(){var e;const t=this.getItemByValue(this.value),i=this.toggle.querySelector("[data-icon]");if(i){i.innerHTML="";const s=(0,o.htmlToElement)(this.apiUrl&&this.apiIconTag?this.apiIconTag||"":(null===(e=null==t?void 0:t.options)||void 0===e?void 0:e.icon)||"");this.value&&this.apiUrl&&this.apiIconTag&&t[this.apiFieldsMap.icon]&&(s.src=t[this.apiFieldsMap.icon]||""),i.append(s),(null==s?void 0:s.src)?i.classList.remove("hidden"):i.classList.add("hidden")}}setToggleTitle(){const e=this.toggle.querySelector("[data-title]");let t=this.placeholder;if(this.optionAllowEmptyOption&&""===this.value){const e=this.selectOptions.find(e=>""===e.val);t=(null==e?void 0:e.title)||this.placeholder}else if(this.value)if(this.apiUrl){const e=this.remoteOptions.find(e=>`${e[this.apiFieldsMap.val]}`===this.value||`${e[this.apiFieldsMap.title]}`===this.value);e&&(t=e[this.apiFieldsMap.title])}else{const e=this.selectOptions.find(e=>e.val===this.value);e&&(t=e.title)}e?(e.innerHTML=t,e.classList.add("truncate"),this.toggle.append(e)):this.toggleTextWrapper.innerHTML=t}buildTags(){this.isDisabled&&this.wrapper.classList.add("disabled"),this.wrapper.setAttribute("tabindex","0"),this.buildTagsInput(),this.setTagsItems()}reassignTagsInputPlaceholder(e){this.tagsInput.placeholder=e,this.tagsInputHelper.innerHTML=e,this.calculateInputWidth()}buildTagsItem(e){var t,i,s,n,l;const r=this.getItemByValue(e);let a,h,d,c;const u=document.createElement("div");if(u.setAttribute("data-tag-value",e),this.tagsItemClasses&&(0,o.classToClassList)(this.tagsItemClasses,u),this.tagsItemTemplate&&(a=(0,o.htmlToElement)(this.tagsItemTemplate),u.append(a)),(null===(t=null==r?void 0:r.options)||void 0===t?void 0:t.icon)||this.apiIconTag){const e=(0,o.htmlToElement)(this.apiUrl&&this.apiIconTag?this.apiIconTag:null===(i=null==r?void 0:r.options)||void 0===i?void 0:i.icon);this.apiUrl&&this.apiIconTag&&r[this.apiFieldsMap.icon]&&(e.src=r[this.apiFieldsMap.icon]||""),c=a?a.querySelector("[data-icon]"):document.createElement("span"),c.append(e),a||u.append(c)}!a||!a.querySelector("[data-icon]")||(null===(s=null==r?void 0:r.options)||void 0===s?void 0:s.icon)||this.apiUrl||this.apiIconTag||r[null===(n=this.apiFieldsMap)||void 0===n?void 0:n.icon]||a.querySelector("[data-icon]").classList.add("hidden"),h=a?a.querySelector("[data-title]"):document.createElement("span"),this.apiUrl&&(null===(l=this.apiFieldsMap)||void 0===l?void 0:l.title)&&r[this.apiFieldsMap.title]?h.textContent=r[this.apiFieldsMap.title]:h.textContent=r.title||"",a||u.append(h),a?d=a.querySelector("[data-remove]"):(d=document.createElement("span"),d.textContent="X",u.append(d)),d.addEventListener("click",()=>{this.value=this.value.filter(t=>t!==e),this.selectedItems=this.selectedItems.filter(t=>t!==e),this.hasValue()||this.reassignTagsInputPlaceholder(this.placeholder),this.unselectMultipleItems(),this.selectMultipleItems(),u.remove(),this.triggerChangeEventForNativeSelect()}),this.wrapper.append(u)}getItemByValue(e){return this.apiUrl?this.remoteOptions.find(t=>`${t[this.apiFieldsMap.val]}`===e||t[this.apiFieldsMap.title]===e):this.selectOptions.find(t=>t.val===e)}setTagsItems(){if(this.value){(Array.isArray(this.value)?this.value:null!=this.value?[this.value]:[]).forEach(e=>{this.selectedItems.includes(e)||this.buildTagsItem(e),this.selectedItems=this.selectedItems.includes(e)?this.selectedItems:[...this.selectedItems,e]})}this._isOpened&&this.floatingUIInstance&&this.floatingUIInstance.update()}buildTagsInput(){this.tagsInput=document.createElement("input"),this.tagsInputId&&(this.tagsInput.id=this.tagsInputId),this.tagsInputClasses&&(0,o.classToClassList)(this.tagsInputClasses,this.tagsInput),this.tagsInput.setAttribute("tabindex","-1"),this.onTagsInputFocusListener=()=>this.tagsInputFocus(),this.onTagsInputInputListener=()=>this.tagsInputInput(),this.onTagsInputInputSecondListener=(0,o.debounce)(e=>this.tagsInputInputSecond(e)),this.onTagsInputKeydownListener=e=>this.tagsInputKeydown(e),this.tagsInput.addEventListener("focus",this.onTagsInputFocusListener),this.tagsInput.addEventListener("input",this.onTagsInputInputListener),this.tagsInput.addEventListener("input",this.onTagsInputInputSecondListener),this.tagsInput.addEventListener("keydown",this.onTagsInputKeydownListener),this.wrapper.append(this.tagsInput),setTimeout(()=>{this.adjustInputWidth(),this.reassignTagsInputPlaceholder(this.hasValue()?"":this.placeholder)})}buildDropdown(){this.dropdown=(0,o.htmlToElement)(this.dropdownTag||"
"),this.dropdown.setAttribute("data-select-dropdown",""),"parent"===this.dropdownScope&&(this.dropdown.classList.add("absolute"),this.dropdownVerticalFixedPlacement||this.dropdown.classList.add("top-full")),this.dropdown.role="listbox",this.dropdown.tabIndex=-1,this.dropdown.ariaOrientation="vertical",this._isOpened||this.dropdown.classList.add("hidden"),this.dropdownClasses&&(0,o.classToClassList)(this.dropdownClasses,this.dropdown),this.wrapper&&this.wrapper.append(this.dropdown),this.dropdown&&this.hasSearch&&this.buildSearch(),this.selectOptions&&this.selectOptions.forEach((e,t)=>this.buildOption(e.title,e.val,e.disabled,e.selected,e.options,`${t}`)),this.apiUrl&&this.optionsFromRemoteData(),this.apiUrl||(this.sortElements(this.el,"option"),this.sortElements(this.dropdown,"[data-value]")),"window"===this.dropdownScope&&this.buildFloatingUI(),this.dropdown&&this.apiLoadMore&&this.setupInfiniteScroll()}setupInfiniteScroll(){this.dropdown.addEventListener("scroll",this.handleScroll.bind(this))}handleScroll(){return s(this,void 0,void 0,function*(){if(!this.dropdown||this.isLoading||!this.hasMore||!this.apiLoadMore)return;const{scrollTop:e,scrollHeight:t,clientHeight:i}=this.dropdown;t-e-i<("object"==typeof this.apiLoadMore?this.apiLoadMore.scrollThreshold:100)&&(yield this.loadMore())})}loadMore(){return s(this,void 0,void 0,function*(){var e,t,i,s;if(this.apiUrl&&!this.isLoading&&this.hasMore&&this.apiLoadMore){this.isLoading=!0;try{const n=new URL(this.apiUrl),o=(null===(e=this.apiFieldsMap)||void 0===e?void 0:e.page)||(null===(t=this.apiFieldsMap)||void 0===t?void 0:t.offset)||"page",l=!!(null===(i=this.apiFieldsMap)||void 0===i?void 0:i.offset),r="object"==typeof this.apiLoadMore?this.apiLoadMore.perPage:10;if(l){const e=this.currentPage*r;n.searchParams.set(o,e.toString()),this.currentPage++}else this.currentPage++,n.searchParams.set(o,this.currentPage.toString());n.searchParams.set((null===(s=this.apiFieldsMap)||void 0===s?void 0:s.limit)||"limit",r.toString());const a=yield fetch(n.toString(),this.apiOptions||{}),h=yield a.json(),d=this.apiDataPart?h[this.apiDataPart]:h.results,c=h.count||0,u=this.currentPage*r;d&&d.length>0?(this.remoteOptions=[...this.remoteOptions||[],...d],this.buildOptionsFromRemoteData(d),this.hasMore=u{(0,l.computePosition)(e,this.dropdown,i).then(({x:e,y:t,placement:i})=>{Object.assign(this.dropdown.style,{position:"fixed",left:`${e}px`,top:`${t}px`,["margin"+("bottom"===i?"Top":"top"===i?"Bottom":"right"===i?"Left":"Right")]:`${this.dropdownSpace}px`}),this.dropdown.setAttribute("data-placement",i)})};s();const n=(0,l.autoUpdate)(e,this.dropdown,s);this.floatingUIInstance={update:s,destroy:n}}updateDropdownWidth(){const e="tags"===this.mode?this.wrapper:this.toggle;this.dropdown.style.width=`${e.clientWidth}px`}buildSearch(){let e;this.searchWrapper=(0,o.htmlToElement)(this.searchWrapperTemplate||"
"),this.searchWrapperClasses&&(0,o.classToClassList)(this.searchWrapperClasses,this.searchWrapper),e=this.searchWrapper.querySelector("[data-input]");const t=(0,o.htmlToElement)(this.searchTemplate||'');this.search="INPUT"===t.tagName?t:t.querySelector(":scope input"),this.search.placeholder=this.searchPlaceholder,this.searchClasses&&(0,o.classToClassList)(this.searchClasses,this.search),this.searchId&&(this.search.id=this.searchId),this.onSearchInputListener=(0,o.debounce)(e=>this.searchInput(e)),this.search.addEventListener("input",this.onSearchInputListener),e?e.append(t):this.searchWrapper.append(t),this.dropdown.append(this.searchWrapper)}buildOption(e,t,i=!1,s=!1,n,l="1",r){var a;let h=null,d=null,c=null,u=null;const p=(0,o.htmlToElement)(this.optionTag||"
");if(p.setAttribute("data-value",t),p.setAttribute("data-title-value",e),p.setAttribute("tabIndex",l),p.classList.add("cursor-pointer"),p.setAttribute("data-id",r||`${this.optionId}`),r||this.optionId++,i&&p.classList.add("disabled"),s&&(this.isMultiple?this.value=[...this.value,t]:this.value=t),this.optionTemplate&&(h=(0,o.htmlToElement)(this.optionTemplate),p.append(h)),h?(d=h.querySelector("[data-title]"),d.textContent=e||""):p.textContent=e||"",n){if(n.icon){const t=(0,o.htmlToElement)(null!==(a=this.apiIconTag)&&void 0!==a?a:n.icon);if(t.classList.add("max-w-full"),this.apiUrl&&(t.setAttribute("alt",e),t.setAttribute("src",n.icon)),h)c=h.querySelector("[data-icon]"),c.append(t);else{const e=(0,o.htmlToElement)("
");this.iconClasses&&(0,o.classToClassList)(this.iconClasses,e),e.append(t),p.append(e)}}if(n.description)if(h)u=h.querySelector("[data-description]"),u&&u.append(n.description);else{const e=(0,o.htmlToElement)("
");e.textContent=n.description,this.descriptionClasses&&(0,o.classToClassList)(this.descriptionClasses,e),p.append(e)}}h&&h.querySelector("[data-icon]")&&!n&&!(null==n?void 0:n.icon)&&h.querySelector("[data-icon]").classList.add("hidden"),this.value&&(this.isMultiple?this.value.includes(t):this.value===t)&&p.classList.add("selected"),i||p.addEventListener("click",()=>this.onSelectOption(t)),this.optionClasses&&(0,o.classToClassList)(this.optionClasses,p),this.dropdown&&this.dropdown.append(p),s&&this.setNewValue()}buildOptionFromRemoteData(e,t,i=!1,s=!1,n="1",o,l){n?this.buildOption(e,t,i,s,l,n,o):alert("ID parameter is required for generating remote options! Please check your API endpoint have it.")}buildOptionsFromRemoteData(e){e.forEach((e,t)=>{let i=null,s="",n="";const o={id:"",val:"",title:"",icon:null,description:null,rest:{}};Object.keys(e).forEach(t=>{var l;e[this.apiFieldsMap.id]&&(i=e[this.apiFieldsMap.id]),e[this.apiFieldsMap.val]&&(n=`${e[this.apiFieldsMap.val]}`),e[this.apiFieldsMap.title]&&(s=e[this.apiFieldsMap.title],e[this.apiFieldsMap.val]||(n=s)),e[this.apiFieldsMap.icon]&&(o.icon=e[this.apiFieldsMap.icon]),e[null===(l=this.apiFieldsMap)||void 0===l?void 0:l.description]&&(o.description=e[this.apiFieldsMap.description]),o.rest[t]=e[t]});if(!this.dropdown.querySelector(`[data-value="${n}"]`)){const e=!!this.apiSelectedValues&&(Array.isArray(this.apiSelectedValues)?this.apiSelectedValues.includes(n):this.apiSelectedValues===n);this.buildOriginalOption(s,n,i,!1,e,o),this.buildOptionFromRemoteData(s,n,!1,e,`${t}`,i,o),e&&(this.isMultiple?(this.value||(this.value=[]),Array.isArray(this.value)&&(this.value=[...this.value,n])):this.value=n)}}),this.sortElements(this.el,"option"),this.sortElements(this.dropdown,"[data-value]")}optionsFromRemoteData(){return s(this,arguments,void 0,function*(e=""){const t=yield this.apiRequest(e);this.remoteOptions=t,t.length?this.buildOptionsFromRemoteData(this.remoteOptions):console.log("There is no data were responded!")})}apiRequest(){return s(this,arguments,void 0,function*(e=""){var t,i,s,n,o,l,r,a,h,d;try{const c=new URL(this.apiUrl),u=new URLSearchParams(null!==(t=this.apiQuery)&&void 0!==t?t:""),p=null!==(i=this.apiOptions)&&void 0!==i?i:{},m=null!==(s=this.apiSearchQueryKey)&&void 0!==s?s:"q",g=(null!=e?e:"").trim().toLowerCase();if(""!==g&&u.set(m,encodeURIComponent(g)),this.apiLoadMore){const e="object"==typeof this.apiLoadMore?this.apiLoadMore.perPage:10,t=null!==(r=null!==(o=null===(n=this.apiFieldsMap)||void 0===n?void 0:n.page)&&void 0!==o?o:null===(l=this.apiFieldsMap)||void 0===l?void 0:l.offset)&&void 0!==r?r:"page",i=null!==(h=null===(a=this.apiFieldsMap)||void 0===a?void 0:a.limit)&&void 0!==h?h:"limit",s=Boolean(null===(d=this.apiFieldsMap)||void 0===d?void 0:d.offset);u.delete(t),u.delete(i),u.set(t,s?"0":"1"),u.set(i,String(e))}c.search=u.toString();const v=yield fetch(c.toString(),p),f=yield v.json();return this.apiDataPart?f[this.apiDataPart]:f}catch(e){console.error(e)}})}sortElements(e,t){const i=Array.from(e.querySelectorAll(t));this.isSelectedOptionOnTop&&i.sort((e,t)=>{const i=e.classList.contains("selected")||e.hasAttribute("selected"),s=t.classList.contains("selected")||t.hasAttribute("selected");return i&&!s?-1:!i&&s?1:0}),i.forEach(t=>e.appendChild(t))}remoteSearch(e){return s(this,void 0,void 0,function*(){if(e.length<=this.minSearchLength){const e=yield this.apiRequest("");return this.remoteOptions=e,Array.from(this.dropdown.querySelectorAll("[data-value]")).forEach(e=>e.remove()),Array.from(this.el.querySelectorAll("option[value]")).forEach(e=>{e.remove()}),e.length?this.buildOptionsFromRemoteData(e):console.log("No data responded!"),!1}const t=yield this.apiRequest(e);this.remoteOptions=t;let i=t.map(e=>`${e.id}`),s=null;const n=this.dropdown.querySelectorAll("[data-value]");this.el.querySelectorAll("[data-select-option]").forEach(e=>{var t;const s=e.getAttribute("data-id");i.includes(s)||(null===(t=this.value)||void 0===t?void 0:t.includes(e.value))||this.destroyOriginalOption(e.value)}),n.forEach(e=>{var t;const s=e.getAttribute("data-id");i.includes(s)||(null===(t=this.value)||void 0===t?void 0:t.includes(e.getAttribute("data-value")))?i=i.filter(e=>e!==s):this.destroyOption(e.getAttribute("data-value"))}),s=t.filter(e=>i.includes(`${e.id}`)),s.length?this.buildOptionsFromRemoteData(s):console.log("No data responded!")})}destroyOption(e){const t=this.dropdown.querySelector(`[data-value="${e}"]`);if(!t)return!1;t.remove()}buildOriginalOption(e,t,i,s,n,l){const r=(0,o.htmlToElement)("");r.setAttribute("value",t),s&&r.setAttribute("disabled","disabled"),n&&r.setAttribute("selected","selected"),i&&r.setAttribute("data-id",i),r.setAttribute("data-select-option",JSON.stringify(l)),r.innerText=e,this.el.append(r)}destroyOriginalOption(e){const t=this.el.querySelector(`[value="${e}"]`);if(!t)return!1;t.remove()}buildTagsInputHelper(){this.tagsInputHelper=document.createElement("span"),this.tagsInputHelper.style.fontSize=window.getComputedStyle(this.tagsInput).fontSize,this.tagsInputHelper.style.fontFamily=window.getComputedStyle(this.tagsInput).fontFamily,this.tagsInputHelper.style.fontWeight=window.getComputedStyle(this.tagsInput).fontWeight,this.tagsInputHelper.style.letterSpacing=window.getComputedStyle(this.tagsInput).letterSpacing,this.tagsInputHelper.style.visibility="hidden",this.tagsInputHelper.style.whiteSpace="pre",this.tagsInputHelper.style.position="absolute",this.wrapper.appendChild(this.tagsInputHelper)}calculateInputWidth(){this.tagsInputHelper.textContent=this.tagsInput.value||this.tagsInput.placeholder;const e=parseInt(window.getComputedStyle(this.tagsInput).paddingLeft)+parseInt(window.getComputedStyle(this.tagsInput).paddingRight),t=parseInt(window.getComputedStyle(this.tagsInput).borderLeftWidth)+parseInt(window.getComputedStyle(this.tagsInput).borderRightWidth),i=this.tagsInputHelper.offsetWidth+e+t,s=this.wrapper.offsetWidth-(parseInt(window.getComputedStyle(this.wrapper).paddingLeft)+parseInt(window.getComputedStyle(this.wrapper).paddingRight));this.tagsInput.style.width=`${Math.min(i,s)+2}px`}adjustInputWidth(){this.buildTagsInputHelper(),this.calculateInputWidth()}onSelectOption(e){if(this.clearSelections(),this.isMultiple?(Array.isArray(this.value)||(this.value=[]),this.value=this.value.includes(e)?this.value.filter(t=>t!==e):[...this.value,e],this.selectMultipleItems(),this.setNewValue()):(this.value=e,this.selectSingleItem(),this.setNewValue()),this.fireEvent("change",this.value),"tags"===this.mode){const e=this.selectedItems.filter(e=>!this.value.includes(e));e.length&&e.forEach(e=>{this.selectedItems=this.selectedItems.filter(t=>t!==e),this.wrapper.querySelector(`[data-tag-value="${e}"]`).remove()}),this.resetTagsInputField()}this.isMultiple||(this.toggle.querySelector("[data-icon]")&&this.setToggleIcon(),this.toggle.querySelector("[data-title]")&&this.setToggleTitle(),this.close(!0)),this.hasValue()||"tags"!==this.mode||this.reassignTagsInputPlaceholder(this.placeholder),this._isOpened&&"tags"===this.mode&&this.tagsInput&&this.tagsInput.focus(),this.triggerChangeEventForNativeSelect()}triggerChangeEventForNativeSelect(){const e=new Event("change",{bubbles:!0});this.el.dispatchEvent(e),(0,o.dispatch)("change.advance.select",this.el,this.value)}addSelectOption(e,t,i,s,n){this.selectOptions=[...this.selectOptions,{title:e,val:t,disabled:i,selected:s,options:n}]}removeSelectOption(e,t=!1){if(!!!this.selectOptions.some(t=>t.val===e))return!1;this.selectOptions=this.selectOptions.filter(t=>t.val!==e),this.value=t?this.value.filter(t=>t!==e):e}resetTagsInputField(){this.tagsInput.value="",this.reassignTagsInputPlaceholder(""),this.searchOptions("")}clearSelections(){Array.from(this.dropdown.children).forEach(e=>{e.classList.contains("selected")&&e.classList.remove("selected")}),Array.from(this.el.children).forEach(e=>{e.selected&&(e.selected=!1)})}setNewValue(){if("tags"===this.mode)this.setTagsItems();else if(this.optionAllowEmptyOption&&""===this.value){const e=this.selectOptions.find(e=>""===e.val);this.toggleTextWrapper.innerHTML=(null==e?void 0:e.title)||this.placeholder}else if(this.hasValue())if(this.apiUrl){const e=this.dropdown.querySelector(`[data-value="${this.value}"]`);if(e)this.toggleTextWrapper.innerHTML=e.getAttribute("data-title-value")||this.placeholder;else{const e=this.remoteOptions.find(e=>(e[this.apiFieldsMap.val]?`${e[this.apiFieldsMap.val]}`:e[this.apiFieldsMap.title])===this.value);this.toggleTextWrapper.innerHTML=e?`${e[this.apiFieldsMap.title]}`:this.stringFromValue()}}else this.toggleTextWrapper.innerHTML=this.stringFromValue();else this.toggleTextWrapper.innerHTML=this.placeholder}stringFromValueBasic(e){var t;const i=[];let s="";if(e.forEach(e=>{this.isMultiple?Array.isArray(this.value)&&this.value.includes(e.val)&&i.push(e.title):this.value===e.val&&i.push(e.title)}),void 0!==this.toggleCountText&&null!==this.toggleCountText&&i.length>=this.toggleCountTextMinItems)if("nItemsAndCount"===this.toggleCountTextMode){const e=i.slice(0,this.toggleCountTextMinItems-1),n=[e.join(this.toggleSeparators.items)],o=""+(i.length-e.length);if((null===(t=null==this?void 0:this.toggleSeparators)||void 0===t?void 0:t.betweenItemsAndCounter)&&n.push(this.toggleSeparators.betweenItemsAndCounter),this.toggleCountText)switch(this.toggleCountTextPlacement){case"postfix-no-space":n.push(`${o}${this.toggleCountText}`);break;case"prefix-no-space":n.push(`${this.toggleCountText}${o}`);break;case"prefix":n.push(`${this.toggleCountText} ${o}`);break;default:n.push(`${o} ${this.toggleCountText}`)}s=n.join(" ")}else s=`${i.length} ${this.toggleCountText}`;else s=i.join(this.toggleSeparators.items);return s}stringFromValueRemoteData(){const e=this.dropdown.querySelectorAll("[data-title-value]"),t=[];let i="";if(e.forEach(e=>{const i=e.getAttribute("data-value"),s=e.getAttribute("data-title-value");this.isMultiple?Array.isArray(this.value)&&this.value.includes(i)&&t.push(s):this.value===i&&t.push(s)}),this.toggleCountText&&""!==this.toggleCountText&&t.length>=this.toggleCountTextMinItems)if("nItemsAndCount"===this.toggleCountTextMode){const e=t.slice(0,this.toggleCountTextMinItems-1);i=`${e.join(this.toggleSeparators.items)} ${this.toggleSeparators.betweenItemsAndCounter} ${t.length-e.length} ${this.toggleCountText}`}else i=`${t.length} ${this.toggleCountText}`;else i=t.join(this.toggleSeparators.items);return i}stringFromValue(){return this.apiUrl?this.stringFromValueRemoteData():this.stringFromValueBasic(this.selectOptions)}selectSingleItem(){Array.from(this.el.children).find(e=>this.value===e.value).selected=!0;const e=Array.from(this.dropdown.children).find(e=>this.value===e.getAttribute("data-value"));e&&e.classList.add("selected"),this.sortElements(this.el,"option"),this.sortElements(this.dropdown,"[data-value]")}selectMultipleItems(){Array.isArray(this.value)&&(Array.from(this.dropdown.children).filter(e=>this.value.includes(e.getAttribute("data-value"))).forEach(e=>e.classList.add("selected")),Array.from(this.el.children).filter(e=>this.value.includes(e.value)).forEach(e=>e.selected=!0),this.sortElements(this.el,"option"),this.sortElements(this.dropdown,"[data-value]"))}unselectMultipleItems(){Array.from(this.dropdown.children).forEach(e=>e.classList.remove("selected")),Array.from(this.el.children).forEach(e=>e.selected=!1),this.sortElements(this.el,"option"),this.sortElements(this.dropdown,"[data-value]")}searchOptions(e){if(e.length<=this.minSearchLength){this.searchNoResult&&(this.searchNoResult.remove(),this.searchNoResult=null);return this.dropdown.querySelectorAll("[data-value]").forEach(e=>{e.classList.remove("hidden")}),!1}this.searchNoResult&&(this.searchNoResult.remove(),this.searchNoResult=null),this.searchNoResult=(0,o.htmlToElement)(this.searchNoResultTemplate),this.searchNoResult.innerText=this.searchNoResultText,(0,o.classToClassList)(this.searchNoResultClasses,this.searchNoResult);const t=this.dropdown.querySelectorAll("[data-value]");let i,s=!1;this.searchLimit&&(i=0),t.forEach(t=>{const n=t.getAttribute("data-title-value").toLocaleLowerCase();let o;if(this.isSearchDirectMatch)o=!n.includes(e.toLowerCase())||this.searchLimit&&i>=this.searchLimit;else{const t=e?e.split("").map(e=>/\w/.test(e)?`${e}[\\W_]*`:"\\W*").join(""):"";o=!new RegExp(t,"i").test(n.trim())||this.searchLimit&&i>=this.searchLimit}o?t.classList.add("hidden"):(t.classList.remove("hidden"),s=!0,this.searchLimit&&i++)}),s||this.dropdown.append(this.searchNoResult)}eraseToggleIcon(){const e=this.toggle.querySelector("[data-icon]");e&&(e.innerHTML=null,e.classList.add("hidden"))}eraseToggleTitle(){const e=this.toggle.querySelector("[data-title]");e?e.innerHTML=this.placeholder:this.toggleTextWrapper.innerHTML=this.placeholder}toggleFn(){this._isOpened?this.close():this.open()}setupAccessibility(){this.accessibilityComponent=window.HSAccessibilityObserver.registerComponent(this.wrapper,{onEnter:()=>{if(this._isOpened){const e=this.dropdown.querySelector(".select-option-highlighted");e&&(this.onSelectOption(e.getAttribute("data-value")||""),this._isOpened&&e.focus())}else this.open()},onSpace:()=>{if(this._isOpened){const e=this.dropdown.querySelector(".select-option-highlighted");e&&(this.onSelectOption(e.getAttribute("data-value")||""),this._isOpened&&e.focus())}else this.open()},onEsc:()=>{this._isOpened&&this.close(!0)},onArrow:e=>{if(!e.metaKey)if(this._isOpened||"ArrowDown"!==e.key){if(this._isOpened)switch(e.key){case"ArrowDown":this.focusMenuItem("next");break;case"ArrowUp":this.focusMenuItem("prev");break;case"Home":this.onStartEnd(!0);break;case"End":this.onStartEnd(!1)}}else this.open()},onHome:()=>{this._isOpened&&this.onStartEnd(!0)},onEnd:()=>{this._isOpened&&this.onStartEnd(!1)},onTab:()=>{this._isOpened&&this.close()}},this._isOpened,"Select",".advance-select",this.dropdown)}focusMenuItem(e){const t=Array.from(this.dropdown.querySelectorAll(":scope > *:not(.hidden)")).filter(e=>!e.classList.contains("disabled"));if(!t.length)return;const i=this.dropdown.querySelector(".select-option-highlighted"),s=i?t.indexOf(i):-1,n="next"===e?(s+1)%t.length:(s-1+t.length)%t.length;i&&i.classList.remove("select-option-highlighted"),t[n].classList.add("select-option-highlighted"),t[n].focus()}onStartEnd(e=!0){if(!this.dropdown)return;const t=Array.from(this.dropdown.querySelectorAll(":scope > *:not(.hidden)")).filter(e=>!e.classList.contains("disabled"));if(!t.length)return;const i=this.dropdown.querySelector(".select-option-highlighted");i&&i.classList.remove("select-option-highlighted");const s=e?0:t.length-1;t[s].classList.add("select-option-highlighted"),t[s].focus()}destroy(){var e;this.wrapper&&this.wrapper.removeEventListener("click",this.onWrapperClickListener),this.toggle&&this.toggle.removeEventListener("click",this.onToggleClickListener),this.tagsInput&&(this.tagsInput.removeEventListener("focus",this.onTagsInputFocusListener),this.tagsInput.removeEventListener("input",this.onTagsInputInputListener),this.tagsInput.removeEventListener("input",this.onTagsInputInputSecondListener),this.tagsInput.removeEventListener("keydown",this.onTagsInputKeydownListener)),this.search&&this.search.removeEventListener("input",this.onSearchInputListener);const t=this.el.parentElement.parentElement;this.el.classList.add("hidden"),this.el.style.display="",t.prepend(this.el),t.querySelector(".advance-select").remove(),this.wrapper=null,null===(e=this.disabledObserver)||void 0===e||e.disconnect(),this.disabledObserver=null,window.$hsSelectCollection=window.$hsSelectCollection.filter(({element:e})=>e.el!==this.el)}open(){var e;const t=(null===(e=null===window||void 0===window?void 0:window.$hsSelectCollection)||void 0===e?void 0:e.find(e=>e.element.isOpened()))||null;if(t&&t.element.close(),this.animationInProcess)return!1;this.animationInProcess=!0,"window"===this.dropdownScope&&this.dropdown.classList.add("invisible"),this.dropdown.classList.remove("hidden"),"window"!==this.dropdownScope&&this.recalculateDirection(),setTimeout(()=>{var e;(null===(e=null==this?void 0:this.toggle)||void 0===e?void 0:e.ariaExpanded)&&(this.toggle.ariaExpanded="true"),this.wrapper.classList.add("active"),this.dropdown.classList.add("opened"),this.dropdown.classList.contains("w-full")&&"window"===this.dropdownScope&&this.updateDropdownWidth(),this.floatingUIInstance&&"window"===this.dropdownScope&&(this.floatingUIInstance.update(),this.dropdown.classList.remove("invisible")),this.hasSearch&&!this.preventSearchFocus&&this.search.focus(),this.animationInProcess=!1}),this._isOpened=!0,window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,this._isOpened)}close(e=!1){var t,i,s,n;if(this.animationInProcess)return!1;this.animationInProcess=!0,(null===(t=null==this?void 0:this.toggle)||void 0===t?void 0:t.ariaExpanded)&&(this.toggle.ariaExpanded="false"),this.wrapper.classList.remove("active"),this.dropdown.classList.remove("opened","bottom-full","top-full"),(null===(i=this.dropdownDirectionClasses)||void 0===i?void 0:i.bottom)&&this.dropdown.classList.remove(this.dropdownDirectionClasses.bottom),(null===(s=this.dropdownDirectionClasses)||void 0===s?void 0:s.top)&&this.dropdown.classList.remove(this.dropdownDirectionClasses.top),this.dropdown.style.marginTop="",this.dropdown.style.marginBottom="",(0,o.afterTransition)(this.dropdown,()=>{var t;this.dropdown.classList.add("hidden"),this.hasSearch&&(this.search.value="",this.apiUrl||this.search.dispatchEvent(new Event("input",{bubbles:!0})),this.search.blur()),e&&((null===(t=this.mode)||void 0===t?void 0:t.includes("tags"))?this.wrapper.focus():this.toggle.focus()),this.animationInProcess=!1}),null===(n=this.dropdown.querySelector(".select-option-highlighted"))||void 0===n||n.classList.remove("select-option-highlighted"),this._isOpened=!1,window.HSAccessibilityObserver&&this.accessibilityComponent&&window.HSAccessibilityObserver.updateComponentState(this.accessibilityComponent,this._isOpened)}addOption(e){let t=`${this.selectOptions.length}`;const i=e=>{const{title:i,val:s,disabled:n,selected:o,options:l}=e;!!this.selectOptions.some(e=>e.val===s)||(this.addSelectOption(i,s,n,o,l),this.buildOption(i,s,n,o,l,t),this.buildOriginalOption(i,s,null,n,o,l),o&&!this.isMultiple&&this.onSelectOption(s))};Array.isArray(e)?e.forEach(e=>{i(e)}):i(e),this.sortElements(this.el,"option"),this.sortElements(this.dropdown,"[data-value]")}removeOption(e){const t=(e,t=!1)=>{!!this.selectOptions.some(t=>t.val===e)&&(this.removeSelectOption(e,t),this.destroyOption(e),this.destroyOriginalOption(e),this.value===e&&(this.value=null,this.eraseToggleTitle(),this.eraseToggleIcon()))};Array.isArray(e)?e.forEach(e=>{t(e,this.isMultiple)}):t(e,this.isMultiple),this.setNewValue(),this.sortElements(this.el,"option"),this.sortElements(this.dropdown,"[data-value]")}recalculateDirection(){var e,t,i,s;if((null==this?void 0:this.dropdownVerticalFixedPlacement)&&(this.dropdown.classList.contains("bottom-full")||this.dropdown.classList.contains("top-full")))return!1;"top"===(null==this?void 0:this.dropdownVerticalFixedPlacement)?(this.dropdown.classList.add("bottom-full"),this.dropdown.style.marginBottom=`${this.dropdownSpace}px`):"bottom"===(null==this?void 0:this.dropdownVerticalFixedPlacement)?(this.dropdown.classList.add("top-full"),this.dropdown.style.marginTop=`${this.dropdownSpace}px`):(0,o.isEnoughSpace)(this.dropdown,this.toggle||this.tagsInput,"bottom",this.dropdownSpace,this.viewport)?(this.dropdown.classList.remove("bottom-full"),(null===(e=this.dropdownDirectionClasses)||void 0===e?void 0:e.bottom)&&this.dropdown.classList.remove(this.dropdownDirectionClasses.bottom),this.dropdown.style.marginBottom="",this.dropdown.classList.add("top-full"),(null===(t=this.dropdownDirectionClasses)||void 0===t?void 0:t.top)&&this.dropdown.classList.add(this.dropdownDirectionClasses.top),this.dropdown.style.marginTop=`${this.dropdownSpace}px`):(this.dropdown.classList.remove("top-full"),(null===(i=this.dropdownDirectionClasses)||void 0===i?void 0:i.top)&&this.dropdown.classList.remove(this.dropdownDirectionClasses.top),this.dropdown.style.marginTop="",this.dropdown.classList.add("bottom-full"),(null===(s=this.dropdownDirectionClasses)||void 0===s?void 0:s.bottom)&&this.dropdown.classList.add(this.dropdownDirectionClasses.bottom),this.dropdown.style.marginBottom=`${this.dropdownSpace}px`)}isOpened(){return this._isOpened||!1}containsElement(e){var t;return(null===(t=this.wrapper)||void 0===t?void 0:t.contains(e))||!1}static findInCollection(e){return window.$hsSelectCollection.find(t=>e instanceof d?t.element.el===e.el:"string"==typeof e?t.element.el===document.querySelector(e):t.element.el===e)||null}static getInstance(e,t){const i=window.$hsSelectCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element:null}static autoInit(){window.$hsSelectCollection||(window.$hsSelectCollection=[],window.addEventListener("click",e=>{const t=e.target;d.closeCurrentlyOpened(t)})),window.$hsSelectCollection&&(window.$hsSelectCollection=window.$hsSelectCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-select]:not(.--prevent-on-load-init)").forEach(e=>{if(!window.$hsSelectCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})){const t=e.getAttribute("data-select"),i=t?JSON.parse(t):{};new d(e,i)}})}static open(e){const t=d.findInCollection(e);t&&!t.element.isOpened()&&t.element.open()}static close(e){const t=d.findInCollection(e);t&&t.element.isOpened()&&t.element.close()}static closeCurrentlyOpened(e=null){if(!e.closest(".advance-select.active")&&!e.closest("[data-select-dropdown].opened")){const e=window.$hsSelectCollection.filter(e=>e.element.isOpened())||null;e&&e.forEach(e=>{e.element.close()})}}}window.addEventListener("load",()=>{d.autoInit()}),document.addEventListener("scroll",()=>{if(!window.$hsSelectCollection)return!1;const e=window.$hsSelectCollection.find(e=>e.element.isOpened());e&&e.element.recalculateDirection()}),"undefined"!=typeof window&&(window.HSSelect=d),t.default=d},977:function(e,t,i){ +/* + * HSRangeSlider + * @version: 3.2.3 + * @author: Preline Labs Ltd. + * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) + * Copyright 2024 Preline Labs Ltd. + */ +var s=this&&this.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(t,"__esModule",{value:!0});const n=s(i(287));class o extends n.default{constructor(e,t,i){var s;super(e,t,i);const n=e.getAttribute("data-range-slider"),o=n?JSON.parse(n):{};this.concatOptions=Object.assign(Object.assign(Object.assign({},o),t),{cssClasses:Object.assign(Object.assign({},noUiSlider.cssClasses),this.processClasses(o.cssClasses))}),this.wrapper=this.concatOptions.wrapper||e.closest(".range-slider-wrapper")||null,this.currentValue=this.concatOptions.currentValue?Array.from(this.concatOptions.currentValue):Array.from((null===(s=this.wrapper)||void 0===s?void 0:s.querySelectorAll(".range-slider-current-value"))||[]),this.icons=this.concatOptions.icons||{},this.init()}get formattedValue(){const e=this.el.noUiSlider.get();if(Array.isArray(e)&&this.format){const t=[];return e.forEach(e=>{t.push(this.format.to(e))}),t}return this.format?this.format.to(e):e}processClasses(e){const t={};return Object.keys(e).forEach(i=>{i&&(t[i]=`${noUiSlider.cssClasses[i]} ${e[i]}`)}),t}init(){var e,t,i,s,n,o,l,r,a,h,d,c,u;this.createCollection(window.$hsRangeSliderCollection,this),("object"==typeof(null===(e=this.concatOptions)||void 0===e?void 0:e.formatter)?"thousandsSeparatorAndDecimalPoints"===(null===(i=null===(t=this.concatOptions)||void 0===t?void 0:t.formatter)||void 0===i?void 0:i.type):"thousandsSeparatorAndDecimalPoints"===(null===(s=this.concatOptions)||void 0===s?void 0:s.formatter))?this.thousandsSeparatorAndDecimalPointsFormatter():("object"==typeof(null===(n=this.concatOptions)||void 0===n?void 0:n.formatter)?"integer"===(null===(l=null===(o=this.concatOptions)||void 0===o?void 0:o.formatter)||void 0===l?void 0:l.type):"integer"===(null===(r=this.concatOptions)||void 0===r?void 0:r.formatter))?this.integerFormatter():"object"==typeof(null===(a=this.concatOptions)||void 0===a?void 0:a.formatter)&&((null===(d=null===(h=this.concatOptions)||void 0===h?void 0:h.formatter)||void 0===d?void 0:d.prefix)||(null===(u=null===(c=this.concatOptions)||void 0===c?void 0:c.formatter)||void 0===u?void 0:u.postfix))&&this.prefixOrPostfixFormatter(),noUiSlider.create(this.el,this.concatOptions),this.currentValue&&this.currentValue.length>0&&this.el.noUiSlider.on("update",e=>{this.updateCurrentValue(e)}),this.concatOptions.disabled&&this.setDisabled(),this.icons.handle&&this.buildHandleIcon()}formatValue(e){var t,i,s,n,o,l,r,a,h;let d="";return"object"==typeof(null===(t=this.concatOptions)||void 0===t?void 0:t.formatter)?((null===(s=null===(i=this.concatOptions)||void 0===i?void 0:i.formatter)||void 0===s?void 0:s.prefix)&&(d+=null===(o=null===(n=this.concatOptions)||void 0===n?void 0:n.formatter)||void 0===o?void 0:o.prefix),d+=e,(null===(r=null===(l=this.concatOptions)||void 0===l?void 0:l.formatter)||void 0===r?void 0:r.postfix)&&(d+=null===(h=null===(a=this.concatOptions)||void 0===a?void 0:a.formatter)||void 0===h?void 0:h.postfix)):d+=e,d}integerFormatter(){var e;this.format={to:e=>this.formatValue(Math.round(e)),from:e=>Math.round(+e)},(null===(e=this.concatOptions)||void 0===e?void 0:e.tooltips)&&(this.concatOptions.tooltips=this.format)}prefixOrPostfixFormatter(){var e;this.format={to:e=>this.formatValue(e),from:e=>+e},(null===(e=this.concatOptions)||void 0===e?void 0:e.tooltips)&&(this.concatOptions.tooltips=this.format)}thousandsSeparatorAndDecimalPointsFormatter(){var e;this.format={to:e=>this.formatValue(new Intl.NumberFormat("en-US",{minimumFractionDigits:2,maximumFractionDigits:2}).format(e)),from:e=>parseFloat(e.replace(/,/g,""))},(null===(e=this.concatOptions)||void 0===e?void 0:e.tooltips)&&(this.concatOptions.tooltips=this.format)}setDisabled(){this.el.setAttribute("disabled","disabled"),this.el.classList.add("disabled")}buildHandleIcon(){if(!this.icons.handle)return!1;const e=this.el.querySelector(".noUi-handle");if(!e)return!1;e.innerHTML=this.icons.handle}updateCurrentValue(e){this.currentValue&&0!==this.currentValue.length&&e.forEach((e,t)=>{var i;const s=null===(i=this.currentValue)||void 0===i?void 0:i[t];if(!s)return;const n=this.format?this.format.to(e).toString():e.toString();s instanceof HTMLInputElement?s.value=n:s.textContent=n})}destroy(){this.el.noUiSlider.destroy(),this.format=null,window.$hsRangeSliderCollection=window.$hsRangeSliderCollection.filter(({element:e})=>e.el!==this.el)}static getInstance(e,t=!1){const i=window.$hsRangeSliderCollection.find(t=>t.element.el===("string"==typeof e?document.querySelector(e):e));return i?t?i:i.element.el:null}static autoInit(){window.$hsRangeSliderCollection||(window.$hsRangeSliderCollection=[]),window.$hsRangeSliderCollection&&(window.$hsRangeSliderCollection=window.$hsRangeSliderCollection.filter(({element:e})=>document.contains(e.el))),document.querySelectorAll("[data-range-slider]:not(.--prevent-on-load-init)").forEach(e=>{window.$hsRangeSliderCollection.find(t=>{var i;return(null===(i=null==t?void 0:t.element)||void 0===i?void 0:i.el)===e})||new o(e)})}}window.addEventListener("load",()=>{o.autoInit()}),"undefined"!=typeof window&&(window.HSRangeSlider=o),t.default=o}},t={};function i(s){var n=t[s];if(void 0!==n)return n.exports;var o=t[s]={exports:{}};return e[s].call(o.exports,o,o.exports,i),o.exports}return i.d=(e,t)=>{for(var s in t)i.o(t,s)&&!i.o(e,s)&&Object.defineProperty(e,s,{enumerable:!0,get:t[s]})},i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),i.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i(800)})()); \ No newline at end of file diff --git a/internal/webui/dist/index.html b/internal/webui/dist/index.html new file mode 100644 index 0000000..88fbc9e --- /dev/null +++ b/internal/webui/dist/index.html @@ -0,0 +1,229 @@ + + + + + + idsync · 飞书通讯录同步 + + + + + + + + + + + + + + + + + + + diff --git a/internal/webui/webui.go b/internal/webui/webui.go new file mode 100644 index 0000000..fd0547b --- /dev/null +++ b/internal/webui/webui.go @@ -0,0 +1,130 @@ +// Package webui 通过 go:embed 把前端资源打进二进制。 +// +// 前端用 Tailwind v4 + FlyonUI 构建:FlyonUI 官方不支持 CDN 方式 +// (样式在构建期生成),所以这里使用构建产物,用户无需 Node 环境。 +// 构建命令见 scripts/build-webui.sh,产物在 internal/webui/dist 下。 +package webui + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "fmt" + "io/fs" + "net/http" + "strings" +) + +// dist 是前端构建产物。 +// +//go:embed all:dist +var dist embed.FS + +// assets 保存资源名 → 内容;assetETags 保存内容指纹用于 ETag。 +var ( + assets map[string][]byte + assetETags map[string]string +) + +func init() { + fsys, err := fs.Sub(dist, "dist") + if err != nil { + return + } + assets = make(map[string][]byte, 8) + assetETags = make(map[string]string, 8) + + _ = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + data, err := fs.ReadFile(fsys, path) + if err != nil { + return nil + } + assets[path] = data + sum := sha256.Sum256(data) + assetETags[path] = `"` + hex.EncodeToString(sum[:8]) + `"` + return nil + }) +} + +// contentTypes 是各静态资源的 MIME 类型。 +var contentTypes = map[string]string{ + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".png": "image/png", + ".woff2": "font/woff2", +} + +// IndexHandler 输出内嵌的 index.html。 +// +// 刻意不缓存 HTML:它引用 app.js,一旦被缓存,升级二进制后浏览器仍会 +// 加载旧脚本,表现为界面数据莫名其妙地缺失或显示 undefined。 +func IndexHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + data, ok := assets["index.html"] + if !ok { + http.Error(w, "前端资源缺失,请先执行 make css", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(data) + } +} + +// AssetHandler 提供 /static/ 下的静态资源。 +// +// 用内容指纹做 ETag 并允许缓存,但每次回源校验(no-cache)。 +// 这样既有 304 的高效,又不会在升级二进制后继续使用旧资源—— +// 仅设 max-age 而不带指纹的策略在内嵌场景下会长期给出过期界面。 +func AssetHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/static/") + if name == "" { + http.NotFound(w, r) + return + } + + data, ok := assets[name] + if !ok { + http.NotFound(w, r) + return + } + + etag := assetETags[name] + w.Header().Set("ETag", etag) + w.Header().Set("Cache-Control", "no-cache") + + if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) { + w.WriteHeader(http.StatusNotModified) + return + } + + if ct, ok := contentTypes[extOf(name)]; ok { + w.Header().Set("Content-Type", ct) + } + w.Header().Set("Content-Length", fmt.Sprint(len(data))) + _, _ = w.Write(data) + } +} + +func extOf(name string) string { + if i := strings.LastIndex(name, "."); i >= 0 { + return name[i:] + } + return "" +} + +// AssetNames 返回已嵌入的资源名,供测试与健康检查使用。 +func AssetNames() []string { + out := make([]string, 0, len(assets)) + for name := range assets { + out = append(out, name) + } + return out +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..fbfb553 --- /dev/null +++ b/main.go @@ -0,0 +1,359 @@ +package main + +import ( + "context" + "encoding/base64" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/robfig/cron/v3" + + "idsync/internal/config" + "idsync/internal/feishu" + "idsync/internal/ldaplib" + "idsync/internal/server" + "idsync/internal/store" + "idsync/internal/syncer" +) + +// version 由构建参数注入。 +var version = "dev" + +func main() { + var ( + cfgPath = flag.String("config", "config.yml", "配置文件路径") + dryRun = flag.Bool("dry-run", false, "只计算并打印同步计划,不写入 LDAP") + applyAll = flag.Bool("apply", false, "直接执行同步(默认只做 dry-run)") + force = flag.Bool("force", false, "忽略停用数量阈值等安全护栏") + genSecret = flag.Bool("gen-secret", false, "生成一个会话密钥并退出") + hashPass = flag.String("hash-password", "", "为给定密码生成 bcrypt 哈希并退出") + checkCfg = flag.Bool("check-config", false, "打印解析后的有效配置(脱敏)并退出,不做任何连接") + ) + flag.Parse() + + if *genSecret { + s, err := server.GenerateSecret() + if err != nil { + fatal("生成密钥失败", err) + } + fmt.Println(s) + return + } + if *hashPass != "" { + h, err := server.HashPassword(*hashPass) + if err != nil { + fatal("生成哈希失败", err) + } + fmt.Println(h) + return + } + + // 配置自检:把解析后的有效值打出来(凭据脱敏)。 + // 用于在真正连接目录前确认关键项,尤其是分组形态—— + // 它必须与 Authelia 的 groups_filter 一致,否则分组会静默不可见。 + if *checkCfg { + cfg, err := config.Load(*cfgPath) + if err != nil { + fatal("配置加载失败", err) + } + printEffectiveConfig(cfg) + return + } + + cfg, err := config.Load(*cfgPath) + if err != nil { + fatal("配置加载失败", err) + } + + level := slog.LevelInfo + if cfg.System.Mode == "debug" { + level = slog.LevelDebug + } + log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + + if err := os.MkdirAll(cfg.System.DataDir, 0o755); err != nil { + fatal("创建数据目录失败", err) + } + + ldapClient, err := ldaplib.NewClient(cfg.Ldap, log) + if err != nil { + fatal("初始化 LDAP 客户端失败", err) + } + if err := ldapClient.Ping(); err != nil { + log.Warn("LDAP 连通性检查未通过,服务仍会启动", "err", err) + } else { + log.Info("LDAP 连接正常", "url", cfg.Ldap.URL, "base_dn", cfg.Ldap.BaseDN) + } + + feishuClient, err := feishu.New(feishu.Options{ + AppID: cfg.Feishu.AppID, + AppSecret: cfg.Feishu.AppSecret, + BaseURL: cfg.Feishu.BaseURL, + QPS: cfg.Feishu.QPS, + Timeout: time.Duration(cfg.Feishu.TimeoutSec) * time.Second, + }, log) + if err != nil { + fatal("初始化飞书客户端失败", err) + } + + db, err := store.Open(cfg.Database.Source) + if err != nil { + fatal("打开台账数据库失败", err) + } + defer db.Close() + + runner := syncer.NewRunner(cfg, feishuClient, ldapClient, db, log) + + // 命令行模式:跑一次同步然后退出,便于放进 crontab 或手工验证。 + if *dryRun || *applyAll { + runOnce(cfg, runner, *applyAll, *force, log) + return + } + + // 服务模式 + srv, err := buildServer(cfg, runner, db, ldapClient, feishuClient, log) + if err != nil { + fatal("初始化服务失败", err) + } + + httpSrv := &http.Server{ + Addr: cfg.Server.Listen, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Info("管理界面已启动", "addr", cfg.Server.Listen, "version", version) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + fatal("HTTP 服务异常退出", err) + } + }() + + if cfg.Sync.Enable { + scheduleSync(cfg, runner, log) + } else { + log.Info("定时同步未启用(sync.enable=false)") + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("正在关闭服务…") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpSrv.Shutdown(ctx); err != nil { + log.Error("关闭 HTTP 服务时出错", "err", err) + } + log.Info("已退出") +} + +// printEffectiveConfig 输出解析后的关键配置,凭据一律脱敏。 +func printEffectiveConfig(cfg *config.Config) { + mask := func(s string) string { + if s == "" { + return "(空)" + } + if len(s) <= 8 { + return "****" + } + return s[:4] + "****" + s[len(s)-4:] + } + + fmt.Println("===== 有效配置 =====") + fmt.Printf("飞书 app-id : %s\n", mask(cfg.Feishu.AppID)) + fmt.Printf("飞书 base-url : %s\n", orDefault(cfg.Feishu.BaseURL, "(默认 open.feishu.cn)")) + fmt.Printf("飞书 部门过滤 : %v\n", cfg.Feishu.DeptList) + fmt.Println() + fmt.Printf("LDAP url : %s\n", cfg.Ldap.URL) + fmt.Printf("LDAP base-dn : %s\n", cfg.Ldap.BaseDN) + fmt.Printf("LDAP admin-dn : %s\n", cfg.Ldap.AdminDN) + fmt.Printf("LDAP admin-pass : %s\n", mask(cfg.Ldap.AdminPass)) + fmt.Printf("LDAP StartTLS : %v\n", cfg.Ldap.StartTLS) + fmt.Println() + fmt.Printf("在职用户 OU : %s\n", cfg.UserOUDN()) + fmt.Printf("离职用户 OU : %s\n", cfg.InactiveOUDN()) + fmt.Printf("部门分组根 OU : %s\n", cfg.GroupRootDN()) + fmt.Println() + fmt.Printf("分组 objectClass : %s\n", cfg.Ldap.GroupObjectClass) + fmt.Printf("分组成员属性 : %s\n", cfg.Ldap.GroupMemberAttr) + fmt.Printf(" 对应 Authelia 过滤 : (&(%s={dn})(objectClass=%s))\n", + cfg.Ldap.GroupMemberAttr, cfg.Ldap.GroupObjectClass) + fmt.Println() + fmt.Printf("初始密码 : %s\n", orDefault(cfg.Ldap.InitialPassword, "(每个用户随机生成)")) + fmt.Printf("定时同步 : enable=%v schedule=%q dry-run=%v\n", + cfg.Sync.Enable, cfg.Sync.Schedule, cfg.Sync.DefaultDryRun) + fmt.Printf("停用阈值 : max=%d percent=%.0f%%\n", + cfg.Sync.MaxDeactivate, cfg.Sync.MaxDeactivatePercent*100) + + // 关键约束检查:离职 OU 必须在用户 OU 之外,否则 Authelia 仍能搜到离职用户。 + userDN, inactiveDN := cfg.UserOUDN(), cfg.InactiveOUDN() + if inactiveDN == userDN { + fmt.Println("\n⚠ 离职 OU 与在职 OU 相同,离职机制将失效") + } else if len(inactiveDN) > len(userDN) && + inactiveDN[len(inactiveDN)-len(userDN):] == userDN { + fmt.Println("\n⚠ 离职 OU 位于在职 OU 之下,Authelia 仍能搜到离职用户") + } else { + fmt.Println("\n✓ 离职 OU 位于在职 OU 之外(Authelia 搜不到离职用户)") + } +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} + +// runOnce 执行一次同步并打印计划,然后退出。 +func runOnce(cfg *config.Config, runner *syncer.Runner, apply bool, force bool, log *slog.Logger) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + plan, res, err := runner.Run(ctx, !apply, force, "manual") + if plan != nil { + printPlanTo(plan) + } + if err != nil { + fatal("同步失败", err) + } + + if !apply { + log.Info("dry-run 完成,未写入任何变更。使用 -apply 真正执行。") + return + } + + log.Info("同步完成", + "created", res.Created, "updated", res.Updated, "moved", res.Moved, + "added", res.Added, "removed", res.Removed, "failed", res.Failed) + for _, e := range res.Errors { + log.Error("操作失败详情", "detail", e) + } +} + +// buildServer 组装 HTTP 服务,并在缺少密钥/哈希时生成安全的替代值。 +func buildServer( + cfg *config.Config, + runner *syncer.Runner, + db *store.Store, + ldapClient *ldaplib.Client, + feishuClient *feishu.Client, + log *slog.Logger, +) (*server.Server, error) { + secret := cfg.Server.SessionSecret + if secret == "" { + // 未配置就随机生成:重启会让旧会话失效,这比内置一个固定默认密钥安全。 + generated, err := server.GenerateSecret() + if err != nil { + return nil, fmt.Errorf("生成会话密钥失败: %w", err) + } + secret = generated + log.Warn("未配置 server.session-secret,已生成临时密钥,重启后需重新登录", + "hint", "把它写入配置可保持会话", "session_secret", secret) + } + + var hashBytes []byte + if cfg.Server.AdminPasswordHash != "" { + b, err := base64.StdEncoding.DecodeString(cfg.Server.AdminPasswordHash) + if err != nil { + return nil, fmt.Errorf("server.admin-password-hash 不是合法的 base64: %w", err) + } + hashBytes = b + } + + auth, err := server.NewAuth( + cfg.Server.AdminUser, hashBytes, cfg.Server.AdminPassword, + []byte(secret), cfg.Server.SecureCookie, + ) + if err != nil { + return nil, err + } + + return server.New(&server.Deps{ + Cfg: cfg, + Auth: auth, + Store: db, + Runner: runner, + LDAP: ldapClient, + FeishuTest: func(ctx context.Context) error { + return feishuClient.Test(ctx) + }, + Log: log, + }), nil +} + +// scheduleSync 注册定时同步。 +// +// 使用 SkipIfStillRunning 避免上一轮未结束就被再次触发:一次同步涉及大量 +// 外部调用,重叠执行会互相干扰并放大飞书侧的频控压力。 +func scheduleSync(cfg *config.Config, runner *syncer.Runner, log *slog.Logger) { + c := cron.New( + cron.WithSeconds(), + cron.WithChain(cron.SkipIfStillRunning(cron.DefaultLogger)), + ) + + _, err := c.AddFunc(cfg.Sync.Schedule, func() { + dry := cfg.Sync.DefaultDryRun + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + + log.Info("定时同步开始", "dry_run", dry) + plan, res, err := runner.Run(ctx, dry, false, "schedule") + if err != nil { + log.Error("定时同步失败", "err", err) + return + } + if dry { + planned := 0 + if plan != nil { + planned = len(plan.Ops) + } + log.Info("定时同步为 dry-run 模式,未写入变更", "planned_ops", planned) + return + } + log.Info("定时同步完成", + "created", res.Created, "updated", res.Updated, + "moved", res.Moved, "failed", res.Failed) + }) + if err != nil { + log.Error("注册定时任务失败", "schedule", cfg.Sync.Schedule, "err", err) + return + } + + c.Start() + log.Info("定时同步已启用", "schedule", cfg.Sync.Schedule, "dry_run", cfg.Sync.DefaultDryRun) +} + +func printPlanTo(p *syncer.Plan) { + fmt.Println("===== 同步计划 =====") + s := p.Stats + fmt.Printf("新建用户 %d · 更新用户 %d · 迁入离职 %d · 恢复 %d\n", + s.CreateUser, s.UpdateUser, s.Deactivate, s.Reactivate) + fmt.Printf("新建分组 %d · 更新分组 %d · 加入分组 %d · 移出分组 %d · 无变化 %d\n", + s.CreateGroup, s.UpdateGroup, s.AddMember, s.RemoveMember, s.Unchanged) + + if len(p.Ops) > 0 { + fmt.Println("\n----- 操作明细 -----") + for _, op := range p.Ops { + fmt.Printf(" [%-16s] %-24s %s\n", op.Kind, op.Target, op.Detail) + } + } + if len(p.Warnings) > 0 { + fmt.Println("\n----- 需要注意 -----") + for _, w := range p.Warnings { + fmt.Printf(" ! %s\n", w) + } + } + fmt.Println() +} + +func fatal(msg string, err error) { + fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err) + os.Exit(1) +} diff --git a/scripts/build-webui.sh b/scripts/build-webui.sh new file mode 100755 index 0000000..43e21e6 --- /dev/null +++ b/scripts/build-webui.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# 构建 WebUI 资源,供 go:embed 使用。 +# +# FlyonUI 的样式必须在构建期由 Tailwind 生成(官方不支持 CDN 方式), +# 因此这一步需要 Node。产物会提交进仓库,使用者无需 Node 环境。 +set -e + +HERE=$(cd "$(dirname "$0")/.." && pwd) +OUT="$HERE/internal/webui/dist" + +PROXY="${PROXY:-http://127.0.0.1:10808}" +NODE_IMAGE="${NODE_IMAGE:-docker.io/library/node:20-alpine}" + +# Tailwind 不会创建输出目录,必须先建好。 +mkdir -p "$OUT" + +if command -v npx >/dev/null 2>&1; then + echo "使用本机 Node 构建" + cd "$HERE/web" + [ -d node_modules ] || npm install --no-fund --no-audit + npx @tailwindcss/cli -i src/input.css -o "$OUT/app.css" --minify +else + echo "本机无 Node,改用容器构建($NODE_IMAGE)" + # 挂载整个项目根目录,使容器内的输出路径与宿主一致。 + podman run --rm --pull=never --network=host \ + -e http_proxy="$PROXY" -e https_proxy="$PROXY" \ + -v "$HERE":/app -w /app/web "$NODE_IMAGE" \ + sh -c ' + set -e + [ -d node_modules ] || npm install --no-fund --no-audit >/dev/null 2>&1 + npx @tailwindcss/cli -i src/input.css -o /app/internal/webui/dist/app.css --minify + ' +fi + +# 拷贝静态资源;FlyonUI 的 JS 提供 tabs / modal 等交互组件。 +cp -f "$HERE/web/src/index.html" "$OUT/index.html" +cp -f "$HERE/web/src/app.js" "$OUT/app.js" +cp -f "$HERE/web/node_modules/flyonui/flyonui.js" "$OUT/flyonui.js" + +echo "WebUI 资源已生成于 internal/webui/dist" +ls -la "$OUT" diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..073c5f5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1169 @@ +{ + "name": "idsync-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "idsync-web", + "version": "1.0.0", + "devDependencies": { + "@tailwindcss/cli": "^4.1.11", + "flyonui": "^2.4.1", + "tailwindcss": "^4.1.11" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.3" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flyonui": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/flyonui/-/flyonui-2.4.1.tgz", + "integrity": "sha512-WgP/YP1nZXbEtRkl3ThJIcIaisXOyON3zS2Qz2IwikQA/2X/PmqnP6E+eOz7WLBzEQI4ec7wViYAcRf9vljcbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4227eec --- /dev/null +++ b/web/package.json @@ -0,0 +1,15 @@ +{ + "name": "idsync-web", + "private": true, + "version": "1.0.0", + "description": "idsync 管理界面样式构建", + "scripts": { + "build": "tailwindcss -i src/input.css -o dist/app.css --minify", + "dev": "tailwindcss -i src/input.css -o dist/app.css --watch" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.1.11", + "flyonui": "^2.4.1", + "tailwindcss": "^4.1.11" + } +} diff --git a/web/src/app.js b/web/src/app.js new file mode 100644 index 0000000..8465f3f --- /dev/null +++ b/web/src/app.js @@ -0,0 +1,423 @@ +'use strict'; + +// idsync 管理界面。刻意不引入前端框架:页面数量少、状态简单, +// 原生 JS 足以胜任,且省去一整套构建链与依赖升级负担。 + +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +// 操作类型的中文标签,与后端 syncer.OpKind 对应。 +const OP_LABELS = { + create_user: '新建用户', + update_user: '更新用户', + deactivate_user: '迁入离职 OU', + reactivate_user: '恢复用户', + create_group: '新建分组', + update_group: '更新分组', + add_member: '加入分组', + remove_member: '移出分组', +}; + +const OP_BADGES = { + create_user: 'badge-success', + create_group: 'badge-success', + update_user: 'badge-info', + update_group: 'badge-info', + reactivate_user: 'badge-warning', + deactivate_user: 'badge-error', + add_member: 'badge-success', + remove_member: 'badge-error', +}; + +// ---------------------------------------------------------------- 主题 + +function initTheme() { + const saved = localStorage.getItem('idsync-theme'); + if (saved === 'light' || saved === 'dark') { + document.documentElement.dataset.theme = saved; + } + $('#themeToggle').addEventListener('click', () => { + const root = document.documentElement; + const next = root.dataset.theme === 'dark' ? 'light' : 'dark'; + root.dataset.theme = next; + localStorage.setItem('idsync-theme', next); + }); +} + +// ---------------------------------------------------------------- HTTP + +async function api(path, options = {}) { + const res = await fetch(path, { + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + + if (res.status === 401) { + showLogin(); + throw new Error('登录状态已失效'); + } + + let body = null; + try { + body = await res.json(); + } catch (_) { + // 非 JSON 响应(如上游超时返回的 HTML),下面按状态码统一处理 + } + + if (!res.ok) { + throw new Error((body && body.error) || `请求失败(HTTP ${res.status})`); + } + return body; +} + +function showError(msg) { + const el = $('#globalError'); + el.textContent = msg; + el.classList.remove('hidden'); + setTimeout(() => el.classList.add('hidden'), 8000); +} + +function hideError() { + $('#globalError').classList.add('hidden'); +} + +// ---------------------------------------------------------------- 视图切换 + +function showLogin() { + $('#loginView').classList.remove('hidden'); + $('#loginView').classList.add('flex'); + $('#appView').classList.add('hidden'); +} + +function showApp() { + $('#loginView').classList.add('hidden'); + $('#loginView').classList.remove('flex'); + $('#appView').classList.remove('hidden'); +} + +function switchView(name) { + $$('.view').forEach((el) => el.classList.add('hidden')); + const view = $(`#view-${name}`); + if (view) view.classList.remove('hidden'); + + $$('.tabs .tab').forEach((t) => { + t.classList.toggle('tab-active', t.dataset.view === name); + }); + + if (name === 'overview') loadStatus(); + if (name === 'users') loadUsers(); + if (name === 'groups') loadGroups(); + if (name === 'plan') loadPlan(); + if (name === 'runs') loadRuns(); +} + +// ---------------------------------------------------------------- 登录 + +async function doLogin() { + const errEl = $('#loginError'); + errEl.classList.add('hidden'); + + try { + await api('/api/login', { + method: 'POST', + body: JSON.stringify({ username: $('#username').value, password: $('#password').value }), + }); + showApp(); + switchView('overview'); + } catch (err) { + errEl.textContent = err.message; + errEl.classList.remove('hidden'); + } +} + +async function doLogout() { + try { + await api('/api/logout', { method: 'POST' }); + } catch (_) { + // 登出失败也要回到登录页,本地状态不该依赖服务端响应 + } + showLogin(); +} + +// ---------------------------------------------------------------- 概览 + +async function loadStatus() { + try { + const d = await api('/api/status'); + hideError(); + + $('#statActive').textContent = d.counts.activeUsers; + $('#statInactive').textContent = d.counts.inactiveUsers; + $('#statGroups').textContent = d.counts.groups; + + $('#connStatus').innerHTML = [ + statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url), + statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`), + ].join(''); + + $('#lastRunInfo').innerHTML = d.lastRun + ? `
+
时间:${fmtTime(d.lastRun.finishedAt)}
+
模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}
+
变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}
+
` + : '暂无成功的同步记录'; + } catch (err) { + showError(err.message); + } +} + +function statusRow(name, ok, error, detail) { + const badge = ok + ? '正常' + : '异常'; + const extra = ok + ? `${escapeHtml(detail || '')}` + : `${escapeHtml(error || '')}`; + return `
+ ${name}${badge} + ${extra} +
`; +} + +// ---------------------------------------------------------------- 用户 + +let usersCache = []; + +async function loadUsers() { + try { + const d = await api('/api/users'); + usersCache = d.users || []; + renderUsers(); + hideError(); + } catch (err) { + showError(err.message); + } +} + +function renderUsers() { + const kw = $('#userFilter').value.trim().toLowerCase(); + const rows = usersCache.filter((u) => { + if (!kw) return true; + return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw)); + }); + + $('#userRows').innerHTML = rows.map((u) => ` + + ${escapeHtml(u.uid)} + ${escapeHtml(u.name || '')} + ${escapeHtml(u.email || '')} + ${u.state === 'inactive' + ? '离职' + : '在职'} + ${(u.deptIds || '').split(',').filter(Boolean).length} + ${fmtTime(u.lastSyncedAt)} + ${u.drift + ? `需关注` + : '—'} + ${escapeHtml(u.dn)} + `).join(''); + + $('#userEmpty').classList.toggle('hidden', rows.length > 0); +} + +// ---------------------------------------------------------------- 分组 + +async function loadGroups() { + try { + const d = await api('/api/groups'); + const groups = d.groups || []; + $('#groupRows').innerHTML = groups.map((g) => ` + + ${escapeHtml(g.name || '')} + ${escapeHtml(g.deptId || '')} + ${escapeHtml(g.parentId || '—')} + ${escapeHtml(g.dn || '')} + `).join(''); + $('#groupEmpty').classList.toggle('hidden', groups.length > 0); + hideError(); + } catch (err) { + showError(err.message); + } +} + +// ---------------------------------------------------------------- 变更预览 + +let currentPlan = null; + +async function loadPlan() { + $('#planRows').innerHTML = ''; + $('#planSummary').innerHTML = ''; + $('#planWarnings').classList.add('hidden'); + $('#planEmpty').classList.add('hidden'); + + try { + const plan = await api('/api/plan'); + currentPlan = plan; + renderPlan(plan); + hideError(); + } catch (err) { + showError(err.message); + } +} + +function renderPlan(plan) { + const s = plan.stats || {}; + + const cards = [ + ['新建用户', s.createUser, 'text-success'], + ['更新用户', s.updateUser, 'text-info'], + ['迁入离职', s.deactivateUser, 'text-error'], + ['恢复用户', s.reactivateUser, 'text-warning'], + ['新建分组', s.createGroup, 'text-success'], + ['加入分组', s.addMember, 'text-success'], + ['移出分组', s.removeMember, 'text-error'], + ]; + + $('#planSummary').innerHTML = cards.map(([label, value, cls]) => ` +
+
+ ${label} + ${value || 0} +
+
`).join(''); + + const warnings = plan.warnings || []; + if (warnings.length > 0) { + const el = $('#planWarnings'); + el.innerHTML = `
+
+
需要注意 ${warnings.length} 项
+
    + ${warnings.map((w) => `
  • ${escapeHtml(w)}
  • `).join('')} +
+
+
`; + el.classList.remove('hidden'); + } + + const ops = plan.ops || []; + $('#planRows').innerHTML = ops.map((op) => ` + + ${OP_LABELS[op.kind] || op.kind} + ${escapeHtml(op.target)} + ${escapeHtml(op.detail)} + `).join(''); + + $('#planEmpty').classList.toggle('hidden', ops.length > 0); +} + +async function applyPlan(force = false) { + const modal = document.getElementById('applyModal'); + if (modal && typeof modal.close === 'function') modal.close(); + + try { + const d = await api('/api/sync', { + method: 'POST', + body: JSON.stringify({ dryRun: false, force }), + }); + const res = d.result || {}; + showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`); + $('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4'; + switchView('runs'); + } catch (err) { + showError(err.message); + } +} + +// ---------------------------------------------------------------- 同步历史 + +async function loadRuns() { + try { + const d = await api('/api/runs'); + const runs = d.runs || []; + $('#runRows').innerHTML = runs.map((r) => ` + + ${fmtTime(r.startedAt)} + ${r.mode === 'dry-run' + ? '预览' + : '执行'} + ${r.trigger === 'schedule' ? '定时' : '手动'} + ${r.status === 'success' + ? '成功' + : '失败'} + ${r.created}${r.updated}${r.moved} + ${r.added}${r.removed} + ${r.failed > 0 ? `${r.failed}` : '0'} + `).join(''); + $('#runEmpty').classList.toggle('hidden', runs.length > 0); + hideError(); + } catch (err) { + showError(err.message); + } +} + +// ---------------------------------------------------------------- 工具 + +function fmtTime(s) { + if (!s) return '—'; + const d = new Date(s); + if (isNaN(d.getTime())) return '—'; + const p = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + +function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// ---------------------------------------------------------------- 启动 + +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + + $('#loginBtn').addEventListener('click', doLogin); + $('#password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); + $('#username').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); }); + $('#logoutBtn').addEventListener('click', doLogout); + + $$('.tabs .tab').forEach((t) => { + t.addEventListener('click', () => switchView(t.dataset.view)); + }); + + $('#userFilter').addEventListener('input', renderUsers); + $('#reloadUsers').addEventListener('click', loadUsers); + $('#reloadRuns').addEventListener('click', loadRuns); + $('#previewBtn').addEventListener('click', loadPlan); + + $('#applyBtn').addEventListener('click', () => { + if (!currentPlan) return; + const s = currentPlan.stats || {}; + const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0) + + (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0); + if (total === 0) { + showError('没有需要应用的变更。'); + return; + } + $('#applyModalBody').innerHTML = ` +
+
新建用户 ${s.createUser || 0} · 更新用户 ${s.updateUser || 0}
+
迁入离职 OU ${s.deactivateUser || 0} · 恢复用户 ${s.reactivateUser || 0}
+
新建分组 ${s.createGroup || 0} · 加入分组 ${s.addMember || 0} · 移出分组 ${s.removeMember || 0}
+
`; + const modal = document.getElementById('applyModal'); + if (modal && typeof modal.showModal === 'function') modal.showModal(); + }); + + $('#applyCancel').addEventListener('click', () => { + const modal = document.getElementById('applyModal'); + if (modal && typeof modal.close === 'function') modal.close(); + }); + $('#applyConfirm').addEventListener('click', () => applyPlan(false)); + + // 已有会话则直接进入主界面 + api('/api/status') + .then(() => { showApp(); switchView('overview'); }) + .catch(() => showLogin()); +}); diff --git a/web/src/index.html b/web/src/index.html new file mode 100644 index 0000000..88fbc9e --- /dev/null +++ b/web/src/index.html @@ -0,0 +1,229 @@ + + + + + + idsync · 飞书通讯录同步 + + + + + + + + + + + + + + + + + + + diff --git a/web/src/input.css b/web/src/input.css new file mode 100644 index 0000000..a3ea88e --- /dev/null +++ b/web/src/input.css @@ -0,0 +1,55 @@ +@import "tailwindcss"; + +/* FlyonUI 组件与主题。 + * + * 必须走构建期插件(官方不支持 CDN 方式):FlyonUI 的样式在编译时生成, + * npm 包里那份预编译 CSS 不含 flex/grid/spacing 等布局工具类, + * 而 Tailwind 的浏览器版明确拒绝加载插件。因此这里用 Tailwind CLI 产出完整样式表, + * 构建产物再被 go:embed 打进二进制,部署时无需 Node。 */ +@plugin "flyonui"; + +/* 让 Tailwind 扫描页面与脚本,只保留实际用到的类。 */ +@source "../src/*.html"; +@source "../src/*.js"; +@source "../node_modules/flyonui/dist/index.js"; + +/* 自定义主题:白/暗底色 + Gitea 绿主色。 + * + * 命名为 light / dark 是刻意的——主题插件会把自定义 token 与同名的内置主题 + * 合并,因此这里只需覆盖主色,其余语义色沿用 FlyonUI 的默认值。 + * 主色取 Gitea logo 绿 #609926(注意 Gitea 自身的 --color-primary 是蓝色, + * 它的品牌绿其实是 logo 色),暗色下用 #7ab63a 以保证对比度。 */ +@plugin "flyonui/theme" { + name: "light"; + default: true; + color-scheme: light; + + --color-primary: #609926; + --color-primary-content: #ffffff; + + --color-success: #21ba45; + --color-success-content: #ffffff; +} + +@plugin "flyonui/theme" { + name: "dark"; + color-scheme: dark; + + --color-primary: #7ab63a; + --color-primary-content: #101d05; + + --color-success: #46de6a; + --color-success-content: #071505; +} + +/* 少量自定义样式:表格可横向滚动,长 DN 不撑破布局。 */ +@layer components { + .idsync-dn { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + word-break: break-all; + } + .idsync-scroll { + overflow-x: auto; + } +}