diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 56cb55418..ca81383ba 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -330,6 +330,19 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: |------|------|------| | GET | `/api/v1/admin/audit-logs` | 审计日志查询 | +### 平台设置(需 SUPER_ADMIN) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/admin/settings/personal-namespace` | 读取「新账号自动建命名空间」策略 | +| PUT | `/api/v1/admin/settings/personal-namespace` | 更新该策略(写审计日志) | +| POST | `/api/v1/admin/settings/personal-namespace/backfill` | 为已有账号补建;`dryRun=true` 只返回计划,不写库 | +| GET | `/api/v1/admin/settings/default-namespaces` | 读取「新账号默认加入的命名空间」列表 | +| PUT | `/api/v1/admin/settings/default-namespaces` | 更新该列表(slug 必须存在且为 ACTIVE;写审计日志)| +| POST | `/api/v1/admin/settings/default-namespaces/backfill` | 把已有账号补加入这些命名空间;`dryRun=true` 只返回计划 | + +详见 [`2026-08-13-personal-namespace-provisioning.md`](./2026-08-13-personal-namespace-provisioning.md)。 + ## 7.7 Namespace 管理 API(需命名空间 OWNER 或 ADMIN) | 方法 | 路径 | 说明 | diff --git a/docs/2026-08-13-personal-namespace-provisioning.md b/docs/2026-08-13-personal-namespace-provisioning.md new file mode 100644 index 000000000..f22d276ed --- /dev/null +++ b/docs/2026-08-13-personal-namespace-provisioning.md @@ -0,0 +1,176 @@ +# 注册时自动创建个人命名空间 + +## 背景 + +自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能, +而不必先向管理员申请命名空间、也不必把半成品塞进 `global`。 + +在此之前 SkillHub 没有任何「全局设置」机制——只有按用户维度的通知偏好, +凡是部署级开关都只能靠配置文件加环境变量,改一次要重启。 +本次改动同时补上这两块:一个通用的设置存储,和第一个使用它的功能。 + +## 一、通用设置存储(`system_setting`) + +```sql +CREATE TABLE system_setting ( + setting_key VARCHAR(128) PRIMARY KEY, + setting_value JSONB NOT NULL, + updated_by VARCHAR(128) REFERENCES user_account(id), + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +一行存一组设置,值是 JSON 文档,因此一组设置增加字段不需要新的迁移。 + +`SystemSettingService` 的读取接口强制调用方传入默认值: + +```java + T get(String settingKey, Class type, T defaults) +``` + +这带来两个性质: + +- **管理员没动过的设置组不存在数据库行**,读取时回落到部署的配置文件默认值。 + 纯配置化的部署可以完全不碰控制台,行为与本功能上线前一致。 +- **存量文档解析失败时同样回落到默认值**,并打一条 WARN 日志。 + 一行损坏的设置不应该让登录这种关键路径挂掉。 + +设置组用 `@JsonIgnoreProperties(ignoreUnknown = true)`, +滚动升级时旧节点读到新节点写入的文档不会报错。 + +## 二、自动创建个人命名空间 + +### 「私有」在当前模型里的含义 + +命名空间没有可见性字段——只有 `GLOBAL` 和 `TEAM` 两种类型, +技能的可见性是技能自己的属性。因此这里的「私有命名空间」= **一个只有本人为成员的 TEAM 命名空间**。 +本人拿到的是 `OWNER` 角色(比 `ADMIN` 更强:可以改设置、管成员、删除)。 + +如果要做到「别人搜不到这个命名空间」,那是独立的 namespace visibility 特性,不在本次范围内。 + +### 触发时机 + +在账号**第一次变得可用**时触发,共三处,均发布 `UserActivatedEvent`: + +| 入口 | 位置 | +|------|------| +| 本地注册 | `LocalAuthService.register` | +| 外部身份首次登录 | `IdentityBindingService.bindOrCreate`(仅 `initialStatus == ACTIVE`) | +| 管理员审批 / 解封 | `AdminUserAppService.updateUserStatus`(仅从非 ACTIVE 转为 ACTIVE) | + +第三处不可省略:开启了准入审批的部署里,用户在 OAuth 首次尝试时就以 `PENDING` 建号, +真正可用是在管理员审批那一刻。 + +### 为什么走事件 + AFTER_COMMIT + +`PersonalNamespaceProvisioningListener` 用 `@TransactionalEventListener` +(默认 AFTER_COMMIT)并在自己的事务里建命名空间。原因是数据库约束: + +``` +namespace.created_by REFERENCES user_account(id) +namespace_member.user_id REFERENCES user_account(id) +``` + +- 如果**加入注册事务**:命名空间创建失败(例如 slug 竞态撞唯一约束)会把注册一起回滚, + 用户会因为「命名空间没建成」而登不上来。 +- 如果在注册事务中**用 `REQUIRES_NEW` 挂起**:新事务看不到尚未提交的 `user_account` 行, + 外键检查会阻塞在外层事务的行锁上,形成互等。 + +放到提交之后就同时避开了这两点:账号已经落库,建命名空间失败只损失一个命名空间, +监听器捕获异常并记 WARN。 + +监听器**不加 `@Async`**:命名空间要在用户下一个请求到达前就绪。 + +### 命名模板 + +两个模板,占位符语法 `${...}`: + +| 占位符 | 取值 | +|--------|------| +| `${username}` | 认证路径提供的用户名;缺失时依次回落到邮箱前缀、用户 ID | +| `${email_prefix}` | 邮箱 `@` 之前的部分 | +| `${user_id}` | 平台内部用户 ID | + +未知占位符原样保留,让拼错的名字暴露出来,而不是静默消失。 + +slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、 +字母数字以外的字符变连字符、去掉首尾与重复连字符。 +**注意下划线不合法**——`${username}_space` 会得到 `alice-space`。 +控制台有实时预览,就是为了让这条规则在保存前可见。 + +冲突处理:候选 slug 若非法(保留字如 `admin`、长度不足)或已被占用, +依次尝试 `-2`、`-3`……最多 64 次;全部失败则跳过并记 WARN。 +`admin` 这类保留字因此自然落到 `admin-2`。 + +幂等:用户若已经拥有任意非 GLOBAL 命名空间,直接跳过。 +解封会再次发布 `UserActivatedEvent`,靠这条保证不会重复发一个命名空间。 + +### 已有账号的补建 + +只在「账号第一次变得可用」触发有个后果:**在一个已经跑了一段时间的部署上打开开关,等于对现有的人全部无效**。 +这不是理论问题——本功能上线后第一个来问「为什么我没有 namespace」的,就是站点管理员自己。 + +所以提供 `POST /api/v1/admin/settings/personal-namespace/backfill`: + +- 遍历 ACTIVE 账号,跳过系统账号和已拥有非 global 命名空间的账号 +- `dryRun=true` 时只返回计划(每个账号将拿到的 slug),不写任何东西; + 控制台强制先预览、后执行 +- 返回体只列出「会被改动」和「放不下」的账号,其余只给计数—— + 管理员看到的是待办,不是整个通讯录 +- 单次运行有账号数上限,达到上限时返回 `truncated=true` 而不是假装跑完了 +- 一次运行内已经许诺出去的 slug 会被预留,避免同一批里把同一个 slug 发给两个人 +- **不加 `@Transactional`**:每个命名空间各自一个事务, + 某个账号放不下不会把整批已建好的回滚掉 + +### 可诊断性 + +三条跳过路径——开关关闭、账号已有命名空间、没有可用 slug——都记 INFO/WARN 日志。 +最初的实现里前两条是静默返回的,结果就是「什么都没发生,也查不出为什么」。 +账号激活本身是低频事件,多两行日志的代价可以忽略。 + +## 二·五、全员默认加入的命名空间 + +「自动建一个自己的命名空间」解决的是个人空间;另一个相邻问题是**组织级公共空间**。 + +部署方新建一个命名空间来代替内置的 `global` 时会发现它对所有人不可见—— +`listNamespaces` 只返回调用者是成员的命名空间,而「新账号自动入伙」这件事 +原本写死在 `GlobalNamespaceMembershipService` 里,只认 slug `global`。 + +所以把它一般化为 `DefaultNamespaceMembershipService`: + +- 设置项 `namespace.default-membership` 存一个 slug 列表,默认 `["global"]`, + 即改造前的行为 +- 保存时校验每个 slug 存在且为 ACTIVE,让拼错在保存那一刻就暴露, + 而不是变成某个人首次登录时的一条警告 +- 运行期遇到已被删除或改名的 slug 只记 WARN 并跳过—— + 一个不存在的命名空间不该让人登不上来 +- 同样配了预览 + 执行的补建,把存量账号一次性加进去 + +发布只要求「是该命名空间的成员」(任意角色),所以加入即可发布, +不需要额外授予角色。 + +## 三、配置 + +| 位置 | 项 | 默认 | +|------|-----|------| +| `application.yml` | `skillhub.namespace.personal-provisioning.enabled` | `false` | +| 控制台 | 启用开关、slug 模板、显示名模板 | `${username}` | + +**默认关闭**:升级不应该让现有部署突然开始建命名空间。 + +模板刻意**不放在 `application.yml`**:它们含 `${...}`, +Spring 会当成属性占位符去解析(Boot 3.2 / Framework 6.1 尚不支持转义 `\${`)。 +模板的默认值写在 `PersonalNamespaceProvisioningProperties` 的 Java 字段里, +运行期改动走控制台。 + +## 四、审计 + +`PUT /api/v1/admin/settings/personal-namespace` 写一条审计日志, +action 为 `SYSTEM_SETTING_PERSONAL_NAMESPACE_UPDATE`,target type `SYSTEM_SETTING`, +detail 中包含改动前后的完整设置。 + +## 五、后续可以复用的地方 + +`system_setting` 是通用的。最直接的下一个使用者是 +[#318](https://github.com/iflytek/skillhub/issues/318)(管理员开关本地注册)—— +目前只能靠在网关层挡 `/api/v1/auth/local/register`。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSystemSettingController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSystemSettingController.java new file mode 100644 index 000000000..1118cc451 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSystemSettingController.java @@ -0,0 +1,106 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.BackfillRequest; +import com.iflytek.skillhub.dto.DefaultNamespaceBackfillResponse; +import com.iflytek.skillhub.dto.DefaultNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.DefaultNamespaceSettingsUpdateRequest; +import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.DefaultNamespaceSettingsAppService; +import com.iflytek.skillhub.service.PersonalNamespaceSettingsAppService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Platform-wide settings an operator can change without redeploying. + */ +@RestController +@RequestMapping("/api/v1/admin/settings") +public class AdminSystemSettingController extends BaseApiController { + + private final PersonalNamespaceSettingsAppService personalNamespaceSettingsAppService; + private final DefaultNamespaceSettingsAppService defaultNamespaceSettingsAppService; + + public AdminSystemSettingController(PersonalNamespaceSettingsAppService personalNamespaceSettingsAppService, + DefaultNamespaceSettingsAppService defaultNamespaceSettingsAppService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.personalNamespaceSettingsAppService = personalNamespaceSettingsAppService; + this.defaultNamespaceSettingsAppService = defaultNamespaceSettingsAppService; + } + + @GetMapping("/personal-namespace") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse getPersonalNamespaceSettings() { + return ok("response.success.read", personalNamespaceSettingsAppService.get()); + } + + @PutMapping("/personal-namespace") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse updatePersonalNamespaceSettings( + @Valid @RequestBody PersonalNamespaceSettingsUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", personalNamespaceSettingsAppService.update( + request, principal.userId(), AuditRequestContext.from(httpRequest))); + } + + /** + * Gives existing accounts the namespace they would have received had provisioning been on when + * they first signed in. Send {@code dryRun} to see the plan first. + */ + @PostMapping("/personal-namespace/backfill") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse backfillPersonalNamespaces( + @Valid @RequestBody BackfillRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success", personalNamespaceSettingsAppService.backfill( + request, principal.userId(), AuditRequestContext.from(httpRequest))); + } + + @GetMapping("/default-namespaces") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse getDefaultNamespaces() { + return ok("response.success.read", defaultNamespaceSettingsAppService.get()); + } + + @PutMapping("/default-namespaces") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse updateDefaultNamespaces( + @Valid @RequestBody DefaultNamespaceSettingsUpdateRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", defaultNamespaceSettingsAppService.update( + request, principal.userId(), AuditRequestContext.from(httpRequest))); + } + + /** + * Enrols existing accounts in the configured default namespaces, for when one is added after + * people have already signed up. Send {@code dryRun} to see the plan first. + */ + @PostMapping("/default-namespaces/backfill") + @PreAuthorize("hasRole('SUPER_ADMIN')") + public ApiResponse backfillDefaultNamespaces( + @Valid @RequestBody BackfillRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success", defaultNamespaceSettingsAppService.backfill( + Boolean.TRUE.equals(request.dryRun()), principal.userId(), + AuditRequestContext.from(httpRequest))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BackfillRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BackfillRequest.java new file mode 100644 index 000000000..353dd9281 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/BackfillRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotNull; + +/** + * Shared body for the admin backfill endpoints. + * + * @param dryRun when true, report what would happen without writing anything + */ +public record BackfillRequest(@NotNull Boolean dryRun) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceBackfillResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceBackfillResponse.java new file mode 100644 index 000000000..fe1856365 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceBackfillResponse.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * @param truncated the run stopped at its per-run account cap; re-run to continue + * @param entries only the accounts that were enrolled, or would be + */ +public record DefaultNamespaceBackfillResponse( + boolean dryRun, + int scannedAccounts, + int alreadyEnrolled, + int systemAccountsSkipped, + boolean truncated, + List entries) { + + public record Entry(String userId, String displayName, List slugs) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsResponse.java new file mode 100644 index 000000000..f276cc65c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * @param slugs namespaces every newly activated account is enrolled in + */ +public record DefaultNamespaceSettingsResponse(List slugs) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsUpdateRequest.java new file mode 100644 index 000000000..a7aca30c9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/DefaultNamespaceSettingsUpdateRequest.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** + * @param slugs may be empty, which means new accounts are enrolled nowhere + */ +public record DefaultNamespaceSettingsUpdateRequest( + @NotNull @Size(max = 20) List<@Size(max = 64) String> slugs +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillResponse.java new file mode 100644 index 000000000..4b81391ab --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceBackfillResponse.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * @param truncated the run stopped at its per-run account cap; re-run to continue + * @param entries only the accounts that were changed, or could not be placed + */ +public record PersonalNamespaceBackfillResponse( + boolean dryRun, + int scannedAccounts, + int alreadyProvisioned, + int systemAccountsSkipped, + boolean truncated, + List entries) { + + /** + * @param outcome one of {@code PLANNED}, {@code CREATED}, {@code NO_SLUG} + */ + public record Entry(String userId, String displayName, String slug, String outcome) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsResponse.java new file mode 100644 index 000000000..d09dbd8c8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * @param supportedPlaceholders placeholder names the templates accept, so the console can document + * them without hard-coding the list + */ +public record PersonalNamespaceSettingsResponse( + boolean enabled, + String slugTemplate, + String displayNameTemplate, + List supportedPlaceholders +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsUpdateRequest.java new file mode 100644 index 000000000..331d6a4c7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PersonalNamespaceSettingsUpdateRequest.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PersonalNamespaceSettingsUpdateRequest( + @NotNull Boolean enabled, + @NotBlank @Size(max = 128) String slugTemplate, + @NotBlank @Size(max = 128) String displayNameTemplate +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/PersonalNamespaceProvisioningListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/PersonalNamespaceProvisioningListener.java new file mode 100644 index 000000000..94f99d11b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/PersonalNamespaceProvisioningListener.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.event.UserActivatedEvent; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceOwner; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Creates a newly activated account's own namespace once the account itself is committed. + * + *

Runs synchronously rather than on the event executor so the namespace exists by the time the + * user's next request arrives, and swallows failures so a naming clash or a database hiccup costs + * the user a namespace rather than their registration or login. + */ +@Component +public class PersonalNamespaceProvisioningListener { + + private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningListener.class); + + private final PersonalNamespaceProvisioningService personalNamespaceProvisioningService; + + public PersonalNamespaceProvisioningListener( + PersonalNamespaceProvisioningService personalNamespaceProvisioningService) { + this.personalNamespaceProvisioningService = personalNamespaceProvisioningService; + } + + @TransactionalEventListener + public void onUserActivated(UserActivatedEvent event) { + try { + personalNamespaceProvisioningService.provisionFor( + new PersonalNamespaceOwner(event.userId(), event.username(), event.email())); + } catch (RuntimeException e) { + log.warn("Personal namespace provisioning failed for user {}; the account is unaffected", + event.userId(), e); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index b3e1bdc74..703dbdc59 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.repository.RoleRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -18,6 +19,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -44,16 +46,19 @@ public class AdminUserAppService { private final UserAccountRepository userAccountRepository; private final UserRoleBindingRepository userRoleBindingRepository; private final RoleRepository roleRepository; + private final ApplicationEventPublisher eventPublisher; public AdminUserAppService( AdminUserSearchRepository adminUserSearchRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, - RoleRepository roleRepository) { + RoleRepository roleRepository, + ApplicationEventPublisher eventPublisher) { this.adminUserSearchRepository = adminUserSearchRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; this.roleRepository = roleRepository; + this.eventPublisher = eventPublisher; } @Transactional(readOnly = true) @@ -109,8 +114,13 @@ public AdminUserMutationResponse updateUserStatus(String userId, String status) UserAccount user = loadUser(userId); rejectSystemAccountMutation(user); UserStatus nextStatus = parseManageableStatus(status); + UserStatus previousStatus = user.getStatus(); user.setStatus(nextStatus); userAccountRepository.save(user); + if (nextStatus == UserStatus.ACTIVE && previousStatus != UserStatus.ACTIVE) { + eventPublisher.publishEvent( + new UserActivatedEvent(user.getId(), user.getDisplayName(), user.getEmail())); + } return new AdminUserMutationResponse(user.getId(), null, nextStatus.name()); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DefaultNamespaceSettingsAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DefaultNamespaceSettingsAppService.java new file mode 100644 index 000000000..a35ed25aa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/DefaultNamespaceSettingsAppService.java @@ -0,0 +1,118 @@ +package com.iflytek.skillhub.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceBackfillReport; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceSettings; +import com.iflytek.skillhub.dto.DefaultNamespaceBackfillResponse; +import com.iflytek.skillhub.dto.DefaultNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.DefaultNamespaceSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Exposes the "namespaces every new account joins" policy to the admin console. + */ +@Service +public class DefaultNamespaceSettingsAppService { + + private static final String AUDIT_TARGET_TYPE = "SYSTEM_SETTING"; + private static final String AUDIT_ACTION_UPDATE = "SYSTEM_SETTING_DEFAULT_NAMESPACES_UPDATE"; + private static final String AUDIT_ACTION_BACKFILL = "SYSTEM_SETTING_DEFAULT_NAMESPACES_BACKFILL"; + + private final DefaultNamespaceMembershipService defaultNamespaceMembershipService; + private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; + private final ObjectMapper objectMapper; + + public DefaultNamespaceSettingsAppService( + DefaultNamespaceMembershipService defaultNamespaceMembershipService, + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor, + ObjectMapper objectMapper) { + this.defaultNamespaceMembershipService = defaultNamespaceMembershipService; + this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public DefaultNamespaceSettingsResponse get() { + return new DefaultNamespaceSettingsResponse( + defaultNamespaceMembershipService.currentSettings().slugs()); + } + + @Transactional + public DefaultNamespaceSettingsResponse update(DefaultNamespaceSettingsUpdateRequest request, + String actorUserId, + AuditRequestContext auditContext) { + DefaultNamespaceSettings previous = defaultNamespaceMembershipService.currentSettings(); + DefaultNamespaceSettings updated = defaultNamespaceMembershipService.updateSettings( + new DefaultNamespaceSettings(request.slugs()), actorUserId); + + Map detail = new LinkedHashMap<>(); + detail.put("before", previous.slugs()); + detail.put("after", updated.slugs()); + record(actorUserId, auditContext, AUDIT_ACTION_UPDATE, detail); + return new DefaultNamespaceSettingsResponse(updated.slugs()); + } + + /** + * A dry run writes nothing and is not audited; an applied run records who it enrolled. + */ + public DefaultNamespaceBackfillResponse backfill(boolean dryRun, + String actorUserId, + AuditRequestContext auditContext) { + DefaultNamespaceBackfillReport report = defaultNamespaceMembershipService.backfill(dryRun); + + if (!dryRun) { + Map detail = new LinkedHashMap<>(); + detail.put("scannedAccounts", report.scannedAccounts()); + detail.put("alreadyEnrolled", report.alreadyEnrolled()); + detail.put("truncated", report.truncated()); + detail.put("enrolled", report.entries().stream() + .map(entry -> Map.of("userId", entry.userId(), "slugs", entry.slugs())) + .toList()); + record(actorUserId, auditContext, AUDIT_ACTION_BACKFILL, detail); + } + + return new DefaultNamespaceBackfillResponse( + report.dryRun(), + report.scannedAccounts(), + report.alreadyEnrolled(), + report.systemAccountsSkipped(), + report.truncated(), + report.entries().stream() + .map(entry -> new DefaultNamespaceBackfillResponse.Entry( + entry.userId(), entry.displayName(), entry.slugs())) + .toList()); + } + + private void record(String actorUserId, + AuditRequestContext auditContext, + String action, + Map detail) { + auditLogService.record( + actorUserId, + action, + AUDIT_TARGET_TYPE, + null, + requestIdAccessor.current(), + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + toJson(detail)); + } + + private String toJson(Map detail) { + try { + return objectMapper.writeValueAsString(detail); + } catch (Exception e) { + return null; + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java new file mode 100644 index 000000000..e47a03ac1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppService.java @@ -0,0 +1,164 @@ +package com.iflytek.skillhub.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillEntry; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillReport; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings; +import com.iflytek.skillhub.dto.BackfillRequest; +import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Exposes the personal-namespace provisioning policy to the admin console. + */ +@Service +public class PersonalNamespaceSettingsAppService { + + private static final String AUDIT_TARGET_TYPE = "SYSTEM_SETTING"; + private static final String AUDIT_ACTION_UPDATE = "SYSTEM_SETTING_PERSONAL_NAMESPACE_UPDATE"; + private static final String AUDIT_ACTION_BACKFILL = "SYSTEM_SETTING_PERSONAL_NAMESPACE_BACKFILL"; + + private static final List SUPPORTED_PLACEHOLDERS = List.of( + PersonalNamespaceSettings.PLACEHOLDER_USERNAME, + PersonalNamespaceSettings.PLACEHOLDER_EMAIL_PREFIX, + PersonalNamespaceSettings.PLACEHOLDER_USER_ID); + + private final PersonalNamespaceProvisioningService personalNamespaceProvisioningService; + private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; + private final ObjectMapper objectMapper; + + public PersonalNamespaceSettingsAppService( + PersonalNamespaceProvisioningService personalNamespaceProvisioningService, + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor, + ObjectMapper objectMapper) { + this.personalNamespaceProvisioningService = personalNamespaceProvisioningService; + this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; + this.objectMapper = objectMapper; + } + + @Transactional(readOnly = true) + public PersonalNamespaceSettingsResponse get() { + return toResponse(personalNamespaceProvisioningService.currentSettings()); + } + + @Transactional + public PersonalNamespaceSettingsResponse update(PersonalNamespaceSettingsUpdateRequest request, + String actorUserId, + AuditRequestContext auditContext) { + PersonalNamespaceSettings previous = personalNamespaceProvisioningService.currentSettings(); + PersonalNamespaceSettings updated = new PersonalNamespaceSettings( + Boolean.TRUE.equals(request.enabled()), + request.slugTemplate().trim(), + request.displayNameTemplate().trim()); + + personalNamespaceProvisioningService.updateSettings(updated, actorUserId); + recordAudit(actorUserId, auditContext, previous, updated); + return toResponse(updated); + } + + /** + * Runs the backfill over existing accounts. A dry run writes nothing and is not audited; an + * applied run records what it created. + */ + public PersonalNamespaceBackfillResponse backfill(BackfillRequest request, + String actorUserId, + AuditRequestContext auditContext) { + boolean dryRun = Boolean.TRUE.equals(request.dryRun()); + PersonalNamespaceBackfillReport report = personalNamespaceProvisioningService.backfill(dryRun); + + if (!dryRun) { + recordBackfillAudit(actorUserId, auditContext, report); + } + return new PersonalNamespaceBackfillResponse( + report.dryRun(), + report.scannedAccounts(), + report.alreadyProvisioned(), + report.systemAccountsSkipped(), + report.truncated(), + report.entries().stream() + .map(entry -> new PersonalNamespaceBackfillResponse.Entry( + entry.userId(), entry.displayName(), entry.slug(), entry.outcome().name())) + .toList()); + } + + private void recordBackfillAudit(String actorUserId, + AuditRequestContext auditContext, + PersonalNamespaceBackfillReport report) { + Map detail = new LinkedHashMap<>(); + detail.put("scannedAccounts", report.scannedAccounts()); + detail.put("alreadyProvisioned", report.alreadyProvisioned()); + detail.put("truncated", report.truncated()); + detail.put("created", report.entries().stream() + .filter(entry -> entry.outcome() == PersonalNamespaceBackfillEntry.Outcome.CREATED) + .map(entry -> Map.of("userId", entry.userId(), "slug", entry.slug())) + .toList()); + detail.put("unplaced", report.entries().stream() + .filter(entry -> entry.outcome() == PersonalNamespaceBackfillEntry.Outcome.NO_SLUG) + .map(PersonalNamespaceBackfillEntry::userId) + .toList()); + auditLogService.record( + actorUserId, + AUDIT_ACTION_BACKFILL, + AUDIT_TARGET_TYPE, + null, + requestIdAccessor.current(), + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + toJson(detail)); + } + + private PersonalNamespaceSettingsResponse toResponse(PersonalNamespaceSettings settings) { + return new PersonalNamespaceSettingsResponse( + settings.enabled(), + settings.slugTemplate(), + settings.displayNameTemplate(), + SUPPORTED_PLACEHOLDERS); + } + + private void recordAudit(String actorUserId, + AuditRequestContext auditContext, + PersonalNamespaceSettings previous, + PersonalNamespaceSettings updated) { + Map detail = new LinkedHashMap<>(); + detail.put("before", describe(previous)); + detail.put("after", describe(updated)); + auditLogService.record( + actorUserId, + AUDIT_ACTION_UPDATE, + AUDIT_TARGET_TYPE, + null, + requestIdAccessor.current(), + auditContext != null ? auditContext.clientIp() : null, + auditContext != null ? auditContext.userAgent() : null, + toJson(detail)); + } + + private Map describe(PersonalNamespaceSettings settings) { + Map described = new LinkedHashMap<>(); + described.put("enabled", settings.enabled()); + described.put("slugTemplate", settings.slugTemplate()); + described.put("displayNameTemplate", settings.displayNameTemplate()); + return described; + } + + private String toJson(Map detail) { + try { + return objectMapper.writeValueAsString(detail); + } catch (Exception e) { + return null; + } + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 10e9638eb..3744d1795 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -116,6 +116,20 @@ skillhub: code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M} email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local} email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub} + namespace: + # Whether a newly activated account gets a namespace of its own. An administrator can override + # this from the admin console, and the stored choice then wins over this file. + # + # The slug and display-name templates are deliberately not configurable here: they contain + # ${...} placeholders, which Spring would try to resolve as property references. Set them in + # the admin console instead; their defaults live in PersonalNamespaceProvisioningProperties. + personal-provisioning: + enabled: ${SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED:false} + # Namespaces every newly activated account is enrolled in. The built-in global namespace is + # the historical behaviour; an administrator can change the list in the admin console, and the + # stored choice then wins over this file. + default-membership: + slugs: ${SKILLHUB_NAMESPACE_DEFAULT_MEMBERSHIP_SLUGS:global} public: base-url: ${SKILLHUB_PUBLIC_BASE_URL:} access-policy: diff --git a/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql b/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql new file mode 100644 index 000000000..11f01954b --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V44__system_setting.sql @@ -0,0 +1,15 @@ +-- V44__system_setting.sql +-- +-- Operator-configurable platform settings. +-- +-- Each row holds one setting group as a JSON document so a group can gain +-- fields without a schema migration. A row is written only when an operator +-- overrides a group; an absent row means "use the configured defaults", which +-- keeps configuration-file-only deployments working unchanged. + +CREATE TABLE system_setting ( + setting_key VARCHAR(128) PRIMARY KEY, + setting_value JSONB NOT NULL, + updated_by VARCHAR(128) REFERENCES user_account(id), + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 8791e6be3..6dcf494a3 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -76,6 +76,8 @@ error.namespace.membership.required=Namespace membership required error.namespace.global.members.platformAdmin.required=Only platform user administrators can list global namespace members error.namespace.admin.required=Namespace owner or admin role required error.namespace.owner.required=Namespace owner role required +error.namespace.defaultMembership.unknownSlug=Namespace ''{0}'' does not exist +error.namespace.defaultMembership.inactiveSlug=Namespace ''{0}'' is not active error.namespace.create.platformAdminRequired=Only SKILL_ADMIN or SUPER_ADMIN can create namespaces error.namespace.delete.hasDependencies=Namespace cannot be deleted while it still contains skills or governance records error.namespace.member.owner.assignDirect=Cannot assign OWNER role directly diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 0e1b3fc33..91297887a 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -76,6 +76,8 @@ error.namespace.membership.required=需要先加入该命名空间 error.namespace.global.members.platformAdmin.required=只有平台用户管理员可以查看 global 命名空间成员 error.namespace.admin.required=需要命名空间管理员或所有者权限 error.namespace.owner.required=需要命名空间所有者权限 +error.namespace.defaultMembership.unknownSlug=命名空间 ''{0}'' 不存在 +error.namespace.defaultMembership.inactiveSlug=命名空间 ''{0}'' 不是启用状态 error.namespace.create.platformAdminRequired=只有 SKILL_ADMIN 或 SUPER_ADMIN 可以创建命名空间 error.namespace.delete.hasDependencies=命名空间下仍有技能或治理记录,暂时不能删除 error.namespace.member.owner.assignDirect=不能直接分配 OWNER 角色 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 8296f940e..4ea162a92 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.repository.AdminUserSearchRepository; import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -35,11 +36,13 @@ class AdminUserAppServiceTest { private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class); private final RoleRepository roleRepository = mock(RoleRepository.class); private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); private final AdminUserAppService service = new AdminUserAppService( adminUserSearchRepository, userAccountRepository, userRoleBindingRepository, - roleRepository + roleRepository, + eventPublisher ); @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppServiceTest.java new file mode 100644 index 000000000..fa8a2faae --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PersonalNamespaceSettingsAppServiceTest.java @@ -0,0 +1,171 @@ +package com.iflytek.skillhub.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillEntry; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceBackfillReport; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService; +import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings; +import com.iflytek.skillhub.dto.BackfillRequest; +import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse; +import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PersonalNamespaceSettingsAppServiceTest { + + @Mock + private PersonalNamespaceProvisioningService personalNamespaceProvisioningService; + + @Mock + private AuditLogService auditLogService; + + @Mock + private RequestIdAccessor requestIdAccessor; + + private PersonalNamespaceSettingsAppService service; + + @BeforeEach + void setUp() { + service = new PersonalNamespaceSettingsAppService( + personalNamespaceProvisioningService, + auditLogService, + requestIdAccessor, + new ObjectMapper()); + when(requestIdAccessor.current()).thenReturn("req-1"); + } + + @Test + void getExposesTheEffectiveSettingsAndSupportedPlaceholders() { + when(personalNamespaceProvisioningService.currentSettings()) + .thenReturn(new PersonalNamespaceSettings(true, "${username}", "${username}")); + + PersonalNamespaceSettingsResponse response = service.get(); + + assertThat(response.enabled()).isTrue(); + assertThat(response.slugTemplate()).isEqualTo("${username}"); + assertThat(response.supportedPlaceholders()) + .containsExactly("username", "email_prefix", "user_id"); + } + + @Test + void updateTrimsTemplatesBeforeStoringThem() { + when(personalNamespaceProvisioningService.currentSettings()) + .thenReturn(new PersonalNamespaceSettings(false, "${username}", "${username}")); + + service.update( + new PersonalNamespaceSettingsUpdateRequest(true, " ${username}-space ", " ${username} "), + "usr_admin", + new AuditRequestContext("10.0.0.1", "curl/8")); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(PersonalNamespaceSettings.class); + verify(personalNamespaceProvisioningService).updateSettings(captor.capture(), eq("usr_admin")); + assertThat(captor.getValue().enabled()).isTrue(); + assertThat(captor.getValue().slugTemplate()).isEqualTo("${username}-space"); + assertThat(captor.getValue().displayNameTemplate()).isEqualTo("${username}"); + } + + @Test + void updateRecordsAnAuditEntryWithBeforeAndAfter() { + when(personalNamespaceProvisioningService.currentSettings()) + .thenReturn(new PersonalNamespaceSettings(false, "${username}", "${username}")); + + service.update( + new PersonalNamespaceSettingsUpdateRequest(true, "${username}-space", "${username}"), + "usr_admin", + new AuditRequestContext("10.0.0.1", "curl/8")); + + ArgumentCaptor detailCaptor = ArgumentCaptor.forClass(String.class); + verify(auditLogService).record( + eq("usr_admin"), + eq("SYSTEM_SETTING_PERSONAL_NAMESPACE_UPDATE"), + eq("SYSTEM_SETTING"), + isNull(), + eq("req-1"), + eq("10.0.0.1"), + eq("curl/8"), + detailCaptor.capture()); + assertThat(detailCaptor.getValue()) + .contains("\"before\"") + .contains("\"after\"") + .contains("${username}-space"); + } + + @Test + void backfillDryRunIsNotAudited() { + when(personalNamespaceProvisioningService.backfill(true)).thenReturn( + new PersonalNamespaceBackfillReport(true, 3, 1, 0, false, List.of( + new PersonalNamespaceBackfillEntry("usr_1", "alice", "alice", + PersonalNamespaceBackfillEntry.Outcome.PLANNED)))); + + PersonalNamespaceBackfillResponse response = service.backfill( + new BackfillRequest(true), "usr_admin", null); + + assertThat(response.dryRun()).isTrue(); + assertThat(response.entries()).singleElement() + .satisfies(entry -> assertThat(entry.outcome()).isEqualTo("PLANNED")); + verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void backfillRecordsWhatItCreated() { + when(personalNamespaceProvisioningService.backfill(false)).thenReturn( + new PersonalNamespaceBackfillReport(false, 3, 1, 0, false, List.of( + new PersonalNamespaceBackfillEntry("usr_1", "alice", "alice", + PersonalNamespaceBackfillEntry.Outcome.CREATED), + new PersonalNamespaceBackfillEntry("usr_2", "admin", null, + PersonalNamespaceBackfillEntry.Outcome.NO_SLUG)))); + + service.backfill(new BackfillRequest(false), "usr_admin", + new AuditRequestContext("10.0.0.1", "curl/8")); + + ArgumentCaptor detailCaptor = ArgumentCaptor.forClass(String.class); + verify(auditLogService).record( + eq("usr_admin"), + eq("SYSTEM_SETTING_PERSONAL_NAMESPACE_BACKFILL"), + eq("SYSTEM_SETTING"), + isNull(), + eq("req-1"), + eq("10.0.0.1"), + eq("curl/8"), + detailCaptor.capture()); + assertThat(detailCaptor.getValue()) + .contains("\"scannedAccounts\":3") + .contains("usr_1") + .contains("\"unplaced\":[\"usr_2\"]"); + } + + @Test + void updateToleratesAMissingAuditContext() { + when(personalNamespaceProvisioningService.currentSettings()) + .thenReturn(new PersonalNamespaceSettings(false, "${username}", "${username}")); + + service.update( + new PersonalNamespaceSettingsUpdateRequest(false, "${username}", "${username}"), + "usr_admin", + null); + + verify(auditLogService).record(any(), any(), any(), isNull(), any(), isNull(), isNull(), any()); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java index 2a4fae8b3..2c4d80bf8 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -6,10 +6,12 @@ import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; -import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.UUID; @@ -26,16 +28,19 @@ public class IdentityBindingService { private final IdentityBindingRepository bindingRepo; private final UserAccountRepository userRepo; private final UserRoleBindingRepository roleBindingRepo; - private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final DefaultNamespaceMembershipService defaultNamespaceMembershipService; + private final ApplicationEventPublisher eventPublisher; public IdentityBindingService(IdentityBindingRepository bindingRepo, UserAccountRepository userRepo, UserRoleBindingRepository roleBindingRepo, - GlobalNamespaceMembershipService globalNamespaceMembershipService) { + DefaultNamespaceMembershipService defaultNamespaceMembershipService, + ApplicationEventPublisher eventPublisher) { this.bindingRepo = bindingRepo; this.userRepo = userRepo; this.roleBindingRepo = roleBindingRepo; - this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.defaultNamespaceMembershipService = defaultNamespaceMembershipService; + this.eventPublisher = eventPublisher; } @Transactional @@ -64,7 +69,9 @@ public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStat user.setStatus(initialStatus); user = userRepo.save(user); if (initialStatus == UserStatus.ACTIVE) { - globalNamespaceMembershipService.ensureMember(user.getId()); + defaultNamespaceMembershipService.ensureMember(user.getId()); + eventPublisher.publishEvent( + new UserActivatedEvent(user.getId(), claims.providerLogin(), claims.email())); } binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin()); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index df0b1867b..ad0bc5c5f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -4,7 +4,8 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.rbac.PlatformRoleDefaults; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; -import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -16,6 +17,7 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -40,25 +42,28 @@ public class LocalAuthService { private final LocalCredentialRepository credentialRepository; private final UserAccountRepository userAccountRepository; private final UserRoleBindingRepository userRoleBindingRepository; - private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final DefaultNamespaceMembershipService defaultNamespaceMembershipService; private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; private final Clock clock; + private final ApplicationEventPublisher eventPublisher; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, - GlobalNamespaceMembershipService globalNamespaceMembershipService, + DefaultNamespaceMembershipService defaultNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, PasswordEncoder passwordEncoder, - Clock clock) { + Clock clock, + ApplicationEventPublisher eventPublisher) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; - this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.defaultNamespaceMembershipService = defaultNamespaceMembershipService; this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; this.clock = clock; + this.eventPublisher = eventPublisher; } /** @@ -99,7 +104,8 @@ public PlatformPrincipal register(String username, String password, String email normalizedUsername, passwordEncoder.encode(password) )); - globalNamespaceMembershipService.ensureMember(user.getId()); + defaultNamespaceMembershipService.ensureMember(user.getId()); + eventPublisher.publishEvent(new UserActivatedEvent(user.getId(), normalizedUsername, normalizedEmail)); return buildPrincipal(user); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java index 79e5df746..b84991616 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityBindingServiceTest.java @@ -16,7 +16,8 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; -import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -29,6 +30,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -44,13 +46,17 @@ class IdentityBindingServiceTest { private UserRoleBindingRepository roleBindingRepo; @Mock - private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private DefaultNamespaceMembershipService defaultNamespaceMembershipService; + + @Mock + private ApplicationEventPublisher eventPublisher; private IdentityBindingService service; @BeforeEach void setUp() { - service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService); + service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, + defaultNamespaceMembershipService, eventPublisher); } @Test @@ -71,12 +77,50 @@ void bindOrCreate_assignsGlobalMembershipForActiveNewUsers() { ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); verify(userRepo).save(userCaptor.capture()); - verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + verify(defaultNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); verify(bindingRepo).save(any(IdentityBinding.class)); assertThat(principal.displayName()).isEqualTo("alice"); assertThat(principal.oauthProvider()).isEqualTo("github"); } + @Test + void bindOrCreate_publishesActivationForActiveNewUsers() { + OAuthClaims claims = new OAuthClaims( + "github", + "gh_1", + "alice@example.com", + true, + "alice", + Map.of() + ); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty()); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + service.bindOrCreate(claims, UserStatus.ACTIVE); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserActivatedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().username()).isEqualTo("alice"); + assertThat(eventCaptor.getValue().email()).isEqualTo("alice@example.com"); + } + + @Test + void bindOrCreate_doesNotPublishActivationForReturningUsers() { + OAuthClaims claims = new OAuthClaims("github", "gh_1", "alice@example.com", true, "alice", Map.of()); + UserAccount existing = new UserAccount("usr_1", "alice", "alice@example.com", null); + existing.setStatus(UserStatus.ACTIVE); + when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")) + .thenReturn(Optional.of(new IdentityBinding("usr_1", "github", "gh_1", "alice"))); + when(userRepo.findById("usr_1")).thenReturn(Optional.of(existing)); + when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(roleBindingRepo.findByUserId(any())).thenReturn(List.of()); + + service.bindOrCreate(claims, UserStatus.ACTIVE); + + verify(eventPublisher, never()).publishEvent(any(UserActivatedEvent.class)); + } + @Test void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() { OAuthClaims claims = new OAuthClaims( @@ -93,7 +137,7 @@ void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() { assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.PENDING)) .isInstanceOf(AccountPendingException.class); - verify(globalNamespaceMembershipService, never()).ensureMember(any()); + verify(defaultNamespaceMembershipService, never()).ensureMember(any()); } @Test diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index b6eaf5afc..96a1dd32c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -13,7 +13,8 @@ import com.iflytek.skillhub.auth.entity.Role; import com.iflytek.skillhub.auth.entity.UserRoleBinding; import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; -import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; +import com.iflytek.skillhub.domain.event.UserActivatedEvent; +import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; @@ -28,6 +29,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.security.crypto.password.PasswordEncoder; @@ -46,11 +48,14 @@ class LocalAuthServiceTest { private UserRoleBindingRepository userRoleBindingRepository; @Mock - private GlobalNamespaceMembershipService globalNamespaceMembershipService; + private DefaultNamespaceMembershipService defaultNamespaceMembershipService; @Mock private PasswordEncoder passwordEncoder; + @Mock + private ApplicationEventPublisher eventPublisher; + private LocalAuthService service; @BeforeEach @@ -59,10 +64,11 @@ void setUp() { credentialRepository, userAccountRepository, userRoleBindingRepository, - globalNamespaceMembershipService, + defaultNamespaceMembershipService, new PasswordPolicyValidator(), passwordEncoder, - CLOCK + CLOCK, + eventPublisher ); } @@ -83,7 +89,23 @@ void register_createsUserAndCredential() { assertThat(principal.email()).isEqualTo("alice@example.com"); assertThat(principal.platformRoles()).containsExactly("USER"); verify(credentialRepository).save(any(LocalCredential.class)); - verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + verify(defaultNamespaceMembershipService).ensureMember(userCaptor.getValue().getId()); + } + + @Test + void register_publishesActivationWithTheNormalizedUsername() { + given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); + given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.empty()); + given(passwordEncoder.encode("Abcd123!")).willReturn("encoded"); + given(userAccountRepository.save(any(UserAccount.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(userRoleBindingRepository.findByUserId(any())).willReturn(List.of()); + + service.register("Alice", "Abcd123!", "alice@example.com"); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(UserActivatedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertThat(eventCaptor.getValue().username()).isEqualTo("alice"); + assertThat(eventCaptor.getValue().email()).isEqualTo("alice@example.com"); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/UserActivatedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/UserActivatedEvent.java new file mode 100644 index 000000000..4af259bd1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/UserActivatedEvent.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.domain.event; + +/** + * Published when an account becomes usable — local registration, the first login through an + * external identity provider, or an administrator approving or re-enabling an account. + * + *

Listeners must be idempotent: re-enabling a previously disabled account publishes the event + * again. + * + * @param username the name the authentication path knows the user by, or {@code null} + */ +public record UserActivatedEvent(String userId, String username, String email) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillEntry.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillEntry.java new file mode 100644 index 000000000..b2b88339a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillEntry.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.List; + +/** + * One account a default-namespace backfill enrolled, or would enrol. + * + * @param slugs the default namespaces the account is not yet a member of + */ +public record DefaultNamespaceBackfillEntry(String userId, String displayName, List slugs) { + + public DefaultNamespaceBackfillEntry { + slugs = slugs == null ? List.of() : List.copyOf(slugs); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillReport.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillReport.java new file mode 100644 index 000000000..ce2311417 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceBackfillReport.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.List; + +/** + * Outcome of enrolling existing accounts in the configured default namespaces. + * + * @param truncated the run stopped at its per-run account cap; re-run to continue + * @param entries only the accounts that were enrolled, or would be + */ +public record DefaultNamespaceBackfillReport( + boolean dryRun, + int scannedAccounts, + int alreadyEnrolled, + int systemAccountsSkipped, + boolean truncated, + List entries) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipService.java new file mode 100644 index 000000000..608a80823 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipService.java @@ -0,0 +1,178 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.setting.SystemSettingService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Enrolls newly active users in the namespaces the operator has designated as defaults. + * + *

This used to be hard-wired to the built-in {@code global} namespace. Deployments that stand up + * their own organisation-wide namespace found it invisible to everyone, because the namespace + * listing only returns namespaces the caller belongs to and nothing ever added them. + */ +@Service +public class DefaultNamespaceMembershipService { + + public static final String SETTING_KEY = "namespace.default-membership"; + + private static final int MAX_BACKFILL_ACCOUNTS = 5000; + private static final int BACKFILL_PAGE_SIZE = 200; + + private static final Logger log = LoggerFactory.getLogger(DefaultNamespaceMembershipService.class); + + private final SystemSettingService systemSettingService; + private final DefaultNamespaceProperties defaults; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + + public DefaultNamespaceMembershipService(SystemSettingService systemSettingService, + DefaultNamespaceProperties defaults, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { + this.systemSettingService = systemSettingService; + this.defaults = defaults; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + } + + public DefaultNamespaceSettings currentSettings() { + return systemSettingService.get(SETTING_KEY, DefaultNamespaceSettings.class, defaults.toSettings()); + } + + /** + * Stores the operator's choice after checking every slug resolves to a live namespace, so a + * typo surfaces here rather than as a warning on somebody's first login. + */ + @Transactional + public DefaultNamespaceSettings updateSettings(DefaultNamespaceSettings settings, String actorUserId) { + Set normalized = new LinkedHashSet<>(); + for (String slug : settings.slugs()) { + String trimmed = slug == null ? "" : slug.trim(); + if (trimmed.isEmpty()) { + continue; + } + Namespace namespace = namespaceRepository.findBySlug(trimmed) + .orElseThrow(() -> new DomainBadRequestException( + "error.namespace.defaultMembership.unknownSlug", trimmed)); + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException( + "error.namespace.defaultMembership.inactiveSlug", trimmed); + } + normalized.add(trimmed); + } + return systemSettingService.put( + SETTING_KEY, new DefaultNamespaceSettings(List.copyOf(normalized)), actorUserId); + } + + /** + * Adds {@code userId} to every configured default namespace it is not already in. + * + *

A slug that no longer resolves is logged and skipped: a namespace that was renamed or + * deleted must not cost somebody their registration. + */ + @Transactional + public void ensureMember(String userId) { + for (String slug : currentSettings().slugs()) { + Optional namespace = namespaceRepository.findBySlug(slug); + if (namespace.isEmpty()) { + log.warn("Default namespace '{}' does not exist; skipping enrolment for user {}", slug, userId); + continue; + } + join(namespace.get(), userId); + } + } + + /** + * Enrolls existing accounts in the configured defaults, for when an operator adds a namespace + * after people have already signed up. + * + *

Not {@code @Transactional}: each account is enrolled on its own, so one failure does not + * discard the rest of the run. + */ + public DefaultNamespaceBackfillReport backfill(boolean dryRun) { + List targets = new ArrayList<>(); + for (String slug : currentSettings().slugs()) { + namespaceRepository.findBySlug(slug).ifPresentOrElse( + targets::add, + () -> log.warn("Default namespace '{}' does not exist; excluded from backfill", slug)); + } + + List entries = new ArrayList<>(); + int scanned = 0; + int alreadyMember = 0; + int systemAccounts = 0; + boolean truncated = false; + + for (int page = 0; !truncated && !targets.isEmpty(); page++) { + Page batch = userAccountRepository.findByStatus(UserStatus.ACTIVE, + PageRequest.of(page, BACKFILL_PAGE_SIZE, Sort.by("id"))); + if (batch.isEmpty()) { + break; + } + for (UserAccount user : batch) { + if (scanned >= MAX_BACKFILL_ACCOUNTS) { + truncated = true; + break; + } + scanned++; + if (user.isSystemAccount()) { + systemAccounts++; + continue; + } + List missing = targets.stream() + .filter(namespace -> namespaceMemberRepository + .findByNamespaceIdAndUserId(namespace.getId(), user.getId()).isEmpty()) + .map(Namespace::getSlug) + .toList(); + if (missing.isEmpty()) { + alreadyMember++; + continue; + } + if (!dryRun) { + targets.stream() + .filter(namespace -> missing.contains(namespace.getSlug())) + .forEach(namespace -> join(namespace, user.getId())); + } + entries.add(new DefaultNamespaceBackfillEntry(user.getId(), user.getDisplayName(), missing)); + } + if (!batch.hasNext()) { + break; + } + } + + log.info("Default namespace backfill ({}): scanned {}, already enrolled {}, acted on {}{}", + dryRun ? "dry run" : "applied", scanned, alreadyMember, entries.size(), + truncated ? ", stopped at the per-run cap" : ""); + return new DefaultNamespaceBackfillReport( + dryRun, scanned, alreadyMember, systemAccounts, truncated, List.copyOf(entries)); + } + + /** + * Idempotent. Called from {@link #ensureMember} inside the caller's transaction, and from the + * backfill outside one, where each save commits on its own. + */ + private void join(Namespace namespace, String userId) { + namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), userId) + .orElseGet(() -> namespaceMemberRepository.save( + new NamespaceMember(namespace.getId(), userId, NamespaceRole.MEMBER))); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceProperties.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceProperties.java new file mode 100644 index 000000000..4cead4655 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceProperties.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * Deployment default for {@link DefaultNamespaceSettings}, used until an administrator saves a + * choice in the admin console. + * + *

Defaults to the built-in global namespace, which is what every deployment did before this was + * configurable. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.namespace.default-membership") +public class DefaultNamespaceProperties { + + private List slugs = new ArrayList<>(List.of("global")); + + public List getSlugs() { + return slugs; + } + + public void setSlugs(List slugs) { + this.slugs = slugs; + } + + public DefaultNamespaceSettings toSettings() { + return new DefaultNamespaceSettings(slugs); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceSettings.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceSettings.java new file mode 100644 index 000000000..f4ac928d2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceSettings.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.List; + +/** + * The namespaces every newly activated account is enrolled in, as member. + * + *

A deployment that outgrows the built-in {@code global} namespace — say it wants an + * organisation-wide space of its own — needs to say so somewhere, because a namespace nobody is a + * member of is invisible: the namespace listing only returns namespaces the caller belongs to. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record DefaultNamespaceSettings(List slugs) { + + public DefaultNamespaceSettings { + slugs = slugs == null ? List.of() : List.copyOf(slugs); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java deleted file mode 100644 index 1ef7d2470..000000000 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipService.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.iflytek.skillhub.domain.namespace; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -/** - * Ensures newly active users belong to the built-in global namespace. - */ -@Service -public class GlobalNamespaceMembershipService { - - private static final String GLOBAL_NAMESPACE_SLUG = "global"; - - private final NamespaceRepository namespaceRepository; - private final NamespaceMemberRepository namespaceMemberRepository; - - public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository) { - this.namespaceRepository = namespaceRepository; - this.namespaceMemberRepository = namespaceMemberRepository; - } - - @Transactional - public void ensureMember(String userId) { - Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG) - .orElseThrow(() -> new IllegalStateException("Missing built-in global namespace")); - - namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId) - .orElseGet(() -> namespaceMemberRepository.save( - new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER) - )); - } -} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillEntry.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillEntry.java new file mode 100644 index 000000000..944c273f9 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillEntry.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.domain.namespace; + +/** + * One account a backfill run acted on, or wanted to act on. + * + * @param slug the slug that was taken, or would be; {@code null} when none was available + */ +public record PersonalNamespaceBackfillEntry( + String userId, + String displayName, + String slug, + Outcome outcome) { + + public enum Outcome { + /** Dry run: this account would get {@code slug}. */ + PLANNED, + /** The namespace was created. */ + CREATED, + /** Every candidate slug was taken or rejected, so the account was left alone. */ + NO_SLUG + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillReport.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillReport.java new file mode 100644 index 000000000..a84e2e319 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceBackfillReport.java @@ -0,0 +1,21 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.List; + +/** + * Outcome of a backfill run over existing accounts. + * + *

{@code entries} lists only the accounts a run would change or could not place, so an operator + * reads the work rather than the whole directory; accounts that already have a namespace are + * counted instead. + * + * @param truncated whether the run stopped at its per-run account cap, leaving accounts unvisited + */ +public record PersonalNamespaceBackfillReport( + boolean dryRun, + int scannedAccounts, + int alreadyProvisioned, + int systemAccountsSkipped, + boolean truncated, + List entries) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNaming.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNaming.java new file mode 100644 index 000000000..5a9619d39 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNaming.java @@ -0,0 +1,104 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Renders the operator-configured name templates for a personal namespace. + * + *

Templates use {@code ${placeholder}} syntax. Unknown placeholders are left untouched so a typo + * shows up in the resulting name instead of silently disappearing. + */ +final class PersonalNamespaceNaming { + + /** + * Longest slug {@link SlugValidator} accepts, minus room for a de-duplication suffix. + */ + private static final int SLUG_BASE_BUDGET = 59; + + /** + * Matches the {@code display_name} column width. + */ + private static final int DISPLAY_NAME_LIMIT = 128; + + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([a-z_]+)}"); + + private PersonalNamespaceNaming() { + } + + /** + * Substitutes placeholders in {@code template} using {@code owner}. + */ + static String render(String template, PersonalNamespaceOwner owner) { + if (template == null || template.isBlank()) { + return ""; + } + Map values = Map.of( + PersonalNamespaceSettings.PLACEHOLDER_USERNAME, username(owner), + PersonalNamespaceSettings.PLACEHOLDER_EMAIL_PREFIX, emailPrefix(owner), + PersonalNamespaceSettings.PLACEHOLDER_USER_ID, blankToEmpty(owner.userId())); + + Matcher matcher = PLACEHOLDER.matcher(template); + StringBuilder rendered = new StringBuilder(); + while (matcher.find()) { + String replacement = values.get(matcher.group(1)); + matcher.appendReplacement(rendered, + Matcher.quoteReplacement(replacement != null ? replacement : matcher.group())); + } + matcher.appendTail(rendered); + return rendered.toString(); + } + + /** + * Renders {@code template} into a slug base, falling back to the user id when the template + * cannot produce anything usable. + */ + static String slugBase(String template, PersonalNamespaceOwner owner) { + String candidate = truncateSlug(SlugValidator.normalize(render(template, owner))); + if (candidate.length() >= 2) { + return candidate; + } + String fallback = truncateSlug(SlugValidator.normalize(owner.userId())); + return fallback.length() >= 2 ? fallback : "user"; + } + + /** + * Renders {@code template} into a display name, falling back to the slug that was chosen. + */ + static String displayName(String template, PersonalNamespaceOwner owner, String slug) { + String rendered = render(template, owner).trim(); + if (rendered.isEmpty()) { + return slug; + } + return rendered.length() > DISPLAY_NAME_LIMIT ? rendered.substring(0, DISPLAY_NAME_LIMIT) : rendered; + } + + private static String username(PersonalNamespaceOwner owner) { + if (owner.username() != null && !owner.username().isBlank()) { + return owner.username().trim(); + } + String emailPrefix = emailPrefix(owner); + return !emailPrefix.isEmpty() ? emailPrefix : blankToEmpty(owner.userId()); + } + + private static String emailPrefix(PersonalNamespaceOwner owner) { + String email = owner.email(); + if (email == null || email.isBlank()) { + return ""; + } + int at = email.indexOf('@'); + return (at > 0 ? email.substring(0, at) : email).trim(); + } + + private static String truncateSlug(String slug) { + if (slug.length() <= SLUG_BASE_BUDGET) { + return slug; + } + return SlugValidator.normalize(slug.substring(0, SLUG_BASE_BUDGET)); + } + + private static String blankToEmpty(String value) { + return value == null ? "" : value.trim(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceOwner.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceOwner.java new file mode 100644 index 000000000..8ae5b395d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceOwner.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.namespace; + +/** + * The account a personal namespace is being created for. + * + *

{@code username} is whatever the authentication path calls a user name — the local login name, + * or the provider login for an external identity. It is absent for accounts that have neither. + */ +public record PersonalNamespaceOwner(String userId, String username, String email) { +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningProperties.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningProperties.java new file mode 100644 index 000000000..ec0ca6e50 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningProperties.java @@ -0,0 +1,58 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Deployment defaults for personal namespace provisioning. + * + *

These apply until an administrator saves the setting in the admin console, after which the + * stored value wins. Deployments that manage configuration purely through files can therefore keep + * doing so and never touch the console. + */ +@Component +@ConfigurationProperties(prefix = "skillhub.namespace.personal-provisioning") +public class PersonalNamespaceProvisioningProperties { + + /** + * Off by default: existing deployments must not start creating namespaces after an upgrade. + */ + private boolean enabled = false; + + /** + * Kept out of {@code application.yml}: the {@code ${...}} placeholders would be resolved as + * Spring property references. Operators change the templates in the admin console, so these + * defaults only apply until someone does. + */ + private String slugTemplate = "${username}"; + + private String displayNameTemplate = "${username}"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getSlugTemplate() { + return slugTemplate; + } + + public void setSlugTemplate(String slugTemplate) { + this.slugTemplate = slugTemplate; + } + + public String getDisplayNameTemplate() { + return displayNameTemplate; + } + + public void setDisplayNameTemplate(String displayNameTemplate) { + this.displayNameTemplate = displayNameTemplate; + } + + public PersonalNamespaceSettings toSettings() { + return new PersonalNamespaceSettings(enabled, slugTemplate, displayNameTemplate); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningService.java new file mode 100644 index 000000000..8c42bac9f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningService.java @@ -0,0 +1,234 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.setting.SystemSettingService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Gives each newly activated account a namespace of its own, when the operator has asked for it. + * + *

The namespace is an ordinary team namespace whose only member is its owner, which is what + * "private" means in this model: there is no namespace-level visibility flag, and skill visibility + * stays a property of each skill. + * + *

Provisioning deliberately runs in its own transaction, after the account has been committed. + * {@code namespace.created_by} and {@code namespace_member.user_id} both reference + * {@code user_account(id)}, so creating the namespace inside the still-open registration + * transaction would either join that transaction — letting a naming clash roll back the + * registration — or, if suspended, block on the uncommitted account row. Running afterwards keeps a + * failure here from costing the user their account; see + * {@code PersonalNamespaceProvisioningListener}. + */ +@Service +public class PersonalNamespaceProvisioningService { + + public static final String SETTING_KEY = "namespace.personal-provisioning"; + + /** + * Upper bound on de-duplication suffixes before giving up on a slug base. + */ + private static final int MAX_SLUG_ATTEMPTS = 64; + + /** + * Per-run account cap, so an unexpectedly large directory cannot turn one click into an + * unbounded job. A run that hits it reports {@code truncated} rather than pretending it + * covered everything. + */ + private static final int MAX_BACKFILL_ACCOUNTS = 5000; + + private static final int BACKFILL_PAGE_SIZE = 200; + + private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningService.class); + + private final SystemSettingService systemSettingService; + private final PersonalNamespaceProvisioningProperties defaults; + private final NamespaceService namespaceService; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + + public PersonalNamespaceProvisioningService(SystemSettingService systemSettingService, + PersonalNamespaceProvisioningProperties defaults, + NamespaceService namespaceService, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { + this.systemSettingService = systemSettingService; + this.defaults = defaults; + this.namespaceService = namespaceService; + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + } + + /** + * Returns the effective policy: the administrator's stored choice, or the deployment defaults. + */ + public PersonalNamespaceSettings currentSettings() { + return systemSettingService.get(SETTING_KEY, PersonalNamespaceSettings.class, defaults.toSettings()); + } + + @Transactional + public PersonalNamespaceSettings updateSettings(PersonalNamespaceSettings settings, String actorUserId) { + return systemSettingService.put(SETTING_KEY, settings, actorUserId); + } + + /** + * Creates the owner's namespace, or returns empty when provisioning is off, the owner already + * has one, or no acceptable slug is available. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Optional provisionFor(PersonalNamespaceOwner owner) { + PersonalNamespaceSettings settings = currentSettings(); + if (!settings.enabled()) { + log.info("Skipping personal namespace for user {}: provisioning is disabled", owner.userId()); + return Optional.empty(); + } + if (alreadyOwnsNamespace(owner.userId())) { + log.info("Skipping personal namespace for user {}: already owns a non-global namespace", + owner.userId()); + return Optional.empty(); + } + + String slug = allocateSlug(settings.slugTemplate(), owner, Set.of()); + if (slug == null) { + log.warn("No namespace slug available for user {} from template '{}'; skipping provisioning", + owner.userId(), settings.slugTemplate()); + return Optional.empty(); + } + + String displayName = PersonalNamespaceNaming.displayName(settings.displayNameTemplate(), owner, slug); + Namespace namespace = namespaceService.createNamespace(slug, displayName, null, owner.userId()); + log.info("Provisioned personal namespace '{}' for user {}", slug, owner.userId()); + return Optional.of(namespace); + } + + /** + * Gives existing accounts the namespace they would have received had provisioning been on when + * they first signed in. + * + *

Turning the setting on only affects accounts activated afterwards, which on a registry + * that has been running for a while means nobody. This walks the active accounts and fills the + * gap. + * + *

Deliberately not {@code @Transactional}: each namespace is created in its own transaction, + * so one account that cannot be placed does not discard the rest of the run. + * + * @param dryRun report what would happen without writing anything + */ + public PersonalNamespaceBackfillReport backfill(boolean dryRun) { + PersonalNamespaceSettings settings = currentSettings(); + List entries = new ArrayList<>(); + Set reserved = new HashSet<>(); + int scanned = 0; + int alreadyProvisioned = 0; + int systemAccounts = 0; + boolean truncated = false; + + for (int page = 0; !truncated; page++) { + Page batch = userAccountRepository.findByStatus(UserStatus.ACTIVE, + PageRequest.of(page, BACKFILL_PAGE_SIZE, Sort.by("id"))); + if (batch.isEmpty()) { + break; + } + for (UserAccount user : batch) { + if (scanned >= MAX_BACKFILL_ACCOUNTS) { + truncated = true; + break; + } + scanned++; + if (user.isSystemAccount()) { + systemAccounts++; + continue; + } + if (alreadyOwnsNamespace(user.getId())) { + alreadyProvisioned++; + continue; + } + entries.add(placeAccount(user, settings, reserved, dryRun)); + } + if (!batch.hasNext()) { + break; + } + } + + log.info("Personal namespace backfill ({}): scanned {}, already provisioned {}, acted on {}{}", + dryRun ? "dry run" : "applied", scanned, alreadyProvisioned, entries.size(), + truncated ? ", stopped at the per-run cap" : ""); + return new PersonalNamespaceBackfillReport( + dryRun, scanned, alreadyProvisioned, systemAccounts, truncated, List.copyOf(entries)); + } + + private PersonalNamespaceBackfillEntry placeAccount(UserAccount user, + PersonalNamespaceSettings settings, + Set reserved, + boolean dryRun) { + PersonalNamespaceOwner owner = + new PersonalNamespaceOwner(user.getId(), user.getDisplayName(), user.getEmail()); + String slug = allocateSlug(settings.slugTemplate(), owner, reserved); + if (slug == null) { + log.warn("Backfill found no available slug for user {} from template '{}'", + user.getId(), settings.slugTemplate()); + return new PersonalNamespaceBackfillEntry(user.getId(), user.getDisplayName(), null, + PersonalNamespaceBackfillEntry.Outcome.NO_SLUG); + } + reserved.add(slug); + if (dryRun) { + return new PersonalNamespaceBackfillEntry(user.getId(), user.getDisplayName(), slug, + PersonalNamespaceBackfillEntry.Outcome.PLANNED); + } + + String displayName = PersonalNamespaceNaming.displayName(settings.displayNameTemplate(), owner, slug); + namespaceService.createNamespace(slug, displayName, null, user.getId()); + log.info("Backfilled personal namespace '{}' for user {}", slug, user.getId()); + return new PersonalNamespaceBackfillEntry(user.getId(), user.getDisplayName(), slug, + PersonalNamespaceBackfillEntry.Outcome.CREATED); + } + + /** + * Treats owning any non-global namespace as "already has a personal namespace", which keeps a + * repeated activation from handing the same user a second one. + */ + private boolean alreadyOwnsNamespace(String userId) { + return namespaceMemberRepository.findByUserId(userId).stream() + .filter(member -> member.getRole() == NamespaceRole.OWNER) + .map(member -> namespaceRepository.findById(member.getNamespaceId())) + .flatMap(Optional::stream) + .anyMatch(namespace -> namespace.getType() != NamespaceType.GLOBAL); + } + + /** + * Returns the first free slug for the owner, or {@code null} when every candidate is taken or + * rejected — for example when the template renders to a reserved word for many users. + * + * @param reserved slugs already promised to earlier owners in this run but not yet persisted, + * so a batch cannot hand the same slug to two accounts + */ + private String allocateSlug(String slugTemplate, PersonalNamespaceOwner owner, Set reserved) { + String base = PersonalNamespaceNaming.slugBase(slugTemplate, owner); + for (int attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) { + String candidate = attempt == 1 ? base : base + "-" + attempt; + if (!reserved.contains(candidate) + && SlugValidator.isValid(candidate) + && namespaceRepository.findBySlug(candidate).isEmpty()) { + return candidate; + } + } + return null; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceSettings.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceSettings.java new file mode 100644 index 000000000..62f817d7c --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceSettings.java @@ -0,0 +1,23 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Operator-controlled policy for giving each new account its own namespace. + * + * @param slugTemplate template for the namespace slug, e.g. {@code ${username}-space} + * @param displayNameTemplate template for the namespace display name + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PersonalNamespaceSettings( + boolean enabled, + String slugTemplate, + String displayNameTemplate) { + + /** + * Supported placeholders, in the order they are documented to operators. + */ + public static final String PLACEHOLDER_USERNAME = "username"; + public static final String PLACEHOLDER_EMAIL_PREFIX = "email_prefix"; + public static final String PLACEHOLDER_USER_ID = "user_id"; +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java index 9417325f7..d4ffaca1e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java @@ -44,12 +44,37 @@ public static String slugify(String raw) { if (raw == null) { throw new DomainBadRequestException("error.slug.blank"); } - String slug = raw.trim().toLowerCase() + String slug = normalize(raw); + validate(slug); + return slug; + } + + /** + * Applies the slug character rules without asserting the result is usable. + * + *

Callers that generate candidate slugs — rather than accepting one from a user — need to + * inspect and adjust the result (append a suffix, truncate) before validating it. + */ + public static String normalize(String raw) { + if (raw == null) { + return ""; + } + return raw.trim().toLowerCase() .replaceAll("[^\\p{L}\\p{N}\\p{So}]+", "-") .replaceAll("^-+", "") .replaceAll("-+$", "") .replaceAll("-{2,}", "-"); - validate(slug); - return slug; + } + + /** + * Returns whether {@code slug} would pass {@link #validate(String)}. + */ + public static boolean isValid(String slug) { + try { + validate(slug); + return true; + } catch (DomainBadRequestException e) { + return false; + } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java new file mode 100644 index 000000000..2abc026b1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSetting.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.domain.setting; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; + +/** + * One operator-configurable setting group, stored as a JSON document. + */ +@Entity +@Table(name = "system_setting") +public class SystemSetting { + + @Id + @Column(name = "setting_key", nullable = false, length = 128) + private String settingKey; + + @Column(name = "setting_value", nullable = false) + @JdbcTypeCode(SqlTypes.JSON) + private String settingValue; + + @Column(name = "updated_by", length = 128) + private String updatedBy; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected SystemSetting() { + } + + public SystemSetting(String settingKey, String settingValue, String updatedBy, Instant updatedAt) { + this.settingKey = settingKey; + this.settingValue = settingValue; + this.updatedBy = updatedBy; + this.updatedAt = updatedAt; + } + + public String getSettingKey() { + return settingKey; + } + + public String getSettingValue() { + return settingValue; + } + + public void setSettingValue(String settingValue) { + this.settingValue = settingValue; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public void setUpdatedBy(String updatedBy) { + this.updatedBy = updatedBy; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java new file mode 100644 index 000000000..ee6522cf8 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingRepository.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.setting; + +import java.util.Optional; + +public interface SystemSettingRepository { + Optional findBySettingKey(String settingKey); + SystemSetting save(SystemSetting setting); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java new file mode 100644 index 000000000..6badc519b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/SystemSettingService.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.domain.setting; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; + +/** + * Reads and writes operator-configurable setting groups. + * + *

Every read carries the caller's defaults so that a deployment which has never touched a group + * — or which configures it entirely through {@code application.yml} — behaves exactly as it did + * before the group existed. + */ +@Service +public class SystemSettingService { + + private static final Logger log = LoggerFactory.getLogger(SystemSettingService.class); + + private final SystemSettingRepository systemSettingRepository; + private final ObjectMapper objectMapper; + private final Clock clock; + + public SystemSettingService(SystemSettingRepository systemSettingRepository, + ObjectMapper objectMapper, + Clock clock) { + this.systemSettingRepository = systemSettingRepository; + this.objectMapper = objectMapper; + this.clock = clock; + } + + /** + * Returns the stored group, or {@code defaults} when the group has never been overridden. + * + *

A stored document that can no longer be parsed also falls back to {@code defaults}: a + * malformed row must not take down the flows that read settings, such as login. + */ + @Transactional(readOnly = true) + public T get(String settingKey, Class type, T defaults) { + Optional stored = systemSettingRepository.findBySettingKey(settingKey); + if (stored.isEmpty()) { + return defaults; + } + try { + return objectMapper.readValue(stored.get().getSettingValue(), type); + } catch (Exception e) { + log.warn("Falling back to defaults for system setting '{}': stored value is not readable as {}", + settingKey, type.getSimpleName(), e); + return defaults; + } + } + + /** + * Overwrites a setting group and records who changed it. + */ + @Transactional + public T put(String settingKey, T value, String updatedBy) { + String json; + try { + json = objectMapper.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalArgumentException("System setting '" + settingKey + "' is not serializable", e); + } + Instant now = Instant.now(clock); + SystemSetting setting = systemSettingRepository.findBySettingKey(settingKey) + .orElseGet(() -> new SystemSetting(settingKey, json, updatedBy, now)); + setting.setSettingValue(json); + setting.setUpdatedBy(updatedBy); + setting.setUpdatedAt(now); + systemSettingRepository.save(setting); + return value; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java new file mode 100644 index 000000000..6f4038ce7 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/setting/package-info.java @@ -0,0 +1,10 @@ +/** + * Operator-configurable platform settings. + * + *

Settings are grouped: one {@link com.iflytek.skillhub.domain.setting.SystemSetting} row holds + * one group serialized as JSON. Callers read a group through + * {@link com.iflytek.skillhub.domain.setting.SystemSettingService} with a typed default, so a group + * that has never been overridden resolves to the deployment's configured defaults rather than to + * {@code null}. + */ +package com.iflytek.skillhub.domain.setting; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index c0f4f2956..bcfbb3b34 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -14,5 +14,16 @@ public interface UserAccountRepository { List findByIdIn(List ids); Optional findByEmailIgnoreCase(String email); Page search(String keyword, UserStatus status, Pageable pageable); + + /** + * Lists accounts in one status. + * + *

Separate from {@link #search} on purpose: that query compares the keyword with + * {@code lower(...)}, and passing a null keyword leaves PostgreSQL to infer the bind type as + * {@code bytea}, which fails with "function lower(bytea) does not exist". Callers that want + * every account in a status have no keyword to give, so they get a query without one. + */ + Page findByStatus(UserStatus status, Pageable pageable); + UserAccount save(UserAccount user); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipServiceTest.java new file mode 100644 index 000000000..728810289 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/DefaultNamespaceMembershipServiceTest.java @@ -0,0 +1,208 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.setting.SystemSettingService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DefaultNamespaceMembershipServiceTest { + + @Mock + private SystemSettingService systemSettingService; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + @Mock + private UserAccountRepository userAccountRepository; + + private DefaultNamespaceMembershipService service; + + @BeforeEach + void setUp() { + service = new DefaultNamespaceMembershipService( + systemSettingService, + new DefaultNamespaceProperties(), + namespaceRepository, + namespaceMemberRepository, + userAccountRepository); + } + + private Namespace namespace(long id, String slug) { + Namespace namespace = new Namespace(slug, slug, "usr_owner"); + try { + Field field = Namespace.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(namespace, id); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + return namespace; + } + + private void configured(String... slugs) { + when(systemSettingService.get(eq(DefaultNamespaceMembershipService.SETTING_KEY), + eq(DefaultNamespaceSettings.class), any())) + .thenReturn(new DefaultNamespaceSettings(List.of(slugs))); + } + + @Test + void defaultsToTheBuiltInGlobalNamespace() { + assertEquals(List.of("global"), new DefaultNamespaceProperties().toSettings().slugs()); + } + + @Test + void ensureMemberJoinsEveryConfiguredNamespace() { + configured("global", "musee"); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace(1L, "global"))); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq("usr_1"))) + .thenReturn(Optional.empty()); + + service.ensureMember("usr_1"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository, org.mockito.Mockito.times(2)).save(captor.capture()); + assertEquals(List.of(1L, 2L), captor.getAllValues().stream().map(NamespaceMember::getNamespaceId).toList()); + assertTrue(captor.getAllValues().stream().allMatch(m -> m.getRole() == NamespaceRole.MEMBER)); + } + + @Test + void ensureMemberSkipsASlugThatNoLongerResolves() { + configured("global", "deleted-one"); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace(1L, "global"))); + when(namespaceRepository.findBySlug("deleted-one")).thenReturn(Optional.empty()); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); + + service.ensureMember("usr_1"); + + verify(namespaceMemberRepository, org.mockito.Mockito.times(1)).save(any(NamespaceMember.class)); + } + + @Test + void ensureMemberIsIdempotent() { + configured("global"); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace(1L, "global"))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "usr_1", NamespaceRole.MEMBER))); + + service.ensureMember("usr_1"); + + verify(namespaceMemberRepository, never()).save(any(NamespaceMember.class)); + } + + @Test + void updateSettingsRejectsASlugThatDoesNotExist() { + when(namespaceRepository.findBySlug("typo")).thenReturn(Optional.empty()); + + assertThrows(DomainBadRequestException.class, () -> + service.updateSettings(new DefaultNamespaceSettings(List.of("typo")), "usr_admin")); + verify(systemSettingService, never()).put(any(), any(), any()); + } + + @Test + void updateSettingsRejectsANamespaceThatIsNotActive() { + Namespace archived = namespace(3L, "old"); + archived.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceRepository.findBySlug("old")).thenReturn(Optional.of(archived)); + + assertThrows(DomainBadRequestException.class, () -> + service.updateSettings(new DefaultNamespaceSettings(List.of("old")), "usr_admin")); + } + + @Test + void updateSettingsTrimsBlanksAndDropsDuplicates() { + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace(1L, "global"))); + when(systemSettingService.put(any(), any(), any())).thenAnswer(i -> i.getArgument(1)); + + service.updateSettings(new DefaultNamespaceSettings(List.of(" global ", "", "global")), "usr_admin"); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(DefaultNamespaceSettings.class); + verify(systemSettingService).put(eq(DefaultNamespaceMembershipService.SETTING_KEY), + captor.capture(), eq("usr_admin")); + assertEquals(List.of("global"), captor.getValue().slugs()); + } + + @Test + void backfillDryRunListsAccountsMissingMembershipWithoutWriting() { + configured("musee"); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of(new UserAccount("usr_1", "alice", null, null)))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(2L, "usr_1")).thenReturn(Optional.empty()); + + DefaultNamespaceBackfillReport report = service.backfill(true); + + assertEquals(1, report.entries().size()); + assertEquals(List.of("musee"), report.entries().getFirst().slugs()); + verify(namespaceMemberRepository, never()).save(any(NamespaceMember.class)); + } + + @Test + void backfillEnrollsAccountsThatAreMissing() { + configured("musee"); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of(new UserAccount("usr_1", "alice", null, null)))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(2L, "usr_1")).thenReturn(Optional.empty()); + + service.backfill(false); + + verify(namespaceMemberRepository).save(any(NamespaceMember.class)); + } + + @Test + void backfillCountsAccountsThatAreAlreadyEnrolled() { + configured("musee"); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of(new UserAccount("usr_1", "alice", null, null)))); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(2L, "usr_1")) + .thenReturn(Optional.of(new NamespaceMember(2L, "usr_1", NamespaceRole.MEMBER))); + + DefaultNamespaceBackfillReport report = service.backfill(false); + + assertEquals(1, report.alreadyEnrolled()); + assertTrue(report.entries().isEmpty()); + } + + @Test + void backfillLeavesSystemAccountsAlone() { + configured("musee"); + when(namespaceRepository.findBySlug("musee")).thenReturn(Optional.of(namespace(2L, "musee"))); + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of( + UserAccount.systemAccount("builtin-skill-publisher", "Built-in", null, null)))); + + DefaultNamespaceBackfillReport report = service.backfill(false); + + assertEquals(1, report.systemAccountsSkipped()); + assertTrue(report.entries().isEmpty()); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java deleted file mode 100644 index c4e211bb1..000000000 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/GlobalNamespaceMembershipServiceTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.iflytek.skillhub.domain.namespace; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Optional; -import java.lang.reflect.Field; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.assertj.core.api.Assertions.assertThat; - -@ExtendWith(MockitoExtension.class) -class GlobalNamespaceMembershipServiceTest { - - @Mock - private NamespaceRepository namespaceRepository; - - @Mock - private NamespaceMemberRepository namespaceMemberRepository; - - private GlobalNamespaceMembershipService service; - - @BeforeEach - void setUp() { - service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository); - } - - @Test - void ensureMember_createsGlobalMembershipWhenMissing() throws Exception { - Namespace global = new Namespace("global", "Global", "system"); - setNamespaceId(global, 1L); - - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty()); - - service.ensureMember("usr_1"); - - ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); - verify(namespaceMemberRepository).save(memberCaptor.capture()); - assertThat(memberCaptor.getValue().getNamespaceId()).isEqualTo(1L); - assertThat(memberCaptor.getValue().getUserId()).isEqualTo("usr_1"); - assertThat(memberCaptor.getValue().getRole()).isEqualTo(NamespaceRole.MEMBER); - } - - @Test - void ensureMember_keepsExistingGlobalMembership() throws Exception { - Namespace global = new Namespace("global", "Global", "system"); - setNamespaceId(global, 1L); - NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN); - - when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); - when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing)); - - service.ensureMember("usr_1"); - - verify(namespaceMemberRepository, never()).save(any()); - } - - private void setNamespaceId(Namespace namespace, Long id) throws Exception { - Field field = Namespace.class.getDeclaredField("id"); - field.setAccessible(true); - field.set(namespace, id); - } -} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNamingTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNamingTest.java new file mode 100644 index 000000000..655378e39 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceNamingTest.java @@ -0,0 +1,83 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PersonalNamespaceNamingTest { + + private static final PersonalNamespaceOwner ALICE = + new PersonalNamespaceOwner("usr_0f2a", "Alice.Wang", "alice.wang@example.com"); + + @Test + void rendersEachSupportedPlaceholder() { + assertEquals("Alice.Wang", PersonalNamespaceNaming.render("${username}", ALICE)); + assertEquals("alice.wang", PersonalNamespaceNaming.render("${email_prefix}", ALICE)); + assertEquals("usr_0f2a", PersonalNamespaceNaming.render("${user_id}", ALICE)); + } + + @Test + void leavesUnknownPlaceholdersInPlaceSoTyposAreVisible() { + assertEquals("Alice.Wang-${nickname}", PersonalNamespaceNaming.render("${username}-${nickname}", ALICE)); + } + + @Test + void slugBaseAppliesSlugCharacterRules() { + assertEquals("alice-wang", PersonalNamespaceNaming.slugBase("${username}", ALICE)); + assertEquals("alice-wang-space", PersonalNamespaceNaming.slugBase("${username}-space", ALICE)); + } + + @Test + void slugBaseRewritesUnderscoresBecauseSlugsDisallowThem() { + String slug = PersonalNamespaceNaming.slugBase("${username}_space", ALICE); + + assertEquals("alice-wang-space", slug); + assertTrue(SlugValidator.isValid(slug)); + } + + @Test + void slugBaseFallsBackToEmailPrefixWhenUsernameIsMissing() { + PersonalNamespaceOwner noUsername = new PersonalNamespaceOwner("usr_1", null, "bob@example.com"); + + assertEquals("bob", PersonalNamespaceNaming.slugBase("${username}", noUsername)); + } + + @Test + void slugBaseFallsBackToUserIdWhenTemplateRendersNothingUsable() { + PersonalNamespaceOwner anonymous = new PersonalNamespaceOwner("usr_abc123", null, null); + + assertEquals("usr-abc123", PersonalNamespaceNaming.slugBase("${username}", anonymous)); + } + + @Test + void slugBaseLeavesRoomForADeduplicationSuffix() { + PersonalNamespaceOwner longName = new PersonalNamespaceOwner("usr_1", "a".repeat(200), null); + + String base = PersonalNamespaceNaming.slugBase("${username}", longName); + + assertTrue(base.length() <= 59, "base was " + base.length() + " chars"); + assertTrue(SlugValidator.isValid(base + "-64")); + } + + @Test + void displayNameFallsBackToTheSlugWhenTemplateRendersBlank() { + PersonalNamespaceOwner noEmail = new PersonalNamespaceOwner("usr_1", "alice", null); + + assertEquals("chosen-slug", + PersonalNamespaceNaming.displayName("${email_prefix}", noEmail, "chosen-slug")); + } + + @Test + void usernameFallsBackToTheUserIdWhenNothingElseIsKnown() { + PersonalNamespaceOwner anonymous = new PersonalNamespaceOwner("usr_1", null, null); + + assertEquals("usr_1", PersonalNamespaceNaming.render("${username}", anonymous)); + } + + @Test + void displayNameKeepsHumanReadableCharacters() { + assertEquals("Alice.Wang's space", + PersonalNamespaceNaming.displayName("${username}'s space", ALICE, "alice-wang")); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningServiceTest.java new file mode 100644 index 000000000..e3b8503a0 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/PersonalNamespaceProvisioningServiceTest.java @@ -0,0 +1,281 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.setting.SystemSettingService; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PersonalNamespaceProvisioningServiceTest { + + private static final PersonalNamespaceOwner ALICE = + new PersonalNamespaceOwner("usr_alice", "alice", "alice@example.com"); + + @Mock + private SystemSettingService systemSettingService; + + @Mock + private NamespaceService namespaceService; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + @Mock + private UserAccountRepository userAccountRepository; + + private PersonalNamespaceProvisioningService service; + + @BeforeEach + void setUp() { + service = new PersonalNamespaceProvisioningService( + systemSettingService, + new PersonalNamespaceProvisioningProperties(), + namespaceService, + namespaceRepository, + namespaceMemberRepository, + userAccountRepository); + } + + private UserAccount account(String id, String displayName, String email) { + return new UserAccount(id, displayName, email, null); + } + + private void directoryContains(UserAccount... users) { + when(userAccountRepository.findByStatus(eq(UserStatus.ACTIVE), any())) + .thenReturn(new PageImpl<>(List.of(users))); + } + + private void withSettings(boolean enabled, String slugTemplate, String displayNameTemplate) { + when(systemSettingService.get(eq(PersonalNamespaceProvisioningService.SETTING_KEY), + eq(PersonalNamespaceSettings.class), any())) + .thenReturn(new PersonalNamespaceSettings(enabled, slugTemplate, displayNameTemplate)); + } + + private void ownsNothing() { + when(namespaceMemberRepository.findByUserId(ALICE.userId())).thenReturn(List.of()); + } + + /** + * Mirrors {@link NamespaceService#createNamespace} returning the namespace it persisted. + */ + private void namespaceCreationSucceeds() { + when(namespaceService.createNamespace(any(), any(), any(), any())) + .thenAnswer(invocation -> new Namespace( + invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(3))); + } + + @Test + void doesNothingWhenProvisioningIsDisabled() { + withSettings(false, "${username}", "${username}"); + + assertTrue(service.provisionFor(ALICE).isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void defaultsAreDisabledSoUpgradesDoNotStartCreatingNamespaces() { + assertEquals(false, new PersonalNamespaceProvisioningProperties().isEnabled()); + } + + @Test + void createsNamespaceFromTheConfiguredTemplate() { + withSettings(true, "${username}-space", "${username}'s space"); + ownsNothing(); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("alice-space")).thenReturn(Optional.empty()); + + service.provisionFor(ALICE); + + verify(namespaceService).createNamespace("alice-space", "alice's space", null, "usr_alice"); + } + + @Test + void appendsSuffixWhenTheSlugIsAlreadyTaken() { + withSettings(true, "${username}", "${username}"); + ownsNothing(); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.of(new Namespace("alice", "Alice", "usr_x"))); + when(namespaceRepository.findBySlug("alice-2")).thenReturn(Optional.empty()); + + service.provisionFor(ALICE); + + verify(namespaceService).createNamespace(eq("alice-2"), any(), isNull(), eq("usr_alice")); + } + + @Test + void skipsReservedSlugsInsteadOfFailing() { + PersonalNamespaceOwner admin = new PersonalNamespaceOwner("usr_admin", "admin", null); + withSettings(true, "${username}", "${username}"); + when(namespaceMemberRepository.findByUserId(admin.userId())).thenReturn(List.of()); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("admin-2")).thenReturn(Optional.empty()); + + service.provisionFor(admin); + + verify(namespaceService).createNamespace(eq("admin-2"), any(), isNull(), eq("usr_admin")); + } + + @Test + void skipsWhenTheUserAlreadyOwnsANamespace() { + withSettings(true, "${username}", "${username}"); + when(namespaceMemberRepository.findByUserId(ALICE.userId())) + .thenReturn(List.of(new NamespaceMember(7L, ALICE.userId(), NamespaceRole.OWNER))); + when(namespaceRepository.findById(7L)) + .thenReturn(Optional.of(new Namespace("alice", "Alice", ALICE.userId()))); + + assertTrue(service.provisionFor(ALICE).isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void globalMembershipDoesNotCountAsOwningANamespace() { + withSettings(true, "${username}", "${username}"); + Namespace global = new Namespace("global", "Global", "usr_system"); + global.setType(NamespaceType.GLOBAL); + when(namespaceMemberRepository.findByUserId(ALICE.userId())) + .thenReturn(List.of(new NamespaceMember(1L, ALICE.userId(), NamespaceRole.OWNER))); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(global)); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty()); + + service.provisionFor(ALICE); + + verify(namespaceService).createNamespace(eq("alice"), any(), isNull(), eq("usr_alice")); + } + + @Test + void plainMembershipDoesNotCountAsOwningANamespace() { + withSettings(true, "${username}", "${username}"); + when(namespaceMemberRepository.findByUserId(ALICE.userId())) + .thenReturn(List.of(new NamespaceMember(3L, ALICE.userId(), NamespaceRole.MEMBER))); + namespaceCreationSucceeds(); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty()); + + service.provisionFor(ALICE); + + verify(namespaceService).createNamespace(eq("alice"), any(), isNull(), eq("usr_alice")); + } + + @Test + void backfillDryRunPlansWithoutCreatingAnything() { + withSettings(true, "${username}", "${username}"); + directoryContains(account("usr_alice", "alice", "alice@example.com")); + when(namespaceMemberRepository.findByUserId("usr_alice")).thenReturn(List.of()); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty()); + + PersonalNamespaceBackfillReport report = service.backfill(true); + + assertTrue(report.dryRun()); + assertEquals(1, report.scannedAccounts()); + assertEquals(1, report.entries().size()); + assertEquals("alice", report.entries().getFirst().slug()); + assertEquals(PersonalNamespaceBackfillEntry.Outcome.PLANNED, report.entries().getFirst().outcome()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void backfillCreatesNamespacesForAccountsThatHaveNone() { + withSettings(true, "${username}", "${username}"); + directoryContains(account("usr_alice", "alice", "alice@example.com")); + when(namespaceMemberRepository.findByUserId("usr_alice")).thenReturn(List.of()); + when(namespaceRepository.findBySlug("alice")).thenReturn(Optional.empty()); + namespaceCreationSucceeds(); + + PersonalNamespaceBackfillReport report = service.backfill(false); + + assertFalse(report.dryRun()); + assertEquals(PersonalNamespaceBackfillEntry.Outcome.CREATED, report.entries().getFirst().outcome()); + verify(namespaceService).createNamespace(eq("alice"), any(), isNull(), eq("usr_alice")); + } + + @Test + void backfillCountsAccountsThatAlreadyHaveANamespaceInsteadOfListingThem() { + withSettings(true, "${username}", "${username}"); + directoryContains(account("usr_alice", "alice", "alice@example.com")); + when(namespaceMemberRepository.findByUserId("usr_alice")) + .thenReturn(List.of(new NamespaceMember(7L, "usr_alice", NamespaceRole.OWNER))); + when(namespaceRepository.findById(7L)) + .thenReturn(Optional.of(new Namespace("alice", "Alice", "usr_alice"))); + + PersonalNamespaceBackfillReport report = service.backfill(false); + + assertEquals(1, report.alreadyProvisioned()); + assertTrue(report.entries().isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void backfillLeavesSystemAccountsAlone() { + withSettings(true, "${username}", "${username}"); + directoryContains(UserAccount.systemAccount( + "builtin-skill-publisher", "Built-in Skill Publisher", null, null)); + + PersonalNamespaceBackfillReport report = service.backfill(false); + + assertEquals(1, report.systemAccountsSkipped()); + assertTrue(report.entries().isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } + + @Test + void backfillDoesNotPromiseTheSameSlugToTwoAccountsInOneRun() { + withSettings(true, "${username}", "${username}"); + directoryContains( + account("usr_1", "alice", "alice@example.com"), + account("usr_2", "Alice", "alice2@example.com")); + when(namespaceMemberRepository.findByUserId(any())).thenReturn(List.of()); + when(namespaceRepository.findBySlug(any())).thenReturn(Optional.empty()); + + PersonalNamespaceBackfillReport report = service.backfill(true); + + assertEquals(List.of("alice", "alice-2"), + report.entries().stream().map(PersonalNamespaceBackfillEntry::slug).toList()); + } + + @Test + void backfillReportsAccountsItCannotPlace() { + withSettings(true, "${username}", "${username}"); + directoryContains(account("usr_alice", "alice", "alice@example.com")); + when(namespaceMemberRepository.findByUserId("usr_alice")).thenReturn(List.of()); + when(namespaceRepository.findBySlug(any())) + .thenReturn(Optional.of(new Namespace("taken", "Taken", "usr_x"))); + + PersonalNamespaceBackfillReport report = service.backfill(true); + + assertEquals(PersonalNamespaceBackfillEntry.Outcome.NO_SLUG, report.entries().getFirst().outcome()); + assertEquals(null, report.entries().getFirst().slug()); + } + + @Test + void givesUpQuietlyWhenEveryCandidateSlugIsTaken() { + withSettings(true, "${username}", "${username}"); + ownsNothing(); + when(namespaceRepository.findBySlug(any())) + .thenReturn(Optional.of(new Namespace("taken", "Taken", "usr_x"))); + + assertTrue(service.provisionFor(ALICE).isEmpty()); + verify(namespaceService, never()).createNamespace(any(), any(), any(), any()); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java new file mode 100644 index 000000000..581fd3fd1 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/setting/SystemSettingServiceTest.java @@ -0,0 +1,110 @@ +package com.iflytek.skillhub.domain.setting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SystemSettingServiceTest { + + private static final String KEY = "demo.group"; + private static final Instant NOW = Instant.parse("2026-01-02T03:04:05Z"); + + @JsonIgnoreProperties(ignoreUnknown = true) + record DemoSettings(boolean enabled, String template) { + } + + @Mock + private SystemSettingRepository systemSettingRepository; + + private SystemSettingService service; + + @BeforeEach + void setUp() { + service = new SystemSettingService( + systemSettingRepository, + new ObjectMapper(), + Clock.fixed(NOW, ZoneOffset.UTC)); + } + + @Test + void getReturnsDefaultsWhenGroupWasNeverOverridden() { + DemoSettings defaults = new DemoSettings(false, "${username}"); + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.empty()); + + assertSame(defaults, service.get(KEY, DemoSettings.class, defaults)); + } + + @Test + void getReturnsStoredGroupWhenPresent() { + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of( + new SystemSetting(KEY, "{\"enabled\":true,\"template\":\"${username}-space\"}", "usr_1", NOW))); + + DemoSettings resolved = service.get(KEY, DemoSettings.class, new DemoSettings(false, "${username}")); + + assertEquals(new DemoSettings(true, "${username}-space"), resolved); + } + + @Test + void getIgnoresUnknownFieldsSoOlderNodesCanReadNewerDocuments() { + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of( + new SystemSetting(KEY, "{\"enabled\":true,\"template\":\"x\",\"addedLater\":42}", "usr_1", NOW))); + + assertEquals(new DemoSettings(true, "x"), + service.get(KEY, DemoSettings.class, new DemoSettings(false, "${username}"))); + } + + @Test + void getFallsBackToDefaultsWhenStoredDocumentIsMalformed() { + DemoSettings defaults = new DemoSettings(false, "${username}"); + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of( + new SystemSetting(KEY, "not json", "usr_1", NOW))); + + assertSame(defaults, service.get(KEY, DemoSettings.class, defaults)); + } + + @Test + void putStoresSerializedGroupWithActorAndTimestamp() { + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.empty()); + + service.put(KEY, new DemoSettings(true, "${username}"), "usr_admin"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SystemSetting.class); + verify(systemSettingRepository).save(captor.capture()); + SystemSetting saved = captor.getValue(); + assertEquals(KEY, saved.getSettingKey()); + assertEquals("{\"enabled\":true,\"template\":\"${username}\"}", saved.getSettingValue()); + assertEquals("usr_admin", saved.getUpdatedBy()); + assertEquals(NOW, saved.getUpdatedAt()); + } + + @Test + void putOverwritesExistingRowInPlace() { + SystemSetting existing = new SystemSetting(KEY, "{\"enabled\":false,\"template\":\"old\"}", + "usr_previous", NOW.minusSeconds(60)); + when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(existing)); + + service.put(KEY, new DemoSettings(true, "new"), "usr_admin"); + + verify(systemSettingRepository).save(any(SystemSetting.class)); + assertEquals("{\"enabled\":true,\"template\":\"new\"}", existing.getSettingValue()); + assertEquals("usr_admin", existing.getUpdatedBy()); + assertEquals(NOW, existing.getUpdatedAt()); + } +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java new file mode 100644 index 000000000..82d4754a1 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SystemSettingJpaRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.setting.SystemSetting; +import com.iflytek.skillhub.domain.setting.SystemSettingRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SystemSettingJpaRepository extends JpaRepository, SystemSettingRepository { + Optional findBySettingKey(String settingKey); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5e4c77567..db3ea600f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -47,6 +47,11 @@ import type { LabelDefinition, LabelItem, BatchMemberResponse, + PersonalNamespaceSettings, + PersonalNamespaceSettingsInput, + PersonalNamespaceBackfillResult, + DefaultNamespaceSettings, + DefaultNamespaceBackfillResult, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -1443,6 +1448,58 @@ export const adminApi = { body: JSON.stringify({ comment }), }) }, + + async getPersonalNamespaceSettings(): Promise { + return fetchJson('/api/v1/admin/settings/personal-namespace') + }, + + async updatePersonalNamespaceSettings( + request: PersonalNamespaceSettingsInput, + ): Promise { + return fetchJson('/api/v1/admin/settings/personal-namespace', { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + enabled: request.enabled, + slugTemplate: request.slugTemplate.trim(), + displayNameTemplate: request.displayNameTemplate.trim(), + }), + }) + }, + + async backfillPersonalNamespaces(dryRun: boolean): Promise { + return fetchJson( + '/api/v1/admin/settings/personal-namespace/backfill', + { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ dryRun }), + }, + ) + }, + + async getDefaultNamespaces(): Promise { + return fetchJson('/api/v1/admin/settings/default-namespaces') + }, + + async updateDefaultNamespaces(slugs: string[]): Promise { + return fetchJson('/api/v1/admin/settings/default-namespaces', { + method: 'PUT', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ slugs }), + }) + }, + + async backfillDefaultNamespaces(dryRun: boolean): Promise { + return fetchJson( + '/api/v1/admin/settings/default-namespaces/backfill', + { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ dryRun }), + }, + ) + }, } export const notificationApi = { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 3c4f5b8e2..573bf962b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -372,6 +372,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/settings/personal-namespace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPersonalNamespaceSettings"]; + put: operations["updatePersonalNamespaceSettings"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/settings/default-namespaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getDefaultNamespaces"]; + put: operations["updateDefaultNamespaces"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/namespaces/{slug}/members/{userId}/role": { parameters: { query?: never; @@ -1572,6 +1604,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/settings/personal-namespace/backfill": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["backfillPersonalNamespaces"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/settings/default-namespaces/backfill": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["backfillDefaultNamespaces"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/search/rebuild": { parameters: { query?: never; @@ -3710,6 +3774,41 @@ export interface components { AdminUserRoleUpdateRequest: { role: string; }; + PersonalNamespaceSettingsUpdateRequest: { + enabled: boolean; + slugTemplate: string; + displayNameTemplate: string; + }; + ApiResponsePersonalNamespaceSettingsResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PersonalNamespaceSettingsResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PersonalNamespaceSettingsResponse: { + enabled?: boolean; + slugTemplate?: string; + displayNameTemplate?: string; + supportedPlaceholders?: string[]; + }; + DefaultNamespaceSettingsUpdateRequest: { + slugs: string[]; + }; + ApiResponseDefaultNamespaceSettingsResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DefaultNamespaceSettingsResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DefaultNamespaceSettingsResponse: { + slugs?: string[]; + }; AdminLabelUpdateRequest: { /** @enum {string} */ type: "RECOMMENDED" | "PRIVILEGED"; @@ -4133,6 +4232,55 @@ export interface components { comment?: string; disposition?: string; }; + BackfillRequest: { + dryRun: boolean; + }; + ApiResponsePersonalNamespaceBackfillResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PersonalNamespaceBackfillResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + Entry: { + userId?: string; + displayName?: string; + slug?: string; + outcome?: string; + }; + PersonalNamespaceBackfillResponse: { + dryRun?: boolean; + /** Format: int32 */ + scannedAccounts?: number; + /** Format: int32 */ + alreadyProvisioned?: number; + /** Format: int32 */ + systemAccountsSkipped?: number; + truncated?: boolean; + entries?: components["schemas"]["Entry"][]; + }; + ApiResponseDefaultNamespaceBackfillResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["DefaultNamespaceBackfillResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + DefaultNamespaceBackfillResponse: { + dryRun?: boolean; + /** Format: int32 */ + scannedAccounts?: number; + /** Format: int32 */ + alreadyEnrolled?: number; + /** Format: int32 */ + systemAccountsSkipped?: number; + truncated?: boolean; + entries?: components["schemas"]["Entry"][]; + }; ProfileReviewRejectRequest: { comment: string; }; @@ -6392,6 +6540,94 @@ export interface operations { }; }; }; + getPersonalNamespaceSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePersonalNamespaceSettingsResponse"]; + }; + }; + }; + }; + updatePersonalNamespaceSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonalNamespaceSettingsUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePersonalNamespaceSettingsResponse"]; + }; + }; + }; + }; + getDefaultNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDefaultNamespaceSettingsResponse"]; + }; + }; + }; + }; + updateDefaultNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DefaultNamespaceSettingsUpdateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDefaultNamespaceSettingsResponse"]; + }; + }; + }; + }; updateMemberRole_2: { parameters: { query?: never; @@ -8546,6 +8782,54 @@ export interface operations { }; }; }; + backfillPersonalNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BackfillRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePersonalNamespaceBackfillResponse"]; + }; + }; + }; + }; + backfillDefaultNamespaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BackfillRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseDefaultNamespaceBackfillResponse"]; + }; + }; + }; + }; rebuildAll: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3f9663e67..590ad720f 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -583,3 +583,51 @@ export interface NotificationPreferenceItem { export interface NotificationUnreadCount { count: number } + +export interface PersonalNamespaceSettings { + enabled: boolean + slugTemplate: string + displayNameTemplate: string + supportedPlaceholders: string[] +} + +export interface PersonalNamespaceSettingsInput { + enabled: boolean + slugTemplate: string + displayNameTemplate: string +} + +export interface PersonalNamespaceBackfillEntry { + userId: string + displayName: string | null + slug: string | null + outcome: 'PLANNED' | 'CREATED' | 'NO_SLUG' +} + +export interface PersonalNamespaceBackfillResult { + dryRun: boolean + scannedAccounts: number + alreadyProvisioned: number + systemAccountsSkipped: number + truncated: boolean + entries: PersonalNamespaceBackfillEntry[] +} + +export interface DefaultNamespaceSettings { + slugs: string[] +} + +export interface DefaultNamespaceBackfillEntry { + userId: string + displayName: string | null + slugs: string[] +} + +export interface DefaultNamespaceBackfillResult { + dryRun: boolean + scannedAccounts: number + alreadyEnrolled: number + systemAccountsSkipped: number + truncated: boolean + entries: DefaultNamespaceBackfillEntry[] +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index bb1aac30c..3f8c6196a 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -151,6 +151,11 @@ const AdminNamespacesPage = createRoleProtectedRouteComponent( 'AdminNamespacesPage', ['SUPER_ADMIN'], ) +const AdminSettingsPage = createRoleProtectedRouteComponent( + () => import('@/pages/admin/settings'), + 'AdminSettingsPage', + ['SUPER_ADMIN'], +) function DefaultNotFound() { return ( @@ -457,6 +462,13 @@ const adminNamespacesRoute = createRoute({ component: AdminNamespacesPage, }) +const adminSettingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'admin/settings', + beforeLoad: requireAuth, + component: AdminSettingsPage, +}) + const routeTree = rootRoute.addChildren([ landingRoute, skillsRoute, @@ -494,6 +506,7 @@ const routeTree = rootRoute.addChildren([ adminAuditLogRoute, adminLabelsRoute, adminNamespacesRoute, + adminSettingsRoute, ]) export const router = createRouter({ diff --git a/web/src/features/admin/use-default-namespaces.ts b/web/src/features/admin/use-default-namespaces.ts new file mode 100644 index 000000000..9345bd44a --- /dev/null +++ b/web/src/features/admin/use-default-namespaces.ts @@ -0,0 +1,36 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { adminApi } from '@/api/client' +import type { DefaultNamespaceBackfillResult, DefaultNamespaceSettings } from '@/api/types' + +const QUERY_KEY = ['admin', 'settings', 'default-namespaces'] + +export function useDefaultNamespaces() { + return useQuery({ + queryKey: QUERY_KEY, + queryFn: () => adminApi.getDefaultNamespaces(), + }) +} + +export function useUpdateDefaultNamespaces() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (slugs: string[]) => adminApi.updateDefaultNamespaces(slugs), + onSuccess: (settings) => { + queryClient.setQueryData(QUERY_KEY, settings) + }, + }) +} + +export function useBackfillDefaultNamespaces() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (dryRun: boolean) => adminApi.backfillDefaultNamespaces(dryRun), + onSuccess: (result) => { + if (!result.dryRun) { + queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces'] }) + } + }, + }) +} diff --git a/web/src/features/admin/use-personal-namespace-settings.ts b/web/src/features/admin/use-personal-namespace-settings.ts new file mode 100644 index 000000000..eca994026 --- /dev/null +++ b/web/src/features/admin/use-personal-namespace-settings.ts @@ -0,0 +1,41 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { adminApi } from '@/api/client' +import type { + PersonalNamespaceBackfillResult, + PersonalNamespaceSettings, + PersonalNamespaceSettingsInput, +} from '@/api/types' + +const QUERY_KEY = ['admin', 'settings', 'personal-namespace'] + +export function usePersonalNamespaceSettings() { + return useQuery({ + queryKey: QUERY_KEY, + queryFn: () => adminApi.getPersonalNamespaceSettings(), + }) +} + +export function useUpdatePersonalNamespaceSettings() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (request: PersonalNamespaceSettingsInput) => + adminApi.updatePersonalNamespaceSettings(request), + onSuccess: (settings) => { + queryClient.setQueryData(QUERY_KEY, settings) + }, + }) +} + +export function useBackfillPersonalNamespaces() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (dryRun: boolean) => adminApi.backfillPersonalNamespaces(dryRun), + onSuccess: (result) => { + if (!result.dryRun) { + queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces'] }) + } + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index dcfc28bfe..c5cab6a00 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1338,7 +1338,8 @@ "notifications": "Notification Settings", "accounts": "Account Merge", "logout": "Logout", - "namespacesAdmin": "Namespace management" + "namespacesAdmin": "Namespace management", + "platformSettings": "Platform settings" } }, "footer": { @@ -1644,5 +1645,62 @@ "unfreezeErrorTitle": "Failed to unfreeze namespace", "archiveErrorTitle": "Failed to archive namespace", "restoreErrorTitle": "Failed to restore namespace" + }, + "adminSettings": { + "title": "Platform settings", + "subtitle": "Settings that apply to the whole deployment.", + "personalNamespaceTitle": "Personal namespace on registration", + "personalNamespaceDescription": "Give every newly activated account a namespace of its own. The account is the only member and holds the owner role. Existing accounts are not affected.", + "enabledLabel": "Automatic creation", + "enabledOn": "Enabled", + "enabledOff": "Disabled", + "slugTemplateLabel": "Namespace slug template", + "displayNameTemplateLabel": "Namespace display name template", + "placeholderHint": "Placeholders: {{placeholders}}", + "slugPreview": "Example slug: {{slug}}", + "slugRulesHint": "Slugs are lowercased, and anything other than a letter or digit becomes a hyphen. A number is appended when the slug is already taken or reserved.", + "displayNamePreview": "Example display name: {{displayName}}", + "loading": "Loading...", + "saveAction": "Save", + "saving": "Saving...", + "saveSuccessTitle": "Settings saved", + "saveSuccessDescription": "Accounts activated from now on use the updated policy.", + "saveErrorTitle": "Could not save settings", + "validationTitle": "Check the form", + "validationTemplateRequired": "Templates cannot be empty.", + "fallbackErrorDescription": "Please try again.", + "backfillTitle": "Existing accounts", + "backfillDescription": "Turning the setting on only affects accounts activated afterwards. Run this once to give the accounts that already exist the namespace they would have received.", + "backfillPreviewAction": "Preview", + "backfillApplyAction": "Create {{count}} namespaces", + "backfillPreviewFirstHint": "Preview first — the create button stays disabled until you do.", + "backfillSummary": "Scanned {{scanned}} accounts · {{already}} already had one · {{acted}} to act on", + "backfillTruncated": "Stopped at the per-run cap. Run it again to continue.", + "backfillNothingToDo": "Every account already has a namespace.", + "backfillColumnUser": "Account", + "backfillColumnSlug": "Slug", + "backfillColumnOutcome": "Outcome", + "backfillOutcome": { + "PLANNED": "Would be created", + "CREATED": "Created", + "NO_SLUG": "No slug available" + }, + "backfillDoneTitle": "Backfill finished", + "backfillDoneDescription": "Created {{count}} namespaces.", + "backfillErrorTitle": "Backfill failed", + "defaultsTitle": "Namespaces every account joins", + "defaultsDescription": "A namespace nobody belongs to is invisible: the namespace list only shows namespaces you are a member of. List the ones every newly activated account should be enrolled in.", + "defaultsLabel": "Namespaces", + "defaultsHint": "Tick the namespaces every newly activated account should be enrolled in.", + "defaultsSaveDescription": "Accounts activated from now on join these namespaces.", + "defaultsBackfillHint": "Preview first — the enrol button stays disabled until you do.", + "defaultsBackfillApplyAction": "Enrol {{count}} accounts", + "defaultsBackfillSummary": "Scanned {{scanned}} accounts · {{already}} already enrolled · {{acted}} to enrol", + "defaultsBackfillNothingToDo": "Every account is already enrolled.", + "defaultsBackfillDoneDescription": "Enrolled {{count}} accounts.", + "defaultsColumnSlugs": "Will join", + "defaultsNoNamespaces": "No active namespaces to choose from yet.", + "defaultsMissingNamespace": "no longer exists — untick to drop it", + "defaultsTruncated": "Showing the first namespaces only; there are more than this list holds." } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e2846cae8..c3bc9499b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1338,7 +1338,8 @@ "notifications": "通知设置", "accounts": "账号合并", "logout": "退出登录", - "namespacesAdmin": "命名空间管理" + "namespacesAdmin": "命名空间管理", + "platformSettings": "平台设置" } }, "footer": { @@ -1644,5 +1645,62 @@ "unfreezeErrorTitle": "解冻命名空间失败", "archiveErrorTitle": "归档命名空间失败", "restoreErrorTitle": "恢复命名空间失败" + }, + "adminSettings": { + "title": "平台设置", + "subtitle": "作用于整个部署的全局设置。", + "personalNamespaceTitle": "注册时自动创建个人命名空间", + "personalNamespaceDescription": "为每个新激活的账号创建一个专属命名空间:该账号是唯一成员,并拥有所有者角色。已有账号不受影响。", + "enabledLabel": "自动创建", + "enabledOn": "已启用", + "enabledOff": "已禁用", + "slugTemplateLabel": "命名空间标识模板", + "displayNameTemplateLabel": "命名空间显示名模板", + "placeholderHint": "可用占位符:{{placeholders}}", + "slugPreview": "标识示例:{{slug}}", + "slugRulesHint": "标识会转为小写,字母和数字以外的字符会变成连字符;标识已被占用或属于保留字时会自动追加数字后缀。", + "displayNamePreview": "显示名示例:{{displayName}}", + "loading": "加载中...", + "saveAction": "保存", + "saving": "保存中...", + "saveSuccessTitle": "设置已保存", + "saveSuccessDescription": "此后激活的账号将采用新的策略。", + "saveErrorTitle": "保存设置失败", + "validationTitle": "请检查表单", + "validationTemplateRequired": "模板不能为空。", + "fallbackErrorDescription": "请稍后重试。", + "backfillTitle": "为现有账号补建", + "backfillDescription": "打开开关只对之后激活的账号生效。已经存在的账号需要执行一次补建,才会拿到本该属于他们的命名空间。", + "backfillPreviewAction": "预览", + "backfillApplyAction": "创建 {{count}} 个命名空间", + "backfillPreviewFirstHint": "请先预览——未预览前创建按钮不可用。", + "backfillSummary": "扫描 {{scanned}} 个账号 · {{already}} 个已有 · {{acted}} 个待处理", + "backfillTruncated": "已达单次上限而停止,再执行一次可继续。", + "backfillNothingToDo": "所有账号都已经有命名空间了。", + "backfillColumnUser": "账号", + "backfillColumnSlug": "标识", + "backfillColumnOutcome": "结果", + "backfillOutcome": { + "PLANNED": "将创建", + "CREATED": "已创建", + "NO_SLUG": "无可用标识" + }, + "backfillDoneTitle": "补建完成", + "backfillDoneDescription": "已创建 {{count}} 个命名空间。", + "backfillErrorTitle": "补建失败", + "defaultsTitle": "全员默认加入的命名空间", + "defaultsDescription": "没有成员的命名空间是看不见的——命名空间列表只显示你是成员的那些。在这里列出每个新激活的账号都应该加入的命名空间。", + "defaultsLabel": "命名空间", + "defaultsHint": "勾选每个新激活的账号都应该加入的命名空间。", + "defaultsSaveDescription": "此后激活的账号会自动加入这些命名空间。", + "defaultsBackfillHint": "请先预览——未预览前加入按钮不可用。", + "defaultsBackfillApplyAction": "加入 {{count}} 个账号", + "defaultsBackfillSummary": "扫描 {{scanned}} 个账号 · {{already}} 个已加入 · {{acted}} 个待加入", + "defaultsBackfillNothingToDo": "所有账号都已经加入了。", + "defaultsBackfillDoneDescription": "已为 {{count}} 个账号加入。", + "defaultsColumnSlugs": "将加入", + "defaultsNoNamespaces": "目前还没有可选的启用状态命名空间。", + "defaultsMissingNamespace": "已不存在——取消勾选以移除", + "defaultsTruncated": "命名空间数量超出列表上限,这里只显示了前一部分。" } } diff --git a/web/src/pages/admin/settings.test.tsx b/web/src/pages/admin/settings.test.tsx new file mode 100644 index 000000000..c1ad23fe0 --- /dev/null +++ b/web/src/pages/admin/settings.test.tsx @@ -0,0 +1,177 @@ +/** @vitest-environment jsdom */ + +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const usePersonalNamespaceSettingsMock = vi.fn() +const useDefaultNamespacesMock = vi.fn() +const useAdminNamespacesMock = vi.fn() + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), + } +}) + +vi.mock('@/shared/lib/toast', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('@/features/admin/use-personal-namespace-settings', () => ({ + usePersonalNamespaceSettings: () => usePersonalNamespaceSettingsMock(), + useUpdatePersonalNamespaceSettings: () => ({ mutateAsync: vi.fn(), isPending: false }), + useBackfillPersonalNamespaces: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +vi.mock('@/features/admin/use-default-namespaces', () => ({ + useDefaultNamespaces: () => useDefaultNamespacesMock(), + useUpdateDefaultNamespaces: () => ({ mutateAsync: vi.fn(), isPending: false }), + useBackfillDefaultNamespaces: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +vi.mock('@/features/admin/use-admin-namespaces', () => ({ + useAdminNamespaces: () => useAdminNamespacesMock(), +})) + +import { AdminSettingsPage, previewSlug, renderTemplate } from './settings' + +describe('previewSlug', () => { + it('lowercases and hyphenates the rendered template', () => { + expect(previewSlug('${username}')).toBe('li-wei') + }) + + it('shows that underscores become hyphens', () => { + expect(previewSlug('${username}_space')).toBe('li-wei-space') + }) + + it('collapses repeated separators and trims the edges', () => { + expect(previewSlug('--${username}...space--')).toBe('li-wei-space') + }) + + it('keeps an unknown placeholder visible instead of dropping it', () => { + expect(renderTemplate('${nickname}')).toBe('${nickname}') + }) + + it('renders the email prefix placeholder', () => { + expect(previewSlug('${email_prefix}')).toBe('li-wei') + }) +}) + +describe('AdminSettingsPage', () => { + beforeEach(() => { + usePersonalNamespaceSettingsMock.mockReturnValue({ + data: { + enabled: true, + slugTemplate: '${username}', + displayNameTemplate: '${username}', + supportedPlaceholders: ['username', 'email_prefix', 'user_id'], + }, + isLoading: false, + }) + useDefaultNamespacesMock.mockReturnValue({ + data: { slugs: ['global', 'musee'] }, + isLoading: false, + }) + useAdminNamespacesMock.mockReturnValue({ + data: { + items: [{ slug: 'global' }, { slug: 'musee' }, { slug: 'team-a' }], + total: 3, + }, + isLoading: false, + }) + }) + + afterEach(() => { + cleanup() + }) + + it('renders the personal namespace section', async () => { + render() + + expect(await screen.findByText('adminSettings.personalNamespaceTitle')).toBeDefined() + expect(await screen.findByText('adminSettings.slugTemplateLabel')).toBeDefined() + }) + + /** + * Regression: the form used to mount before the fetched settings reached it. Radix's Select + * keeps a hidden native for form integration whose

+
+

{t('adminSettings.title')}

+

{t('adminSettings.subtitle')}

+
+ + +
+

{t('adminSettings.personalNamespaceTitle')}

+

+ {t('adminSettings.personalNamespaceDescription')} +

+
+ + {isLoading || !form ? ( +
{t('adminSettings.loading')}
+ ) : ( +
+
+ + +
+ +
+ + + setForm((current) => + current ? { ...current, slugTemplate: event.target.value } : current, + ) + } + /> +

+ {t('adminSettings.placeholderHint', { placeholders: placeholders.map((name) => `\${${name}}`).join(', ') })} +

+

+ {t('adminSettings.slugPreview', { slug: slugPreview || '—' })} +

+

{t('adminSettings.slugRulesHint')}

+
+ +
+ + + setForm((current) => + current ? { ...current, displayNameTemplate: event.target.value } : current, + ) + } + /> +

+ {t('adminSettings.displayNamePreview', { displayName: displayNamePreview || '—' })} +

+
+ +
+ +
+
+ )} +
+ + +
+

{t('adminSettings.defaultsTitle')}

+

{t('adminSettings.defaultsDescription')}

+
+ + {defaultsLoading || namespacesLoading || defaultSlugs === null ? ( +
{t('adminSettings.loading')}
+ ) : ( +
+
+ {t('adminSettings.defaultsLabel')} + {namespaceOptions.length === 0 ? ( +

{t('adminSettings.defaultsNoNamespaces')}

+ ) : ( +
+ {namespaceOptions.map((slug) => { + const missing = staleSlugs.includes(slug) + return ( + + ) + })} +
+ )} + {namespacesTruncated ? ( +

{t('adminSettings.defaultsTruncated')}

+ ) : null} +

{t('adminSettings.defaultsHint')}

+
+
+ +
+ +
+ + +
+

{t('adminSettings.defaultsBackfillHint')}

+ + {defaultsBackfill ? ( +
+

+ {t('adminSettings.defaultsBackfillSummary', { + scanned: defaultsBackfill.scannedAccounts, + already: defaultsBackfill.alreadyEnrolled, + acted: defaultsBackfill.entries.length, + })} +

+ {defaultsBackfill.truncated ? ( +

+ {t('adminSettings.backfillTruncated')} +

+ ) : null} + {defaultsBackfill.entries.length === 0 ? ( +

+ {t('adminSettings.defaultsBackfillNothingToDo')} +

+ ) : ( +
+ + + + {t('adminSettings.backfillColumnUser')} + {t('adminSettings.defaultsColumnSlugs')} + + + + {defaultsBackfill.entries.map((entry) => ( + + {entry.displayName || entry.userId} + {entry.slugs.join(', ')} + + ))} + +
+
+ )} +
+ ) : null} +
+ )} +
+ + +
+

{t('adminSettings.backfillTitle')}

+

+ {t('adminSettings.backfillDescription')} +

+
+ +
+ + +
+

{t('adminSettings.backfillPreviewFirstHint')}

+ + {backfill ? ( +
+

+ {t('adminSettings.backfillSummary', { + scanned: backfill.scannedAccounts, + already: backfill.alreadyProvisioned, + acted: backfill.entries.length, + })} +

+ {backfill.truncated ? ( +

{t('adminSettings.backfillTruncated')}

+ ) : null} + {backfill.entries.length === 0 ? ( +

{t('adminSettings.backfillNothingToDo')}

+ ) : ( +
+ + + + {t('adminSettings.backfillColumnUser')} + {t('adminSettings.backfillColumnSlug')} + {t('adminSettings.backfillColumnOutcome')} + + + + {backfill.entries.map((entry) => ( + + {entry.displayName || entry.userId} + {entry.slug ?? '—'} + {t(`adminSettings.backfillOutcome.${entry.outcome}`)} + + ))} + +
+
+ )} +
+ ) : null} +
+
+ ) +} diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index bd3f4cba0..2139142fa 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -195,6 +195,11 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { {t('user.menu.namespacesAdmin')} ) : null} + {isSuperAdmin ? ( + + {t('user.menu.platformSettings')} + + ) : null} {isAuditor ? ( {t('user.menu.auditLog')}