mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
feat(namespace): auto-provision a personal namespace on registration
Self-hosted deployments want every new account to have somewhere of its
own to publish, without asking an administrator for a namespace first and
without pushing drafts into `global`.
Add an operator-controlled policy, off by default so upgrading changes no
behaviour. When enabled, an account that becomes usable gets a namespace
it owns. "Private" here means a team namespace whose only member is that
account: namespaces have no visibility flag, and skill visibility stays a
property of each skill.
Trigger points. UserActivatedEvent is published wherever an account first
becomes usable:
- LocalAuthService.register
- IdentityBindingService.bindOrCreate, for ACTIVE first logins
- AdminUserAppService.updateUserStatus, on a transition into ACTIVE
The third matters for deployments that gate access behind approval: those
accounts are created PENDING at the first OAuth attempt and only become
usable when an administrator approves them.
Why an AFTER_COMMIT listener rather than a call alongside
GlobalNamespaceMembershipService.ensureMember. Both namespace.created_by
and namespace_member.user_id reference user_account(id), which rules out
each obvious alternative:
- Joining the registration transaction lets a slug clash roll the
registration back, so a namespace failure costs the user their account
— or, on OAuth, their login.
- Suspending it with REQUIRES_NEW leaves the new transaction unable to see
the uncommitted user_account row, so the foreign key check blocks on the
outer transaction's row lock and the two wait on each other.
Provisioning after commit avoids both. The listener is deliberately not
@Async, so the namespace exists by the time the user's next request
arrives, and it swallows failures.
Naming. Two templates over ${username}, ${email_prefix} and ${user_id};
unknown placeholders are left in place so a typo is visible rather than
silently dropped. ${username} falls back to the email local part and then
to the user id. Slugs go through the existing slug rules, which is why the
console renders a live preview: underscores are not legal in a slug, so
`${username}_space` yields `alice-space`. A taken or reserved candidate
gets a numeric suffix, so `admin` becomes `admin-2`. Owning any non-global
namespace already skips provisioning, which keeps re-enabling an account
from handing out a second one.
The templates are not exposed in application.yml: they contain ${...},
which Spring would resolve as property references, and Boot 3.2 predates
placeholder escaping. Only the enable flag lives there; templates are set
in the console and default from PersonalNamespaceProvisioningProperties.
Updating the policy writes an audit entry with the before and after.
This commit is contained in:
parent
68f120c5e1
commit
ae23d1a051
34 changed files with 1662 additions and 11 deletions
|
|
@ -330,6 +330,15 @@ 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` | 更新该策略(写审计日志) |
|
||||
|
||||
详见 [`2026-08-13-personal-namespace-provisioning.md`](./2026-08-13-personal-namespace-provisioning.md)。
|
||||
|
||||
## 7.7 Namespace 管理 API(需命名空间 OWNER 或 ADMIN)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
|
|
|||
132
docs/2026-08-13-personal-namespace-provisioning.md
Normal file
132
docs/2026-08-13-personal-namespace-provisioning.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# 注册时自动创建个人命名空间
|
||||
|
||||
## 背景
|
||||
|
||||
自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能,
|
||||
而不必先向管理员申请命名空间、也不必把半成品塞进 `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`,靠这条保证不会重复发一个命名空间。
|
||||
|
||||
## 三、配置
|
||||
|
||||
| 位置 | 项 | 默认 |
|
||||
|------|-----|------|
|
||||
| `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,51 @@
|
|||
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.PersonalNamespaceSettingsResponse;
|
||||
import com.iflytek.skillhub.dto.PersonalNamespaceSettingsUpdateRequest;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
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.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;
|
||||
|
||||
public AdminSystemSettingController(PersonalNamespaceSettingsAppService personalNamespaceSettingsAppService,
|
||||
ApiResponseFactory responseFactory) {
|
||||
super(responseFactory);
|
||||
this.personalNamespaceSettingsAppService = personalNamespaceSettingsAppService;
|
||||
}
|
||||
|
||||
@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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,108 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings;
|
||||
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 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -116,6 +116,15 @@ 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}
|
||||
public:
|
||||
base-url: ${SKILLHUB_PUBLIC_BASE_URL:}
|
||||
access-policy:
|
||||
|
|
|
|||
|
|
@ -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,120 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceProvisioningService;
|
||||
import com.iflytek.skillhub.domain.namespace.PersonalNamespaceSettings;
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@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 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.event.UserActivatedEvent;
|
||||
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;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.util.UUID;
|
||||
|
|
@ -27,15 +29,18 @@ public class IdentityBindingService {
|
|||
private final UserAccountRepository userRepo;
|
||||
private final UserRoleBindingRepository roleBindingRepo;
|
||||
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public IdentityBindingService(IdentityBindingRepository bindingRepo,
|
||||
UserAccountRepository userRepo,
|
||||
UserRoleBindingRepository roleBindingRepo,
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService) {
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.bindingRepo = bindingRepo;
|
||||
this.userRepo = userRepo;
|
||||
this.roleBindingRepo = roleBindingRepo;
|
||||
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -65,6 +70,8 @@ public class IdentityBindingService {
|
|||
user = userRepo.save(user);
|
||||
if (initialStatus == UserStatus.ACTIVE) {
|
||||
globalNamespaceMembershipService.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,6 +4,7 @@ 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.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
|
|
@ -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;
|
||||
|
|
@ -44,6 +46,7 @@ public class LocalAuthService {
|
|||
private final PasswordPolicyValidator passwordPolicyValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final Clock clock;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public LocalAuthService(LocalCredentialRepository credentialRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
|
|
@ -51,7 +54,8 @@ public class LocalAuthService {
|
|||
GlobalNamespaceMembershipService globalNamespaceMembershipService,
|
||||
PasswordPolicyValidator passwordPolicyValidator,
|
||||
PasswordEncoder passwordEncoder,
|
||||
Clock clock) {
|
||||
Clock clock,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.credentialRepository = credentialRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
|
|
@ -59,6 +63,7 @@ public class LocalAuthService {
|
|||
this.passwordPolicyValidator = passwordPolicyValidator;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.clock = clock;
|
||||
this.eventPublisher = eventPublisher;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -100,6 +105,7 @@ public class LocalAuthService {
|
|||
passwordEncoder.encode(password)
|
||||
));
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
eventPublisher.publishEvent(new UserActivatedEvent(user.getId(), normalizedUsername, normalizedEmail));
|
||||
|
||||
return buildPrincipal(user);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ 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.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
|
|
@ -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)
|
||||
|
|
@ -46,11 +48,15 @@ class IdentityBindingServiceTest {
|
|||
@Mock
|
||||
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private IdentityBindingService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService);
|
||||
service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo,
|
||||
globalNamespaceMembershipService, eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -77,6 +83,44 @@ class IdentityBindingServiceTest {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ 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.event.UserActivatedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -51,6 +53,9 @@ class LocalAuthServiceTest {
|
|||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
private LocalAuthService service;
|
||||
|
||||
@BeforeEach
|
||||
|
|
@ -62,7 +67,8 @@ class LocalAuthServiceTest {
|
|||
globalNamespaceMembershipService,
|
||||
new PasswordPolicyValidator(),
|
||||
passwordEncoder,
|
||||
CLOCK
|
||||
CLOCK,
|
||||
eventPublisher
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +92,22 @@ class LocalAuthServiceTest {
|
|||
verify(globalNamespaceMembershipService).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
|
||||
void login_withValidPassword_resetsCounters() {
|
||||
LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded");
|
||||
|
|
|
|||
|
|
@ -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,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,122 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import com.iflytek.skillhub.domain.setting.SystemSettingService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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;
|
||||
|
||||
public PersonalNamespaceProvisioningService(SystemSettingService systemSettingService,
|
||||
PersonalNamespaceProvisioningProperties defaults,
|
||||
NamespaceService namespaceService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository) {
|
||||
this.systemSettingService = systemSettingService;
|
||||
this.defaults = defaults;
|
||||
this.namespaceService = namespaceService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (alreadyOwnsNamespace(owner.userId())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String slug = allocateSlug(settings.slugTemplate(), owner);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private String allocateSlug(String slugTemplate, PersonalNamespaceOwner owner) {
|
||||
String base = PersonalNamespaceNaming.slugBase(slugTemplate, owner);
|
||||
for (int attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) {
|
||||
String candidate = attempt == 1 ? base : base + "-" + attempt;
|
||||
if (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,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,173 @@
|
|||
package com.iflytek.skillhub.domain.namespace;
|
||||
|
||||
import com.iflytek.skillhub.domain.setting.SystemSettingService;
|
||||
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 java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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;
|
||||
|
||||
private PersonalNamespaceProvisioningService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PersonalNamespaceProvisioningService(
|
||||
systemSettingService,
|
||||
new PersonalNamespaceProvisioningProperties(),
|
||||
namespaceService,
|
||||
namespaceRepository,
|
||||
namespaceMemberRepository);
|
||||
}
|
||||
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -47,6 +47,8 @@ import type {
|
|||
LabelDefinition,
|
||||
LabelItem,
|
||||
BatchMemberResponse,
|
||||
PersonalNamespaceSettings,
|
||||
PersonalNamespaceSettingsInput,
|
||||
} from './types'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import i18n from '@/i18n/config'
|
||||
|
|
@ -1443,6 +1445,24 @@ 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(),
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const notificationApi = {
|
||||
|
|
|
|||
80
web/src/api/generated/schema.d.ts
vendored
80
web/src/api/generated/schema.d.ts
vendored
|
|
@ -372,6 +372,22 @@ 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/namespaces/{slug}/members/{userId}/role": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -3710,6 +3726,26 @@ 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[];
|
||||
};
|
||||
AdminLabelUpdateRequest: {
|
||||
/** @enum {string} */
|
||||
type: "RECOMMENDED" | "PRIVILEGED";
|
||||
|
|
@ -6392,6 +6428,50 @@ 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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
updateMemberRole_2: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -583,3 +583,16 @@ 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
24
web/src/features/admin/use-personal-namespace-settings.ts
Normal file
24
web/src/features/admin/use-personal-namespace-settings.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { adminApi } from '@/api/client'
|
||||
import type { 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1338,7 +1338,8 @@
|
|||
"notifications": "Notification Settings",
|
||||
"accounts": "Account Merge",
|
||||
"logout": "Logout",
|
||||
"namespacesAdmin": "Namespace management"
|
||||
"namespacesAdmin": "Namespace management",
|
||||
"platformSettings": "Platform settings"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
|
|
@ -1644,5 +1645,29 @@
|
|||
"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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1338,7 +1338,8 @@
|
|||
"notifications": "通知设置",
|
||||
"accounts": "账号合并",
|
||||
"logout": "退出登录",
|
||||
"namespacesAdmin": "命名空间管理"
|
||||
"namespacesAdmin": "命名空间管理",
|
||||
"platformSettings": "平台设置"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
|
|
@ -1644,5 +1645,29 @@
|
|||
"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": "请稍后重试。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
web/src/pages/admin/settings.test.tsx
Normal file
80
web/src/pages/admin/settings.test.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const usePersonalNamespaceSettingsMock = 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 }),
|
||||
}))
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the personal namespace section', () => {
|
||||
const html = renderToStaticMarkup(<AdminSettingsPage />)
|
||||
|
||||
expect(html).toContain('adminSettings.personalNamespaceTitle')
|
||||
expect(html).toContain('adminSettings.slugTemplateLabel')
|
||||
})
|
||||
|
||||
it('shows a loading state while the settings are fetched', () => {
|
||||
usePersonalNamespaceSettingsMock.mockReturnValue({ data: undefined, isLoading: true })
|
||||
|
||||
const html = renderToStaticMarkup(<AdminSettingsPage />)
|
||||
|
||||
expect(html).toContain('adminSettings.loading')
|
||||
})
|
||||
})
|
||||
169
web/src/pages/admin/settings.tsx
Normal file
169
web/src/pages/admin/settings.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
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 type { PersonalNamespaceSettingsInput } from '@/api/types'
|
||||
import {
|
||||
usePersonalNamespaceSettings,
|
||||
useUpdatePersonalNamespaceSettings,
|
||||
} from '@/features/admin/use-personal-namespace-settings'
|
||||
|
||||
/**
|
||||
* 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 [form, setForm] = useState<PersonalNamespaceSettingsInput>({
|
||||
enabled: false,
|
||||
slugTemplate: '${username}',
|
||||
displayNameTemplate: '${username}',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setForm({
|
||||
enabled: settings.enabled,
|
||||
slugTemplate: settings.slugTemplate,
|
||||
displayNameTemplate: settings.displayNameTemplate,
|
||||
})
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
const slugPreview = previewSlug(form.slugTemplate)
|
||||
const displayNamePreview = renderTemplate(form.displayNameTemplate).trim()
|
||||
const placeholders = settings?.supportedPlaceholders ?? Object.keys(PREVIEW_OWNER)
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
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 ? (
|
||||
<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) =>
|
||||
setForm((current) => ({ ...current, enabled: value === 'enabled' }))
|
||||
}
|
||||
>
|
||||
<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, slugTemplate: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<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, displayNameTemplate: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
</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