mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-10 03:27:54 +00:00
Revert "feat(namespace): let operators choose which namespaces new accounts join"
This reverts commit a9e7f43e5a.
This commit is contained in:
parent
0221c17113
commit
eba2762b5b
34 changed files with 145 additions and 1161 deletions
|
|
@ -337,9 +337,6 @@ Admin API 按最小权限拆分,不再统一要求 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)。
|
||||
|
||||
|
|
|
|||
|
|
@ -128,27 +128,6 @@ slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、
|
|||
最初的实现里前两条是静默返回的,结果就是「什么都没发生,也查不出为什么」。
|
||||
账号激活本身是低频事件,多两行日志的代价可以忽略。
|
||||
|
||||
## 二·五、全员默认加入的命名空间
|
||||
|
||||
「自动建一个自己的命名空间」解决的是个人空间;另一个相邻问题是**组织级公共空间**。
|
||||
|
||||
部署方新建一个命名空间来代替内置的 `global` 时会发现它对所有人不可见——
|
||||
`listNamespaces` 只返回调用者是成员的命名空间,而「新账号自动入伙」这件事
|
||||
原本写死在 `GlobalNamespaceMembershipService` 里,只认 slug `global`。
|
||||
|
||||
所以把它一般化为 `DefaultNamespaceMembershipService`:
|
||||
|
||||
- 设置项 `namespace.default-membership` 存一个 slug 列表,默认 `["global"]`,
|
||||
即改造前的行为
|
||||
- 保存时校验每个 slug 存在且为 ACTIVE,让拼错在保存那一刻就暴露,
|
||||
而不是变成某个人首次登录时的一条警告
|
||||
- 运行期遇到已被删除或改名的 slug 只记 WARN 并跳过——
|
||||
一个不存在的命名空间不该让人登不上来
|
||||
- 同样配了预览 + 执行的补建,把存量账号一次性加进去
|
||||
|
||||
发布只要求「是该命名空间的成员」(任意角色),所以加入即可发布,
|
||||
不需要额外授予角色。
|
||||
|
||||
## 三、配置
|
||||
|
||||
| 位置 | 项 | 默认 |
|
||||
|
|
|
|||
|
|
@ -4,15 +4,11 @@ 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.PersonalNamespaceBackfillRequest;
|
||||
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;
|
||||
|
|
@ -33,14 +29,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
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")
|
||||
|
|
@ -66,41 +59,10 @@ public class AdminSystemSettingController extends BaseApiController {
|
|||
@PostMapping("/personal-namespace/backfill")
|
||||
@PreAuthorize("hasRole('SUPER_ADMIN')")
|
||||
public ApiResponse<PersonalNamespaceBackfillResponse> backfillPersonalNamespaces(
|
||||
@Valid @RequestBody BackfillRequest request,
|
||||
@Valid @RequestBody PersonalNamespaceBackfillRequest 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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
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) {}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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) {}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
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) {}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @param dryRun when true, report the accounts that would get a namespace without creating any
|
||||
*/
|
||||
public record PersonalNamespaceBackfillRequest(@NotNull Boolean dryRun) {}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ 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.PersonalNamespaceBackfillRequest;
|
||||
import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse;
|
||||
import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse;
|
||||
import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest;
|
||||
|
|
@ -73,7 +73,7 @@ public class PersonalNamespaceSettingsAppService {
|
|||
* 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,
|
||||
public PersonalNamespaceBackfillResponse backfill(PersonalNamespaceBackfillRequest request,
|
||||
String actorUserId,
|
||||
AuditRequestContext auditContext) {
|
||||
boolean dryRun = Boolean.TRUE.equals(request.dryRun());
|
||||
|
|
|
|||
|
|
@ -125,11 +125,6 @@ skillhub:
|
|||
# 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:
|
||||
|
|
|
|||
|
|
@ -76,8 +76,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -76,8 +76,6 @@ 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 角色
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ 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.PersonalNamespaceBackfillRequest;
|
||||
import com.iflytek.skillhub.dto.PersonalNamespaceBackfillResponse;
|
||||
import com.iflytek.skillhub.dto.PersonalNamespaceSettingsResponse;
|
||||
import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest;
|
||||
|
|
@ -120,7 +120,7 @@ class PersonalNamespaceSettingsAppServiceTest {
|
|||
PersonalNamespaceBackfillEntry.Outcome.PLANNED))));
|
||||
|
||||
PersonalNamespaceBackfillResponse response = service.backfill(
|
||||
new BackfillRequest(true), "usr_admin", null);
|
||||
new PersonalNamespaceBackfillRequest(true), "usr_admin", null);
|
||||
|
||||
assertThat(response.dryRun()).isTrue();
|
||||
assertThat(response.entries()).singleElement()
|
||||
|
|
@ -137,7 +137,7 @@ class PersonalNamespaceSettingsAppServiceTest {
|
|||
new PersonalNamespaceBackfillEntry("usr_2", "admin", null,
|
||||
PersonalNamespaceBackfillEntry.Outcome.NO_SLUG))));
|
||||
|
||||
service.backfill(new BackfillRequest(false), "usr_admin",
|
||||
service.backfill(new PersonalNamespaceBackfillRequest(false), "usr_admin",
|
||||
new AuditRequestContext("10.0.0.1", "curl/8"));
|
||||
|
||||
ArgumentCaptor<String> detailCaptor = ArgumentCaptor.forClass(String.class);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
|
|
@ -28,18 +28,18 @@ public class IdentityBindingService {
|
|||
private final IdentityBindingRepository bindingRepo;
|
||||
private final UserAccountRepository userRepo;
|
||||
private final UserRoleBindingRepository roleBindingRepo;
|
||||
private final DefaultNamespaceMembershipService defaultNamespaceMembershipService;
|
||||
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public IdentityBindingService(IdentityBindingRepository bindingRepo,
|
||||
UserAccountRepository userRepo,
|
||||
UserRoleBindingRepository roleBindingRepo,
|
||||
DefaultNamespaceMembershipService defaultNamespaceMembershipService,
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.bindingRepo = bindingRepo;
|
||||
this.userRepo = userRepo;
|
||||
this.roleBindingRepo = roleBindingRepo;
|
||||
this.defaultNamespaceMembershipService = defaultNamespaceMembershipService;
|
||||
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ public class IdentityBindingService {
|
|||
user.setStatus(initialStatus);
|
||||
user = userRepo.save(user);
|
||||
if (initialStatus == UserStatus.ACTIVE) {
|
||||
defaultNamespaceMembershipService.ensureMember(user.getId());
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
eventPublisher.publishEvent(
|
||||
new UserActivatedEvent(user.getId(), claims.providerLogin(), claims.email()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ 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.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
|
|
@ -42,7 +42,7 @@ public class LocalAuthService {
|
|||
private final LocalCredentialRepository credentialRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
private final DefaultNamespaceMembershipService defaultNamespaceMembershipService;
|
||||
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
private final PasswordPolicyValidator passwordPolicyValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final Clock clock;
|
||||
|
|
@ -51,7 +51,7 @@ public class LocalAuthService {
|
|||
public LocalAuthService(LocalCredentialRepository credentialRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
DefaultNamespaceMembershipService defaultNamespaceMembershipService,
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService,
|
||||
PasswordPolicyValidator passwordPolicyValidator,
|
||||
PasswordEncoder passwordEncoder,
|
||||
Clock clock,
|
||||
|
|
@ -59,7 +59,7 @@ public class LocalAuthService {
|
|||
this.credentialRepository = credentialRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
this.defaultNamespaceMembershipService = defaultNamespaceMembershipService;
|
||||
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
|
||||
this.passwordPolicyValidator = passwordPolicyValidator;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.clock = clock;
|
||||
|
|
@ -104,7 +104,7 @@ public class LocalAuthService {
|
|||
normalizedUsername,
|
||||
passwordEncoder.encode(password)
|
||||
));
|
||||
defaultNamespaceMembershipService.ensureMember(user.getId());
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
eventPublisher.publishEvent(new UserActivatedEvent(user.getId(), normalizedUsername, normalizedEmail));
|
||||
|
||||
return buildPrincipal(user);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ 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.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
|
|
@ -46,7 +46,7 @@ class IdentityBindingServiceTest {
|
|||
private UserRoleBindingRepository roleBindingRepo;
|
||||
|
||||
@Mock
|
||||
private DefaultNamespaceMembershipService defaultNamespaceMembershipService;
|
||||
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
|
@ -56,7 +56,7 @@ class IdentityBindingServiceTest {
|
|||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo,
|
||||
defaultNamespaceMembershipService, eventPublisher);
|
||||
globalNamespaceMembershipService, eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -77,7 +77,7 @@ class IdentityBindingServiceTest {
|
|||
|
||||
ArgumentCaptor<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
|
||||
verify(userRepo).save(userCaptor.capture());
|
||||
verify(defaultNamespaceMembershipService).ensureMember(userCaptor.getValue().getId());
|
||||
verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId());
|
||||
verify(bindingRepo).save(any(IdentityBinding.class));
|
||||
assertThat(principal.displayName()).isEqualTo("alice");
|
||||
assertThat(principal.oauthProvider()).isEqualTo("github");
|
||||
|
|
@ -137,7 +137,7 @@ class IdentityBindingServiceTest {
|
|||
assertThatThrownBy(() -> service.bindOrCreate(claims, UserStatus.PENDING))
|
||||
.isInstanceOf(AccountPendingException.class);
|
||||
|
||||
verify(defaultNamespaceMembershipService, never()).ensureMember(any());
|
||||
verify(globalNamespaceMembershipService, never()).ensureMember(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ 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.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.DefaultNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
|
|
@ -48,7 +48,7 @@ class LocalAuthServiceTest {
|
|||
private UserRoleBindingRepository userRoleBindingRepository;
|
||||
|
||||
@Mock
|
||||
private DefaultNamespaceMembershipService defaultNamespaceMembershipService;
|
||||
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
|
@ -64,7 +64,7 @@ class LocalAuthServiceTest {
|
|||
credentialRepository,
|
||||
userAccountRepository,
|
||||
userRoleBindingRepository,
|
||||
defaultNamespaceMembershipService,
|
||||
globalNamespaceMembershipService,
|
||||
new PasswordPolicyValidator(),
|
||||
passwordEncoder,
|
||||
CLOCK,
|
||||
|
|
@ -89,7 +89,7 @@ class LocalAuthServiceTest {
|
|||
assertThat(principal.email()).isEqualTo("alice@example.com");
|
||||
assertThat(principal.platformRoles()).containsExactly("USER");
|
||||
verify(credentialRepository).save(any(LocalCredential.class));
|
||||
verify(defaultNamespaceMembershipService).ensureMember(userCaptor.getValue().getId());
|
||||
verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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) {
|
||||
}
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -50,8 +50,6 @@ import type {
|
|||
PersonalNamespaceSettings,
|
||||
PersonalNamespaceSettingsInput,
|
||||
PersonalNamespaceBackfillResult,
|
||||
DefaultNamespaceSettings,
|
||||
DefaultNamespaceBackfillResult,
|
||||
} from './types'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import i18n from '@/i18n/config'
|
||||
|
|
@ -1477,29 +1475,6 @@ export const adminApi = {
|
|||
},
|
||||
)
|
||||
},
|
||||
|
||||
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 = {
|
||||
|
|
|
|||
139
web/src/api/generated/schema.d.ts
vendored
139
web/src/api/generated/schema.d.ts
vendored
|
|
@ -388,22 +388,6 @@ export interface paths {
|
|||
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;
|
||||
|
|
@ -1620,22 +1604,6 @@ export interface paths {
|
|||
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;
|
||||
|
|
@ -3794,21 +3762,6 @@ export interface components {
|
|||
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";
|
||||
|
|
@ -4232,7 +4185,7 @@ export interface components {
|
|||
comment?: string;
|
||||
disposition?: string;
|
||||
};
|
||||
BackfillRequest: {
|
||||
PersonalNamespaceBackfillRequest: {
|
||||
dryRun: boolean;
|
||||
};
|
||||
ApiResponsePersonalNamespaceBackfillResponse: {
|
||||
|
|
@ -4261,26 +4214,6 @@ export interface components {
|
|||
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;
|
||||
};
|
||||
|
|
@ -6584,50 +6517,6 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
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;
|
||||
|
|
@ -8791,7 +8680,7 @@ export interface operations {
|
|||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["BackfillRequest"];
|
||||
"application/json": components["schemas"]["PersonalNamespaceBackfillRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
|
|
@ -8806,30 +8695,6 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -612,22 +612,3 @@ export interface PersonalNamespaceBackfillResult {
|
|||
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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
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'] })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1687,17 +1687,6 @@
|
|||
},
|
||||
"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": "Namespace slugs",
|
||||
"defaultsHint": "Comma separated. Each must be an existing, active namespace.",
|
||||
"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"
|
||||
"backfillErrorTitle": "Backfill failed"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1687,17 +1687,6 @@
|
|||
},
|
||||
"backfillDoneTitle": "补建完成",
|
||||
"backfillDoneDescription": "已创建 {{count}} 个命名空间。",
|
||||
"backfillErrorTitle": "补建失败",
|
||||
"defaultsTitle": "全员默认加入的命名空间",
|
||||
"defaultsDescription": "没有成员的命名空间是看不见的——命名空间列表只显示你是成员的那些。在这里列出每个新激活的账号都应该加入的命名空间。",
|
||||
"defaultsLabel": "命名空间标识",
|
||||
"defaultsHint": "用逗号分隔,每个都必须是已存在且启用的命名空间。",
|
||||
"defaultsSaveDescription": "此后激活的账号会自动加入这些命名空间。",
|
||||
"defaultsBackfillHint": "请先预览——未预览前加入按钮不可用。",
|
||||
"defaultsBackfillApplyAction": "加入 {{count}} 个账号",
|
||||
"defaultsBackfillSummary": "扫描 {{scanned}} 个账号 · {{already}} 个已加入 · {{acted}} 个待加入",
|
||||
"defaultsBackfillNothingToDo": "所有账号都已经加入了。",
|
||||
"defaultsBackfillDoneDescription": "已为 {{count}} 个账号加入。",
|
||||
"defaultsColumnSlugs": "将加入"
|
||||
"backfillErrorTitle": "补建失败"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ 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()
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
|
|
@ -30,12 +29,6 @@ vi.mock('@/features/admin/use-personal-namespace-settings', () => ({
|
|||
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 }),
|
||||
}))
|
||||
|
||||
import { AdminSettingsPage, previewSlug, renderTemplate } from './settings'
|
||||
|
||||
describe('previewSlug', () => {
|
||||
|
|
@ -71,10 +64,6 @@ describe('AdminSettingsPage', () => {
|
|||
},
|
||||
isLoading: false,
|
||||
})
|
||||
useDefaultNamespacesMock.mockReturnValue({
|
||||
data: { slugs: ['global', 'musee'] },
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -121,23 +110,13 @@ describe('AdminSettingsPage', () => {
|
|||
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)
|
||||
expect(screen.getByRole('button', { name: 'adminSettings.backfillPreviewAction' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the apply button disabled until a preview has been run', async () => {
|
||||
render(<AdminSettingsPage />)
|
||||
|
||||
const apply = await screen.findByRole('button', { name: /adminSettings.backfillApplyAction/ })
|
||||
const apply = await screen.findByRole('button', { name: /backfillApplyAction/ })
|
||||
expect((apply as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the configured default namespaces the server returned', async () => {
|
||||
render(<AdminSettingsPage />)
|
||||
|
||||
const input = (await screen.findByLabelText('adminSettings.defaultsLabel')) as HTMLInputElement
|
||||
await waitFor(() => {
|
||||
expect(input.value).toBe('global, musee')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,21 +14,12 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/shared/ui/table'
|
||||
import type {
|
||||
DefaultNamespaceBackfillResult,
|
||||
PersonalNamespaceBackfillResult,
|
||||
PersonalNamespaceSettingsInput,
|
||||
} from '@/api/types'
|
||||
import type { 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'
|
||||
|
||||
/**
|
||||
* Sample account used for the live template preview.
|
||||
|
|
@ -64,20 +55,6 @@ export function AdminSettingsPage() {
|
|||
const backfillMutation = useBackfillPersonalNamespaces()
|
||||
const [backfill, setBackfill] = useState<PersonalNamespaceBackfillResult | null>(null)
|
||||
|
||||
const { data: defaults, isLoading: defaultsLoading } = useDefaultNamespaces()
|
||||
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.join(', '))
|
||||
}, [defaults])
|
||||
|
||||
// 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
|
||||
|
|
@ -125,48 +102,6 @@ export function AdminSettingsPage() {
|
|||
? backfill.entries.filter((entry) => entry.outcome === 'PLANNED').length
|
||||
: 0
|
||||
|
||||
const saveDefaults = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (defaultSlugs === null) {
|
||||
return
|
||||
}
|
||||
const slugs = defaultSlugs
|
||||
.split(',')
|
||||
.map((slug) => slug.trim())
|
||||
.filter((slug) => slug.length > 0)
|
||||
try {
|
||||
const saved = await updateDefaultsMutation.mutateAsync(slugs)
|
||||
setDefaultSlugs(saved.slugs.join(', '))
|
||||
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()
|
||||
|
||||
|
|
@ -281,101 +216,6 @@ export function AdminSettingsPage() {
|
|||
)}
|
||||
</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 || 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">
|
||||
<Label htmlFor="default-namespaces">{t('adminSettings.defaultsLabel')}</Label>
|
||||
<Input
|
||||
id="default-namespaces"
|
||||
value={defaultSlugs}
|
||||
placeholder="global, musee"
|
||||
onChange={(event) => setDefaultSlugs(event.target.value)}
|
||||
/>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue