mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
Merge 7247defd5d into 26f49e6819
This commit is contained in:
commit
5376217928
61 changed files with 3961 additions and 132 deletions
|
|
@ -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)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
|
|
|||
176
docs/2026-08-13-personal-namespace-provisioning.md
Normal file
176
docs/2026-08-13-personal-namespace-provisioning.md
Normal file
|
|
@ -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> T get(String settingKey, Class<T> 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`。
|
||||
|
|
@ -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<PersonalNamespaceSettingsResponse> getPersonalNamespaceSettings() {
|
||||
return ok("response.success.read", personalNamespaceSettingsAppService.get());
|
||||
}
|
||||
|
||||
@PutMapping("/personal-namespace")
|
||||
@PreAuthorize("hasRole('SUPER_ADMIN')")
|
||||
public ApiResponse<PersonalNamespaceSettingsResponse> 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<PersonalNamespaceBackfillResponse> 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<DefaultNamespaceSettingsResponse> getDefaultNamespaces() {
|
||||
return ok("response.success.read", defaultNamespaceSettingsAppService.get());
|
||||
}
|
||||
|
||||
@PutMapping("/default-namespaces")
|
||||
@PreAuthorize("hasRole('SUPER_ADMIN')")
|
||||
public ApiResponse<DefaultNamespaceSettingsResponse> 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<DefaultNamespaceBackfillResponse> 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -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<Entry> entries) {
|
||||
|
||||
public record Entry(String userId, String displayName, List<String> slugs) {}
|
||||
}
|
||||
|
|
@ -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<String> slugs) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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<Entry> entries) {
|
||||
|
||||
/**
|
||||
* @param outcome one of {@code PLANNED}, {@code CREATED}, {@code NO_SLUG}
|
||||
*/
|
||||
public record Entry(String userId, String displayName, String slug, String outcome) {}
|
||||
}
|
||||
|
|
@ -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<String> supportedPlaceholders
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.entity.Role;
|
|||
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.Page;
|
|||
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 class AdminUserAppService {
|
|||
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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> detail) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(detail);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> 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<String, Object> 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<String, Object> 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<String, Object> describe(PersonalNamespaceSettings settings) {
|
||||
Map<String, Object> described = new LinkedHashMap<>();
|
||||
described.put("enabled", settings.enabled());
|
||||
described.put("slugTemplate", settings.slugTemplate());
|
||||
described.put("displayNameTemplate", settings.displayNameTemplate());
|
||||
return described;
|
||||
}
|
||||
|
||||
private String toJson(Map<String, Object> detail) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(detail);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +117,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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -77,6 +77,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
|
||||
|
|
|
|||
|
|
@ -77,6 +77,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 角色
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.iflytek.skillhub.domain.user.UserStatus;
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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<PersonalNamespaceSettings> 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<String> 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<String> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -6,10 +6,12 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
|||
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 class IdentityBindingService {
|
|||
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());
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
|||
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.Set;
|
|||
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 class LocalAuthService {
|
|||
normalizedUsername,
|
||||
passwordEncoder.encode(password)
|
||||
));
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
defaultNamespaceMembershipService.ensureMember(user.getId());
|
||||
eventPublisher.publishEvent(new UserActivatedEvent(user.getId(), normalizedUsername, normalizedEmail));
|
||||
|
||||
return buildPrincipal(user);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ import com.iflytek.skillhub.auth.oauth.AccountPendingException;
|
|||
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.junit.jupiter.api.extension.ExtendWith;
|
|||
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 @@ class IdentityBindingServiceTest {
|
|||
|
||||
ArgumentCaptor<UserAccount> 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<UserActivatedEvent> 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 @@ class IdentityBindingServiceTest {
|
|||
assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.PENDING))
|
||||
.isInstanceOf(AccountPendingException.class);
|
||||
|
||||
verify(globalNamespaceMembershipService, never()).ensureMember(any());
|
||||
verify(defaultNamespaceMembershipService, never()).ensureMember(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException;
|
|||
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.junit.jupiter.api.extension.ExtendWith;
|
|||
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 @@ class LocalAuthServiceTest {
|
|||
credentialRepository,
|
||||
userAccountRepository,
|
||||
userRoleBindingRepository,
|
||||
globalNamespaceMembershipService,
|
||||
defaultNamespaceMembershipService,
|
||||
new PasswordPolicyValidator(),
|
||||
passwordEncoder,
|
||||
CLOCK
|
||||
CLOCK,
|
||||
eventPublisher
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +89,23 @@ class LocalAuthServiceTest {
|
|||
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<UserActivatedEvent> 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>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) {
|
||||
}
|
||||
|
|
@ -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<String> slugs) {
|
||||
|
||||
public DefaultNamespaceBackfillEntry {
|
||||
slugs = slugs == null ? List.of() : List.copyOf(slugs);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DefaultNamespaceBackfillEntry> entries) {
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<String> 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.
|
||||
*
|
||||
* <p>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> 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.
|
||||
*
|
||||
* <p>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<Namespace> 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<DefaultNamespaceBackfillEntry> entries = new ArrayList<>();
|
||||
int scanned = 0;
|
||||
int alreadyMember = 0;
|
||||
int systemAccounts = 0;
|
||||
boolean truncated = false;
|
||||
|
||||
for (int page = 0; !truncated && !targets.isEmpty(); page++) {
|
||||
Page<UserAccount> 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<String> 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<String> slugs = new ArrayList<>(List.of("global"));
|
||||
|
||||
public List<String> getSlugs() {
|
||||
return slugs;
|
||||
}
|
||||
|
||||
public void setSlugs(List<String> slugs) {
|
||||
this.slugs = slugs;
|
||||
}
|
||||
|
||||
public DefaultNamespaceSettings toSettings() {
|
||||
return new DefaultNamespaceSettings(slugs);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<String> slugs) {
|
||||
|
||||
public DefaultNamespaceSettings {
|
||||
slugs = slugs == null ? List.of() : List.copyOf(slugs);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Outcome of a backfill run over existing accounts.
|
||||
*
|
||||
* <p>{@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<PersonalNamespaceBackfillEntry> entries) {
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<String, String> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
/**
|
||||
* The account a personal namespace is being created for.
|
||||
*
|
||||
* <p>{@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) {
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<Namespace> 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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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<PersonalNamespaceBackfillEntry> entries = new ArrayList<>();
|
||||
Set<String> reserved = new HashSet<>();
|
||||
int scanned = 0;
|
||||
int alreadyProvisioned = 0;
|
||||
int systemAccounts = 0;
|
||||
boolean truncated = false;
|
||||
|
||||
for (int page = 0; !truncated; page++) {
|
||||
Page<UserAccount> 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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -44,12 +44,37 @@ public class SlugValidator {
|
|||
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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.setting;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SystemSettingRepository {
|
||||
Optional<SystemSetting> findBySettingKey(String settingKey);
|
||||
SystemSetting save(SystemSetting setting);
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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> T get(String settingKey, Class<T> type, T defaults) {
|
||||
Optional<SystemSetting> 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> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Operator-configurable platform settings.
|
||||
*
|
||||
* <p>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;
|
||||
|
|
@ -14,5 +14,16 @@ public interface UserAccountRepository {
|
|||
List<UserAccount> findByIdIn(List<String> ids);
|
||||
Optional<UserAccount> findByEmailIgnoreCase(String email);
|
||||
Page<UserAccount> search(String keyword, UserStatus status, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Lists accounts in one status.
|
||||
*
|
||||
* <p>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<UserAccount> findByStatus(UserStatus status, Pageable pageable);
|
||||
|
||||
UserAccount save(UserAccount user);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NamespaceMember> 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<DefaultNamespaceSettings> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<NamespaceMember> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SystemSetting> 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SystemSetting, String>, SystemSettingRepository {
|
||||
Optional<SystemSetting> findBySettingKey(String settingKey);
|
||||
}
|
||||
|
|
@ -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<PersonalNamespaceSettings> {
|
||||
return fetchJson<PersonalNamespaceSettings>('/api/v1/admin/settings/personal-namespace')
|
||||
},
|
||||
|
||||
async updatePersonalNamespaceSettings(
|
||||
request: PersonalNamespaceSettingsInput,
|
||||
): Promise<PersonalNamespaceSettings> {
|
||||
return fetchJson<PersonalNamespaceSettings>('/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<PersonalNamespaceBackfillResult> {
|
||||
return fetchJson<PersonalNamespaceBackfillResult>(
|
||||
'/api/v1/admin/settings/personal-namespace/backfill',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ dryRun }),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
async getDefaultNamespaces(): Promise<DefaultNamespaceSettings> {
|
||||
return fetchJson<DefaultNamespaceSettings>('/api/v1/admin/settings/default-namespaces')
|
||||
},
|
||||
|
||||
async updateDefaultNamespaces(slugs: string[]): Promise<DefaultNamespaceSettings> {
|
||||
return fetchJson<DefaultNamespaceSettings>('/api/v1/admin/settings/default-namespaces', {
|
||||
method: 'PUT',
|
||||
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ slugs }),
|
||||
})
|
||||
},
|
||||
|
||||
async backfillDefaultNamespaces(dryRun: boolean): Promise<DefaultNamespaceBackfillResult> {
|
||||
return fetchJson<DefaultNamespaceBackfillResult>(
|
||||
'/api/v1/admin/settings/default-namespaces/backfill',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ dryRun }),
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export const notificationApi = {
|
||||
|
|
|
|||
284
web/src/api/generated/schema.d.ts
vendored
284
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -6394,6 +6542,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;
|
||||
|
|
@ -8550,6 +8786,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;
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
36
web/src/features/admin/use-default-namespaces.ts
Normal file
36
web/src/features/admin/use-default-namespaces.ts
Normal file
|
|
@ -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<DefaultNamespaceSettings>({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: () => adminApi.getDefaultNamespaces(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateDefaultNamespaces() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<DefaultNamespaceSettings, Error, string[]>({
|
||||
mutationFn: (slugs: string[]) => adminApi.updateDefaultNamespaces(slugs),
|
||||
onSuccess: (settings) => {
|
||||
queryClient.setQueryData(QUERY_KEY, settings)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useBackfillDefaultNamespaces() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<DefaultNamespaceBackfillResult, Error, boolean>({
|
||||
mutationFn: (dryRun: boolean) => adminApi.backfillDefaultNamespaces(dryRun),
|
||||
onSuccess: (result) => {
|
||||
if (!result.dryRun) {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces'] })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
41
web/src/features/admin/use-personal-namespace-settings.ts
Normal file
41
web/src/features/admin/use-personal-namespace-settings.ts
Normal file
|
|
@ -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<PersonalNamespaceSettings>({
|
||||
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<PersonalNamespaceBackfillResult, Error, boolean>({
|
||||
mutationFn: (dryRun: boolean) => adminApi.backfillPersonalNamespaces(dryRun),
|
||||
onSuccess: (result) => {
|
||||
if (!result.dryRun) {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'namespaces'] })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1339,7 +1339,8 @@
|
|||
"notifications": "Notification Settings",
|
||||
"accounts": "Account Merge",
|
||||
"logout": "Logout",
|
||||
"namespacesAdmin": "Namespace management"
|
||||
"namespacesAdmin": "Namespace management",
|
||||
"platformSettings": "Platform settings"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
|
|
@ -1646,5 +1647,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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1339,7 +1339,8 @@
|
|||
"notifications": "通知设置",
|
||||
"accounts": "账号合并",
|
||||
"logout": "退出登录",
|
||||
"namespacesAdmin": "命名空间管理"
|
||||
"namespacesAdmin": "命名空间管理",
|
||||
"platformSettings": "平台设置"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
|
|
@ -1646,5 +1647,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": "命名空间数量超出列表上限,这里只显示了前一部分。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
177
web/src/pages/admin/settings.test.tsx
Normal file
177
web/src/pages/admin/settings.test.tsx
Normal file
|
|
@ -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<typeof import('react-i18next')>('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(<AdminSettingsPage />)
|
||||
|
||||
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 <select> whose options only exist while the dropdown is mounted, so
|
||||
* changing the controlled value afterwards landed on "" and fired onValueChange(""), which read
|
||||
* as "disabled" and silently reverted the server's answer.
|
||||
*/
|
||||
it('keeps the enabled setting the server returned', async () => {
|
||||
render(<AdminSettingsPage />)
|
||||
|
||||
const slugTemplate = (await screen.findByLabelText(
|
||||
'adminSettings.slugTemplateLabel',
|
||||
)) as HTMLInputElement
|
||||
|
||||
await waitFor(() => {
|
||||
expect(slugTemplate.disabled).toBe(false)
|
||||
})
|
||||
// The trigger renders the selected item's label, so it must read "enabled".
|
||||
const trigger = document.querySelector('#personal-namespace-enabled')
|
||||
expect(trigger?.textContent).toContain('adminSettings.enabledOn')
|
||||
})
|
||||
|
||||
it('shows a loading state while the settings are fetched', () => {
|
||||
usePersonalNamespaceSettingsMock.mockReturnValue({ data: undefined, isLoading: true })
|
||||
|
||||
render(<AdminSettingsPage />)
|
||||
|
||||
expect(screen.getByText('adminSettings.loading')).toBeDefined()
|
||||
})
|
||||
|
||||
it('offers the backfill for accounts that already exist', async () => {
|
||||
render(<AdminSettingsPage />)
|
||||
|
||||
expect(await screen.findByText('adminSettings.backfillTitle')).toBeDefined()
|
||||
// One preview button per backfill: default namespaces, and personal namespaces.
|
||||
expect(screen.getAllByRole('button', { name: 'adminSettings.backfillPreviewAction' })).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the apply button disabled until a preview has been run', async () => {
|
||||
render(<AdminSettingsPage />)
|
||||
|
||||
const apply = await screen.findByRole('button', { name: /adminSettings.backfillApplyAction/ })
|
||||
expect((apply as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('ticks exactly the default namespaces the server returned', async () => {
|
||||
render(<AdminSettingsPage />)
|
||||
|
||||
await screen.findByText('adminSettings.defaultsTitle')
|
||||
const ticked = (slug: string) =>
|
||||
(screen.getByText(slug).closest('label')?.querySelector('input') as HTMLInputElement).checked
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ticked('global')).toBe(true)
|
||||
})
|
||||
expect(ticked('musee')).toBe(true)
|
||||
expect(ticked('team-a')).toBe(false)
|
||||
})
|
||||
|
||||
/**
|
||||
* A configured namespace that has since been deleted is not in the choice list. It must still
|
||||
* appear, ticked and flagged, so saving cannot drop it without the operator noticing.
|
||||
*/
|
||||
it('still offers a configured namespace that no longer exists', async () => {
|
||||
useDefaultNamespacesMock.mockReturnValue({
|
||||
data: { slugs: ['global', 'vanished'] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<AdminSettingsPage />)
|
||||
|
||||
const row = (await screen.findByText('vanished')).closest('label')
|
||||
expect((row?.querySelector('input') as HTMLInputElement).checked).toBe(true)
|
||||
expect(row?.textContent).toContain('adminSettings.defaultsMissingNamespace')
|
||||
})
|
||||
})
|
||||
502
web/src/pages/admin/settings.tsx
Normal file
502
web/src/pages/admin/settings.tsx
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/shared/ui/table'
|
||||
import type {
|
||||
DefaultNamespaceBackfillResult,
|
||||
PersonalNamespaceBackfillResult,
|
||||
PersonalNamespaceSettingsInput,
|
||||
} from '@/api/types'
|
||||
import {
|
||||
useBackfillPersonalNamespaces,
|
||||
usePersonalNamespaceSettings,
|
||||
useUpdatePersonalNamespaceSettings,
|
||||
} from '@/features/admin/use-personal-namespace-settings'
|
||||
import {
|
||||
useBackfillDefaultNamespaces,
|
||||
useDefaultNamespaces,
|
||||
useUpdateDefaultNamespaces,
|
||||
} from '@/features/admin/use-default-namespaces'
|
||||
import { useAdminNamespaces } from '@/features/admin/use-admin-namespaces'
|
||||
|
||||
/**
|
||||
* Upper bound on the namespaces offered as choices. Beyond this the list is
|
||||
* reported as partial rather than silently cut.
|
||||
*/
|
||||
const NAMESPACE_CHOICE_LIMIT = 200
|
||||
|
||||
/**
|
||||
* Sample account used for the live template preview.
|
||||
*/
|
||||
const PREVIEW_OWNER: Record<string, string> = {
|
||||
username: 'Li.Wei',
|
||||
email_prefix: 'li.wei',
|
||||
user_id: 'usr_4f9c2a1b',
|
||||
}
|
||||
|
||||
export function renderTemplate(template: string): string {
|
||||
return template.replace(/\$\{([a-z_]+)}/g, (match, name: string) => PREVIEW_OWNER[name] ?? match)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the server's slug rules so operators can see the effect of a template — in particular
|
||||
* that underscores and dots become hyphens — before saving it.
|
||||
*/
|
||||
export function previewSlug(template: string): string {
|
||||
return renderTemplate(template)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||
.replace(/^-+/, '')
|
||||
.replace(/-+$/, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
}
|
||||
|
||||
export function AdminSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { data: settings, isLoading } = usePersonalNamespaceSettings()
|
||||
const updateMutation = useUpdatePersonalNamespaceSettings()
|
||||
const backfillMutation = useBackfillPersonalNamespaces()
|
||||
const [backfill, setBackfill] = useState<PersonalNamespaceBackfillResult | null>(null)
|
||||
|
||||
const { data: defaults, isLoading: defaultsLoading } = useDefaultNamespaces()
|
||||
const { data: namespacePage, isLoading: namespacesLoading } = useAdminNamespaces({
|
||||
status: 'ACTIVE',
|
||||
page: 0,
|
||||
size: NAMESPACE_CHOICE_LIMIT,
|
||||
})
|
||||
const updateDefaultsMutation = useUpdateDefaultNamespaces()
|
||||
const defaultsBackfillMutation = useBackfillDefaultNamespaces()
|
||||
// Null until loaded, for the same reason as `form` below.
|
||||
const [defaultSlugs, setDefaultSlugs] = useState<string[] | null>(null)
|
||||
const [defaultsBackfill, setDefaultsBackfill] = useState<DefaultNamespaceBackfillResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaults) {
|
||||
return
|
||||
}
|
||||
setDefaultSlugs((current) => current ?? defaults.slugs)
|
||||
}, [defaults])
|
||||
|
||||
const namespaceChoices = namespacePage?.items.map((item) => item.slug) ?? []
|
||||
// A slug can be configured and yet missing here — the namespace was deleted, archived or
|
||||
// renamed. Surface it as its own choice rather than dropping it silently on the next save.
|
||||
const staleSlugs = (defaultSlugs ?? []).filter((slug) => !namespaceChoices.includes(slug))
|
||||
const namespaceOptions = [...namespaceChoices, ...staleSlugs]
|
||||
const namespacesTruncated = (namespacePage?.total ?? 0) > namespaceChoices.length
|
||||
|
||||
const toggleDefaultSlug = (slug: string, checked: boolean) => {
|
||||
setDefaultSlugs((current) => {
|
||||
if (current === null) {
|
||||
return current
|
||||
}
|
||||
if (checked) {
|
||||
return current.includes(slug) ? current : [...current, slug]
|
||||
}
|
||||
return current.filter((value) => value !== slug)
|
||||
})
|
||||
}
|
||||
|
||||
// Null until the server answers. The form must not mount before then: Radix's
|
||||
// Select keeps a hidden native <select> for form integration whose <option>s
|
||||
// only exist while the dropdown content is mounted. Changing the controlled
|
||||
// value before the user has ever opened it therefore assigns a value the
|
||||
// native select has no option for, which lands on "" and fires a real change
|
||||
// event — arriving here as onValueChange(""), which would read as "disabled"
|
||||
// and silently undo what the server just told us.
|
||||
const [form, setForm] = useState<PersonalNamespaceSettingsInput | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
setForm((current) => current ?? {
|
||||
enabled: settings.enabled,
|
||||
slugTemplate: settings.slugTemplate,
|
||||
displayNameTemplate: settings.displayNameTemplate,
|
||||
})
|
||||
}, [settings])
|
||||
|
||||
const slugPreview = form ? previewSlug(form.slugTemplate) : ''
|
||||
const displayNamePreview = form ? renderTemplate(form.displayNameTemplate).trim() : ''
|
||||
const placeholders = settings?.supportedPlaceholders ?? Object.keys(PREVIEW_OWNER)
|
||||
|
||||
const runBackfill = async (dryRun: boolean) => {
|
||||
try {
|
||||
const result = await backfillMutation.mutateAsync(dryRun)
|
||||
setBackfill(result)
|
||||
if (!dryRun) {
|
||||
const created = result.entries.filter((entry) => entry.outcome === 'CREATED').length
|
||||
toast.success(
|
||||
t('adminSettings.backfillDoneTitle'),
|
||||
t('adminSettings.backfillDoneDescription', { count: created }),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t('adminSettings.backfillErrorTitle'),
|
||||
error instanceof Error ? error.message : t('adminSettings.fallbackErrorDescription'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const plannedCount = backfill?.dryRun
|
||||
? backfill.entries.filter((entry) => entry.outcome === 'PLANNED').length
|
||||
: 0
|
||||
|
||||
const saveDefaults = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (defaultSlugs === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = await updateDefaultsMutation.mutateAsync(defaultSlugs)
|
||||
setDefaultSlugs(saved.slugs)
|
||||
setDefaultsBackfill(null)
|
||||
toast.success(t('adminSettings.saveSuccessTitle'), t('adminSettings.defaultsSaveDescription'))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t('adminSettings.saveErrorTitle'),
|
||||
error instanceof Error ? error.message : t('adminSettings.fallbackErrorDescription'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const runDefaultsBackfill = async (dryRun: boolean) => {
|
||||
try {
|
||||
const result = await defaultsBackfillMutation.mutateAsync(dryRun)
|
||||
setDefaultsBackfill(result)
|
||||
if (!dryRun) {
|
||||
toast.success(
|
||||
t('adminSettings.backfillDoneTitle'),
|
||||
t('adminSettings.defaultsBackfillDoneDescription', { count: result.entries.length }),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t('adminSettings.backfillErrorTitle'),
|
||||
error instanceof Error ? error.message : t('adminSettings.fallbackErrorDescription'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultsPlannedCount = defaultsBackfill?.dryRun ? defaultsBackfill.entries.length : 0
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!form) {
|
||||
return
|
||||
}
|
||||
if (!form.slugTemplate.trim() || !form.displayNameTemplate.trim()) {
|
||||
toast.error(t('adminSettings.validationTitle'), t('adminSettings.validationTemplateRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync(form)
|
||||
toast.success(t('adminSettings.saveSuccessTitle'), t('adminSettings.saveSuccessDescription'))
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t('adminSettings.saveErrorTitle'),
|
||||
error instanceof Error ? error.message : t('adminSettings.fallbackErrorDescription'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<div>
|
||||
<h1 className="mb-2 text-4xl font-bold font-heading">{t('adminSettings.title')}</h1>
|
||||
<p className="text-lg text-muted-foreground">{t('adminSettings.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold font-heading">{t('adminSettings.personalNamespaceTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('adminSettings.personalNamespaceDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading || !form ? (
|
||||
<div className="text-sm text-muted-foreground">{t('adminSettings.loading')}</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-2 md:max-w-xs">
|
||||
<Label htmlFor="personal-namespace-enabled">{t('adminSettings.enabledLabel')}</Label>
|
||||
<Select
|
||||
value={form.enabled ? 'enabled' : 'disabled'}
|
||||
onValueChange={(value) => {
|
||||
// Ignore anything that is not a real choice; see the note on `form`.
|
||||
if (value !== 'enabled' && value !== 'disabled') {
|
||||
return
|
||||
}
|
||||
setForm((current) =>
|
||||
current ? { ...current, enabled: value === 'enabled' } : current,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="personal-namespace-enabled">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">{t('adminSettings.enabledOn')}</SelectItem>
|
||||
<SelectItem value="disabled">{t('adminSettings.enabledOff')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="personal-namespace-slug-template">{t('adminSettings.slugTemplateLabel')}</Label>
|
||||
<Input
|
||||
id="personal-namespace-slug-template"
|
||||
value={form.slugTemplate}
|
||||
disabled={!form.enabled}
|
||||
onChange={(event) =>
|
||||
setForm((current) =>
|
||||
current ? { ...current, slugTemplate: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('adminSettings.placeholderHint', { placeholders: placeholders.map((name) => `\${${name}}`).join(', ') })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('adminSettings.slugPreview', { slug: slugPreview || '—' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t('adminSettings.slugRulesHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="personal-namespace-display-template">
|
||||
{t('adminSettings.displayNameTemplateLabel')}
|
||||
</Label>
|
||||
<Input
|
||||
id="personal-namespace-display-template"
|
||||
value={form.displayNameTemplate}
|
||||
disabled={!form.enabled}
|
||||
onChange={(event) =>
|
||||
setForm((current) =>
|
||||
current ? { ...current, displayNameTemplate: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('adminSettings.displayNamePreview', { displayName: displayNamePreview || '—' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? t('adminSettings.saving') : t('adminSettings.saveAction')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold font-heading">{t('adminSettings.defaultsTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('adminSettings.defaultsDescription')}</p>
|
||||
</div>
|
||||
|
||||
{defaultsLoading || namespacesLoading || defaultSlugs === null ? (
|
||||
<div className="text-sm text-muted-foreground">{t('adminSettings.loading')}</div>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={saveDefaults}>
|
||||
<div className="grid gap-2">
|
||||
<span className="text-sm font-medium">{t('adminSettings.defaultsLabel')}</span>
|
||||
{namespaceOptions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('adminSettings.defaultsNoNamespaces')}</p>
|
||||
) : (
|
||||
<div
|
||||
id="default-namespaces"
|
||||
className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border/60 p-3"
|
||||
>
|
||||
{namespaceOptions.map((slug) => {
|
||||
const missing = staleSlugs.includes(slug)
|
||||
return (
|
||||
<label
|
||||
key={slug}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 hover:bg-secondary/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 shrink-0 accent-primary"
|
||||
checked={defaultSlugs.includes(slug)}
|
||||
onChange={(event) => toggleDefaultSlug(slug, event.target.checked)}
|
||||
/>
|
||||
<span className="font-mono text-sm">{slug}</span>
|
||||
{missing ? (
|
||||
<span className="text-xs text-destructive">
|
||||
{t('adminSettings.defaultsMissingNamespace')}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{namespacesTruncated ? (
|
||||
<p className="text-xs text-muted-foreground">{t('adminSettings.defaultsTruncated')}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">{t('adminSettings.defaultsHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<Button type="submit" disabled={updateDefaultsMutation.isPending}>
|
||||
{updateDefaultsMutation.isPending
|
||||
? t('adminSettings.saving')
|
||||
: t('adminSettings.saveAction')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 border-t border-border/60 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={defaultsBackfillMutation.isPending}
|
||||
onClick={() => runDefaultsBackfill(true)}
|
||||
>
|
||||
{t('adminSettings.backfillPreviewAction')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
defaultsBackfillMutation.isPending ||
|
||||
!defaultsBackfill?.dryRun ||
|
||||
defaultsPlannedCount === 0
|
||||
}
|
||||
onClick={() => runDefaultsBackfill(false)}
|
||||
>
|
||||
{t('adminSettings.defaultsBackfillApplyAction', { count: defaultsPlannedCount })}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('adminSettings.defaultsBackfillHint')}</p>
|
||||
|
||||
{defaultsBackfill ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adminSettings.defaultsBackfillSummary', {
|
||||
scanned: defaultsBackfill.scannedAccounts,
|
||||
already: defaultsBackfill.alreadyEnrolled,
|
||||
acted: defaultsBackfill.entries.length,
|
||||
})}
|
||||
</p>
|
||||
{defaultsBackfill.truncated ? (
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t('adminSettings.backfillTruncated')}
|
||||
</p>
|
||||
) : null}
|
||||
{defaultsBackfill.entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adminSettings.defaultsBackfillNothingToDo')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('adminSettings.backfillColumnUser')}</TableHead>
|
||||
<TableHead>{t('adminSettings.defaultsColumnSlugs')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defaultsBackfill.entries.map((entry) => (
|
||||
<TableRow key={entry.userId}>
|
||||
<TableCell>{entry.displayName || entry.userId}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{entry.slugs.join(', ')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-xl font-semibold font-heading">{t('adminSettings.backfillTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('adminSettings.backfillDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={backfillMutation.isPending}
|
||||
onClick={() => runBackfill(true)}
|
||||
>
|
||||
{t('adminSettings.backfillPreviewAction')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={backfillMutation.isPending || !backfill?.dryRun || plannedCount === 0}
|
||||
onClick={() => runBackfill(false)}
|
||||
>
|
||||
{t('adminSettings.backfillApplyAction', { count: plannedCount })}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('adminSettings.backfillPreviewFirstHint')}</p>
|
||||
|
||||
{backfill ? (
|
||||
<div className="mt-6 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('adminSettings.backfillSummary', {
|
||||
scanned: backfill.scannedAccounts,
|
||||
already: backfill.alreadyProvisioned,
|
||||
acted: backfill.entries.length,
|
||||
})}
|
||||
</p>
|
||||
{backfill.truncated ? (
|
||||
<p className="text-sm font-medium text-foreground">{t('adminSettings.backfillTruncated')}</p>
|
||||
) : null}
|
||||
{backfill.entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('adminSettings.backfillNothingToDo')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('adminSettings.backfillColumnUser')}</TableHead>
|
||||
<TableHead>{t('adminSettings.backfillColumnSlug')}</TableHead>
|
||||
<TableHead>{t('adminSettings.backfillColumnOutcome')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{backfill.entries.map((entry) => (
|
||||
<TableRow key={entry.userId}>
|
||||
<TableCell>{entry.displayName || entry.userId}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{entry.slug ?? '—'}</TableCell>
|
||||
<TableCell>{t(`adminSettings.backfillOutcome.${entry.outcome}`)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -195,6 +195,11 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
{t('user.menu.namespacesAdmin')}
|
||||
</Link>
|
||||
) : null}
|
||||
{isSuperAdmin ? (
|
||||
<Link to="/admin/settings" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.platformSettings')}
|
||||
</Link>
|
||||
) : null}
|
||||
{isAuditor ? (
|
||||
<Link to="/admin/audit-log" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.auditLog')}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue