mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-07 02:57:51 +00:00
refactor(namespace): keep personal provisioning configuration-only
This commit is contained in:
parent
7beb1be356
commit
ee0f0763db
30 changed files with 42 additions and 1165 deletions
|
|
@ -330,15 +330,6 @@ 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)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
|
|
|||
|
|
@ -5,40 +5,7 @@
|
|||
自建部署里常见的诉求:每个新账号都应该有一块属于自己的地盘,可以直接发布技能,
|
||||
而不必先向管理员申请命名空间、也不必把半成品塞进 `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)`,
|
||||
滚动升级时旧节点读到新节点写入的文档不会报错。
|
||||
|
||||
## 二、自动创建个人命名空间
|
||||
## 一、自动创建个人命名空间
|
||||
|
||||
### 「私有」在当前模型里的含义
|
||||
|
||||
|
|
@ -96,8 +63,6 @@ namespace_member.user_id REFERENCES user_account(id)
|
|||
slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、
|
||||
字母数字以外的字符变连字符、去掉首尾与重复连字符。
|
||||
**注意下划线不合法**——`${username}_space` 会得到 `alice-space`。
|
||||
控制台有实时预览,就是为了让这条规则在保存前可见。
|
||||
|
||||
冲突处理:候选 slug 若非法(保留字如 `admin`、长度不足)或已被占用,
|
||||
依次尝试 `-2`、`-3`……最多 64 次;全部失败则跳过并记 WARN。
|
||||
`admin` 这类保留字因此自然落到 `admin-2`。
|
||||
|
|
@ -105,28 +70,16 @@ slug 模板渲染后按 `SlugValidator` 的规则归一化:转小写、
|
|||
幂等:用户若已经拥有任意非 GLOBAL 命名空间,直接跳过。
|
||||
解封会再次发布 `UserActivatedEvent`,靠这条保证不会重复发一个命名空间。
|
||||
|
||||
## 三、配置
|
||||
## 二、配置
|
||||
|
||||
| 位置 | 项 | 默认 |
|
||||
|------|-----|------|
|
||||
| `application.yml` | `skillhub.namespace.personal-provisioning.enabled` | `false` |
|
||||
| 控制台 | 启用开关、slug 模板、显示名模板 | `${username}` |
|
||||
| 配置文件/环境变量 | 启用开关 | `false` |
|
||||
|
||||
**默认关闭**:升级不应该让现有部署突然开始建命名空间。
|
||||
|
||||
模板刻意**不放在 `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`。
|
||||
模板默认值固定为 `personal-${random}` 和 `${username}-个人空间`,
|
||||
如需关闭可设置 `SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED=false`。
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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
|
||||
) {}
|
||||
|
|
@ -1,108 +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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,12 +117,8 @@ skillhub:
|
|||
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.
|
||||
# Whether a newly activated account gets a namespace of its own.
|
||||
# Slug and display-name templates use safe code defaults in PersonalNamespaceProvisioningProperties.
|
||||
personal-provisioning:
|
||||
enabled: ${SKILLHUB_NAMESPACE_PERSONAL_PROVISIONING_ENABLED:false}
|
||||
public:
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
-- V44__system_setting.sql
|
||||
--
|
||||
-- Operator-configurable platform settings.
|
||||
--
|
||||
-- Each row holds one setting group as a JSON document so a group can gain
|
||||
-- fields without a schema migration. A row is written only when an operator
|
||||
-- overrides a group; an absent row means "use the configured defaults", which
|
||||
-- keeps configuration-file-only deployments working unchanged.
|
||||
|
||||
CREATE TABLE system_setting (
|
||||
setting_key VARCHAR(128) PRIMARY KEY,
|
||||
setting_value JSONB NOT NULL,
|
||||
updated_by VARCHAR(128) REFERENCES user_account(id),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
@ -54,6 +54,7 @@ error.forbidden=Forbidden
|
|||
error.apiToken.scope.missing=API token is missing required scope: {0}
|
||||
error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0}
|
||||
error.request.timeout=Request timed out
|
||||
error.request.include.unsupported=Unsupported include option: {0}
|
||||
error.rateLimit.exceeded=Rate limit exceeded
|
||||
error.storage.unavailable=Object storage is temporarily unavailable. Please try again later.
|
||||
error.internal=An unexpected error occurred
|
||||
|
|
@ -184,3 +185,4 @@ promotion.status.invalid=Unsupported promotion status: {0}
|
|||
promotion.sort.field.invalid=Unsupported promotion sort field: {0}
|
||||
promotion.sort.direction.invalid=Unsupported promotion sort direction: {0}
|
||||
promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting
|
||||
error.skill.subscription.noPermission=You do not have permission to subscribe to this skill.
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ error.forbidden=没有权限执行该操作
|
|||
error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0}
|
||||
error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0}
|
||||
error.request.timeout=请求超时
|
||||
error.request.include.unsupported=不支持的 include 参数:{0}
|
||||
error.rateLimit.exceeded=请求过于频繁,请稍后再试
|
||||
error.storage.unavailable=对象存储暂时不可用,请稍后再试
|
||||
error.internal=服务器内部错误
|
||||
|
|
@ -184,3 +185,4 @@ promotion.status.invalid=不支持的提升审核状态:{0}
|
|||
promotion.sort.field.invalid=不支持的提升审核排序字段:{0}
|
||||
promotion.sort.direction.invalid=不支持的提升审核排序方向:{0}
|
||||
promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序
|
||||
error.skill.subscription.noPermission=您没有订阅此技能的权限。
|
||||
|
|
|
|||
|
|
@ -1,120 +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.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,9 +6,7 @@ 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.
|
||||
* <p>Deployments can disable provisioning with the environment-backed {@code enabled} property.
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.namespace.personal-provisioning")
|
||||
|
|
@ -20,9 +18,8 @@ public class PersonalNamespaceProvisioningProperties {
|
|||
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.
|
||||
* Templates remain code defaults because Spring treats {@code ${...}} in YAML as property
|
||||
* references.
|
||||
*/
|
||||
private String slugTemplate = "personal-${random}";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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;
|
||||
|
|
@ -27,8 +26,6 @@ import java.util.Optional;
|
|||
@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.
|
||||
*/
|
||||
|
|
@ -36,20 +33,17 @@ public class PersonalNamespaceProvisioningService {
|
|||
|
||||
private static final Logger log = LoggerFactory.getLogger(PersonalNamespaceProvisioningService.class);
|
||||
|
||||
private final SystemSettingService systemSettingService;
|
||||
private final PersonalNamespaceProvisioningProperties defaults;
|
||||
private final NamespaceService namespaceService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository;
|
||||
|
||||
public PersonalNamespaceProvisioningService(SystemSettingService systemSettingService,
|
||||
PersonalNamespaceProvisioningProperties defaults,
|
||||
public PersonalNamespaceProvisioningService(PersonalNamespaceProvisioningProperties defaults,
|
||||
NamespaceService namespaceService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository) {
|
||||
this.systemSettingService = systemSettingService;
|
||||
this.defaults = defaults;
|
||||
this.namespaceService = namespaceService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
|
|
@ -61,12 +55,7 @@ public class PersonalNamespaceProvisioningService {
|
|||
* 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);
|
||||
return defaults.toSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
package com.iflytek.skillhub.domain.setting;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One operator-configurable setting group, stored as a JSON document.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "system_setting")
|
||||
public class SystemSetting {
|
||||
|
||||
@Id
|
||||
@Column(name = "setting_key", nullable = false, length = 128)
|
||||
private String settingKey;
|
||||
|
||||
@Column(name = "setting_value", nullable = false)
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
private String settingValue;
|
||||
|
||||
@Column(name = "updated_by", length = 128)
|
||||
private String updatedBy;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
protected SystemSetting() {
|
||||
}
|
||||
|
||||
public SystemSetting(String settingKey, String settingValue, String updatedBy, Instant updatedAt) {
|
||||
this.settingKey = settingKey;
|
||||
this.settingValue = settingValue;
|
||||
this.updatedBy = updatedBy;
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public String getSettingKey() {
|
||||
return settingKey;
|
||||
}
|
||||
|
||||
public String getSettingValue() {
|
||||
return settingValue;
|
||||
}
|
||||
|
||||
public void setSettingValue(String settingValue) {
|
||||
this.settingValue = settingValue;
|
||||
}
|
||||
|
||||
public String getUpdatedBy() {
|
||||
return updatedBy;
|
||||
}
|
||||
|
||||
public void setUpdatedBy(String updatedBy) {
|
||||
this.updatedBy = updatedBy;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package com.iflytek.skillhub.domain.setting;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface SystemSettingRepository {
|
||||
Optional<SystemSetting> findBySettingKey(String settingKey);
|
||||
SystemSetting save(SystemSetting setting);
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package com.iflytek.skillhub.domain.setting;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads and writes operator-configurable setting groups.
|
||||
*
|
||||
* <p>Every read carries the caller's defaults so that a deployment which has never touched a group
|
||||
* — or which configures it entirely through {@code application.yml} — behaves exactly as it did
|
||||
* before the group existed.
|
||||
*/
|
||||
@Service
|
||||
public class SystemSettingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SystemSettingService.class);
|
||||
|
||||
private final SystemSettingRepository systemSettingRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
public SystemSettingService(SystemSettingRepository systemSettingRepository,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock) {
|
||||
this.systemSettingRepository = systemSettingRepository;
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored group, or {@code defaults} when the group has never been overridden.
|
||||
*
|
||||
* <p>A stored document that can no longer be parsed also falls back to {@code defaults}: a
|
||||
* malformed row must not take down the flows that read settings, such as login.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public <T> T get(String settingKey, Class<T> type, T defaults) {
|
||||
Optional<SystemSetting> stored = systemSettingRepository.findBySettingKey(settingKey);
|
||||
if (stored.isEmpty()) {
|
||||
return defaults;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(stored.get().getSettingValue(), type);
|
||||
} catch (Exception e) {
|
||||
log.warn("Falling back to defaults for system setting '{}': stored value is not readable as {}",
|
||||
settingKey, type.getSimpleName(), e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites a setting group and records who changed it.
|
||||
*/
|
||||
@Transactional
|
||||
public <T> T put(String settingKey, T value, String updatedBy) {
|
||||
String json;
|
||||
try {
|
||||
json = objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("System setting '" + settingKey + "' is not serializable", e);
|
||||
}
|
||||
Instant now = Instant.now(clock);
|
||||
SystemSetting setting = systemSettingRepository.findBySettingKey(settingKey)
|
||||
.orElseGet(() -> new SystemSetting(settingKey, json, updatedBy, now));
|
||||
setting.setSettingValue(json);
|
||||
setting.setUpdatedBy(updatedBy);
|
||||
setting.setUpdatedAt(now);
|
||||
systemSettingRepository.save(setting);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
/**
|
||||
* Operator-configurable platform settings.
|
||||
*
|
||||
* <p>Settings are grouped: one {@link com.iflytek.skillhub.domain.setting.SystemSetting} row holds
|
||||
* one group serialized as JSON. Callers read a group through
|
||||
* {@link com.iflytek.skillhub.domain.setting.SystemSettingService} with a typed default, so a group
|
||||
* that has never been overridden resolves to the deployment's configured defaults rather than to
|
||||
* {@code null}.
|
||||
*/
|
||||
package com.iflytek.skillhub.domain.setting;
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
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;
|
||||
|
|
@ -25,9 +24,6 @@ class PersonalNamespaceProvisioningServiceTest {
|
|||
private static final PersonalNamespaceOwner ALICE =
|
||||
new PersonalNamespaceOwner("usr_alice", "alice", "alice@example.com");
|
||||
|
||||
@Mock
|
||||
private SystemSettingService systemSettingService;
|
||||
|
||||
@Mock
|
||||
private NamespaceService namespaceService;
|
||||
|
||||
|
|
@ -41,12 +37,13 @@ class PersonalNamespaceProvisioningServiceTest {
|
|||
private com.iflytek.skillhub.domain.user.UserAccountRepository userAccountRepository;
|
||||
|
||||
private PersonalNamespaceProvisioningService service;
|
||||
private PersonalNamespaceProvisioningProperties properties;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new PersonalNamespaceProvisioningProperties();
|
||||
service = new PersonalNamespaceProvisioningService(
|
||||
systemSettingService,
|
||||
new PersonalNamespaceProvisioningProperties(),
|
||||
properties,
|
||||
namespaceService,
|
||||
namespaceRepository,
|
||||
namespaceMemberRepository,
|
||||
|
|
@ -54,9 +51,9 @@ class PersonalNamespaceProvisioningServiceTest {
|
|||
}
|
||||
|
||||
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));
|
||||
properties.setEnabled(enabled);
|
||||
properties.setSlugTemplate(slugTemplate);
|
||||
properties.setDisplayNameTemplate(displayNameTemplate);
|
||||
}
|
||||
|
||||
private void ownsNothing() {
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
package com.iflytek.skillhub.domain.setting;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SystemSettingServiceTest {
|
||||
|
||||
private static final String KEY = "demo.group";
|
||||
private static final Instant NOW = Instant.parse("2026-01-02T03:04:05Z");
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
record DemoSettings(boolean enabled, String template) {
|
||||
}
|
||||
|
||||
@Mock
|
||||
private SystemSettingRepository systemSettingRepository;
|
||||
|
||||
private SystemSettingService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new SystemSettingService(
|
||||
systemSettingRepository,
|
||||
new ObjectMapper(),
|
||||
Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReturnsDefaultsWhenGroupWasNeverOverridden() {
|
||||
DemoSettings defaults = new DemoSettings(false, "${username}");
|
||||
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.empty());
|
||||
|
||||
assertSame(defaults, service.get(KEY, DemoSettings.class, defaults));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getReturnsStoredGroupWhenPresent() {
|
||||
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(
|
||||
new SystemSetting(KEY, "{\"enabled\":true,\"template\":\"${username}-space\"}", "usr_1", NOW)));
|
||||
|
||||
DemoSettings resolved = service.get(KEY, DemoSettings.class, new DemoSettings(false, "${username}"));
|
||||
|
||||
assertEquals(new DemoSettings(true, "${username}-space"), resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getIgnoresUnknownFieldsSoOlderNodesCanReadNewerDocuments() {
|
||||
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(
|
||||
new SystemSetting(KEY, "{\"enabled\":true,\"template\":\"x\",\"addedLater\":42}", "usr_1", NOW)));
|
||||
|
||||
assertEquals(new DemoSettings(true, "x"),
|
||||
service.get(KEY, DemoSettings.class, new DemoSettings(false, "${username}")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFallsBackToDefaultsWhenStoredDocumentIsMalformed() {
|
||||
DemoSettings defaults = new DemoSettings(false, "${username}");
|
||||
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(
|
||||
new SystemSetting(KEY, "not json", "usr_1", NOW)));
|
||||
|
||||
assertSame(defaults, service.get(KEY, DemoSettings.class, defaults));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putStoresSerializedGroupWithActorAndTimestamp() {
|
||||
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.empty());
|
||||
|
||||
service.put(KEY, new DemoSettings(true, "${username}"), "usr_admin");
|
||||
|
||||
ArgumentCaptor<SystemSetting> captor = ArgumentCaptor.forClass(SystemSetting.class);
|
||||
verify(systemSettingRepository).save(captor.capture());
|
||||
SystemSetting saved = captor.getValue();
|
||||
assertEquals(KEY, saved.getSettingKey());
|
||||
assertEquals("{\"enabled\":true,\"template\":\"${username}\"}", saved.getSettingValue());
|
||||
assertEquals("usr_admin", saved.getUpdatedBy());
|
||||
assertEquals(NOW, saved.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void putOverwritesExistingRowInPlace() {
|
||||
SystemSetting existing = new SystemSetting(KEY, "{\"enabled\":false,\"template\":\"old\"}",
|
||||
"usr_previous", NOW.minusSeconds(60));
|
||||
when(systemSettingRepository.findBySettingKey(KEY)).thenReturn(Optional.of(existing));
|
||||
|
||||
service.put(KEY, new DemoSettings(true, "new"), "usr_admin");
|
||||
|
||||
verify(systemSettingRepository).save(any(SystemSetting.class));
|
||||
assertEquals("{\"enabled\":true,\"template\":\"new\"}", existing.getSettingValue());
|
||||
assertEquals("usr_admin", existing.getUpdatedBy());
|
||||
assertEquals(NOW, existing.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
package com.iflytek.skillhub.infra.jpa;
|
||||
|
||||
import com.iflytek.skillhub.domain.setting.SystemSetting;
|
||||
import com.iflytek.skillhub.domain.setting.SystemSettingRepository;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface SystemSettingJpaRepository extends JpaRepository<SystemSetting, String>, SystemSettingRepository {
|
||||
Optional<SystemSetting> findBySettingKey(String settingKey);
|
||||
}
|
||||
|
|
@ -47,8 +47,6 @@ import type {
|
|||
LabelDefinition,
|
||||
LabelItem,
|
||||
BatchMemberResponse,
|
||||
PersonalNamespaceSettings,
|
||||
PersonalNamespaceSettingsInput,
|
||||
} from './types'
|
||||
import { ApiError } from '@/shared/lib/api-error'
|
||||
import i18n from '@/i18n/config'
|
||||
|
|
@ -1445,24 +1443,6 @@ 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 = {
|
||||
|
|
|
|||
86
web/src/api/generated/schema.d.ts
vendored
86
web/src/api/generated/schema.d.ts
vendored
|
|
@ -372,22 +372,6 @@ 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;
|
||||
|
|
@ -3726,26 +3710,6 @@ 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";
|
||||
|
|
@ -4389,6 +4353,7 @@ export interface components {
|
|||
ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
|
||||
resolutionMode?: string;
|
||||
complianceSnapshot?: components["schemas"]["ComplianceSnapshotResponse"];
|
||||
labels?: components["schemas"]["SkillLabelDto"][];
|
||||
};
|
||||
ApiResponseBoolean: {
|
||||
/** Format: int32 */
|
||||
|
|
@ -5016,6 +4981,7 @@ export interface components {
|
|||
/** Format: int64 */
|
||||
updatedAt?: number;
|
||||
latestVersion?: components["schemas"]["LatestVersion"];
|
||||
labels?: components["schemas"]["SkillLabelDto"][];
|
||||
};
|
||||
ApiResponseListSecurityAuditResponse: {
|
||||
/** Format: int32 */
|
||||
|
|
@ -6428,50 +6394,6 @@ 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;
|
||||
|
|
@ -8082,6 +8004,8 @@ export interface operations {
|
|||
page?: number;
|
||||
limit?: number;
|
||||
sort?: string;
|
||||
/** @description Optional response expansions. Supported value: labels */
|
||||
include?: string[];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -9119,6 +9043,8 @@ export interface operations {
|
|||
q?: string;
|
||||
namespace?: string;
|
||||
label?: string[];
|
||||
/** @description Optional response expansions. Supported value: labels */
|
||||
include?: string[];
|
||||
sort?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@ export interface SkillSummary {
|
|||
ratingCount: number
|
||||
namespace: string
|
||||
updatedAt: string
|
||||
ownerId?: string
|
||||
ownerDisplayName?: string
|
||||
canSubmitPromotion: boolean
|
||||
headlineVersion?: SkillLifecycleVersion
|
||||
publishedVersion?: SkillLifecycleVersion
|
||||
|
|
@ -583,16 +585,3 @@ 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,11 +151,6 @@ const AdminNamespacesPage = createRoleProtectedRouteComponent(
|
|||
'AdminNamespacesPage',
|
||||
['SUPER_ADMIN'],
|
||||
)
|
||||
const AdminSettingsPage = createRoleProtectedRouteComponent(
|
||||
() => import('@/pages/admin/settings'),
|
||||
'AdminSettingsPage',
|
||||
['SUPER_ADMIN'],
|
||||
)
|
||||
|
||||
function DefaultNotFound() {
|
||||
return (
|
||||
|
|
@ -188,8 +183,8 @@ const skillsRoute = createRoute({
|
|||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'login',
|
||||
validateSearch: (search: Record<string, unknown>): { returnTo: string; reason?: string } => ({
|
||||
returnTo: typeof search.returnTo === 'string' ? search.returnTo : '',
|
||||
validateSearch: (search: Record<string, unknown>): { returnTo?: string; reason?: string } => ({
|
||||
returnTo: typeof search.returnTo === 'string' && search.returnTo ? search.returnTo : undefined,
|
||||
reason: typeof search.reason === 'string' ? search.reason : undefined,
|
||||
}),
|
||||
component: LoginPage,
|
||||
|
|
@ -462,13 +457,6 @@ const adminNamespacesRoute = createRoute({
|
|||
component: AdminNamespacesPage,
|
||||
})
|
||||
|
||||
const adminSettingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'admin/settings',
|
||||
beforeLoad: requireAuth,
|
||||
component: AdminSettingsPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
landingRoute,
|
||||
skillsRoute,
|
||||
|
|
@ -506,7 +494,6 @@ const routeTree = rootRoute.addChildren([
|
|||
adminAuditLogRoute,
|
||||
adminLabelsRoute,
|
||||
adminNamespacesRoute,
|
||||
adminSettingsRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1212,7 +1212,8 @@
|
|||
"upload": {
|
||||
"dropHint": "Drop to upload...",
|
||||
"dragHint": "Drag a ZIP file here, or click to select",
|
||||
"formatHint": "Only .zip format supported"
|
||||
"formatHint": "Only .zip format supported",
|
||||
"folderHint": "Or select a folder to package and upload"
|
||||
},
|
||||
"layout": {
|
||||
"footerDescription": "Skill registry, providing efficient skill management and distribution for developers."
|
||||
|
|
@ -1338,8 +1339,7 @@
|
|||
"notifications": "Notification Settings",
|
||||
"accounts": "Account Merge",
|
||||
"logout": "Logout",
|
||||
"namespacesAdmin": "Namespace management",
|
||||
"platformSettings": "Platform settings"
|
||||
"namespacesAdmin": "Namespace management"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
|
|
@ -1390,7 +1390,8 @@
|
|||
"warningConfirmCancel": "Go back and fix",
|
||||
"frontmatterFailedTitle": "SKILL.md format is invalid",
|
||||
"frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.",
|
||||
"selectRequired": "Please select namespace and file"
|
||||
"selectRequired": "Please select namespace and file",
|
||||
"folderPackagingFailed": "Could not package the selected folder. Make sure it contains files."
|
||||
},
|
||||
"toast": {
|
||||
"success": "Success",
|
||||
|
|
@ -1645,29 +1646,5 @@
|
|||
"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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1212,7 +1212,8 @@
|
|||
"upload": {
|
||||
"dropHint": "放开以上传文件...",
|
||||
"dragHint": "拖拽 ZIP 文件到此处,或点击选择",
|
||||
"formatHint": "仅支持 .zip 格式"
|
||||
"formatHint": "仅支持 .zip 格式",
|
||||
"folderHint": "或选择文件夹,自动打包上传"
|
||||
},
|
||||
"layout": {
|
||||
"footerDescription": "技能注册中心,为开发者提供高效的技能管理和分发平台。"
|
||||
|
|
@ -1338,8 +1339,7 @@
|
|||
"notifications": "通知设置",
|
||||
"accounts": "账号合并",
|
||||
"logout": "退出登录",
|
||||
"namespacesAdmin": "命名空间管理",
|
||||
"platformSettings": "平台设置"
|
||||
"namespacesAdmin": "命名空间管理"
|
||||
}
|
||||
},
|
||||
"footer": {
|
||||
|
|
@ -1390,7 +1390,8 @@
|
|||
"warningConfirmCancel": "返回修改",
|
||||
"frontmatterFailedTitle": "SKILL.md 格式有误",
|
||||
"frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。",
|
||||
"selectRequired": "请选择命名空间和文件"
|
||||
"selectRequired": "请选择命名空间和文件",
|
||||
"folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。"
|
||||
},
|
||||
"toast": {
|
||||
"success": "成功",
|
||||
|
|
@ -1645,29 +1646,5 @@
|
|||
"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": "请稍后重试。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
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,11 +195,6 @@ 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