mirror of https://github.com/halo-dev/halo
refactor: improve the system initialization process (#4306)
* refactor: improve the system initialization process * Sync api-client Signed-off-by: Ryan Wang <i@ryanc.cc> * feat: add initialized state to global info * Refine setup page ui Signed-off-by: Ryan Wang <i@ryanc.cc> * refactor: improve the system initialization process * Refine setup page ui Signed-off-by: Ryan Wang <i@ryanc.cc> * Refine setup page ui Signed-off-by: Ryan Wang <i@ryanc.cc> * fix: update with initialize state * Refactor setup Signed-off-by: Ryan Wang <i@ryanc.cc> * refactor: initialization state * Refactor router guards Signed-off-by: Ryan Wang <i@ryanc.cc> * Refine i18n Signed-off-by: Ryan Wang <i@ryanc.cc> * Refactor init data Signed-off-by: Ryan Wang <i@ryanc.cc> * Refactor init data Signed-off-by: Ryan Wang <i@ryanc.cc> * Update console/src/views/system/Setup.vue Co-authored-by: Takagi <mail@e.lixingyong.com> * refactor: initialization interface --------- Signed-off-by: Ryan Wang <i@ryanc.cc> Co-authored-by: Ryan Wang <i@ryanc.cc> Co-authored-by: Takagi <mail@e.lixingyong.com>pull/4411/head
parent
1172f4a98c
commit
5690de3f24
|
@ -22,6 +22,10 @@ public class JsonUtils {
|
|||
private JsonUtils() {
|
||||
}
|
||||
|
||||
public static ObjectMapper mapper() {
|
||||
return DEFAULT_JSON_MAPPER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a map to the object specified type.
|
||||
*
|
||||
|
|
|
@ -14,6 +14,7 @@ import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
|||
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.infra.InitializationStateGetter;
|
||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||
import run.halo.app.infra.SystemSetting;
|
||||
import run.halo.app.infra.SystemSetting.Basic;
|
||||
|
@ -33,6 +34,8 @@ public class GlobalInfoEndpoint {
|
|||
|
||||
private final AuthProviderService authProviderService;
|
||||
|
||||
private final InitializationStateGetter initializationStateGetter;
|
||||
|
||||
@ReadOperation
|
||||
public GlobalInfo globalInfo() {
|
||||
final var info = new GlobalInfo();
|
||||
|
@ -40,6 +43,10 @@ public class GlobalInfoEndpoint {
|
|||
info.setUseAbsolutePermalink(haloProperties.isUseAbsolutePermalink());
|
||||
info.setLocale(Locale.getDefault());
|
||||
info.setTimeZone(TimeZone.getDefault());
|
||||
info.setUserInitialized(initializationStateGetter.userInitialized()
|
||||
.blockOptional().orElse(false));
|
||||
info.setDataInitialized(initializationStateGetter.dataInitialized()
|
||||
.blockOptional().orElse(false));
|
||||
handleSocialAuthProvider(info);
|
||||
systemConfigFetcher.ifAvailable(fetcher -> fetcher.getConfigMapBlocking()
|
||||
.ifPresent(configMap -> {
|
||||
|
@ -70,6 +77,10 @@ public class GlobalInfoEndpoint {
|
|||
|
||||
private String favicon;
|
||||
|
||||
private boolean userInitialized;
|
||||
|
||||
private boolean dataInitialized;
|
||||
|
||||
private List<SocialAuthProvider> socialAuthProviders;
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ import static org.springframework.security.web.server.util.matcher.ServerWebExch
|
|||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
|
@ -27,13 +26,11 @@ import org.springframework.web.reactive.function.server.RouterFunction;
|
|||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import run.halo.app.core.extension.service.RoleService;
|
||||
import run.halo.app.core.extension.service.UserService;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.AnonymousUserConst;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
|
||||
import run.halo.app.security.DefaultUserDetailService;
|
||||
import run.halo.app.security.DynamicMatcherSecurityWebFilterChain;
|
||||
import run.halo.app.security.SuperAdminInitializer;
|
||||
import run.halo.app.security.authentication.SecurityConfigurer;
|
||||
import run.halo.app.security.authentication.login.CryptoService;
|
||||
import run.halo.app.security.authentication.login.PublicKeyRouteBuilder;
|
||||
|
@ -127,16 +124,6 @@ public class WebServerSecurityConfig {
|
|||
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "halo.security.initializer.disabled",
|
||||
havingValue = "false",
|
||||
matchIfMissing = true)
|
||||
SuperAdminInitializer superAdminInitializer(ReactiveExtensionClient client,
|
||||
HaloProperties halo) {
|
||||
return new SuperAdminInitializer(client, passwordEncoder(),
|
||||
halo.getSecurity().getInitializer());
|
||||
}
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> publicKeyRoute(CryptoService cryptoService) {
|
||||
return new PublicKeyRouteBuilder(cryptoService).build();
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
|
||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.InitializationStateGetter;
|
||||
import run.halo.app.infra.SystemSetting;
|
||||
import run.halo.app.infra.ValidationUtils;
|
||||
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.app.security.SuperAdminInitializer;
|
||||
|
||||
/**
|
||||
* System initialization endpoint.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.9.0
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SystemInitializationEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final SuperAdminInitializer superAdminInitializer;
|
||||
private final InitializationStateGetter initializationStateSupplier;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
var tag = "api.console.halo.run/v1alpha1/System";
|
||||
// define a non-resource api
|
||||
return SpringdocRouteBuilder.route()
|
||||
.POST("/system/initialize", this::initialize,
|
||||
builder -> builder.operationId("initialize")
|
||||
.description("Initialize system")
|
||||
.tag(tag)
|
||||
.requestBody(requestBodyBuilder()
|
||||
.implementation(SystemInitializationRequest.class))
|
||||
.response(responseBuilder().implementation(Boolean.class))
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> initialize(ServerRequest request) {
|
||||
return request.bodyToMono(SystemInitializationRequest.class)
|
||||
.switchIfEmpty(
|
||||
Mono.error(new ServerWebInputException("Request body must not be empty"))
|
||||
)
|
||||
.doOnNext(requestBody -> {
|
||||
if (!ValidationUtils.validateName(requestBody.getUsername())) {
|
||||
throw new UnsatisfiedAttributeValueException(
|
||||
"The username does not meet the specifications",
|
||||
"problemDetail.user.username.unsatisfied", null);
|
||||
}
|
||||
if (StringUtils.isBlank(requestBody.getPassword())) {
|
||||
throw new UnsatisfiedAttributeValueException(
|
||||
"The password does not meet the specifications",
|
||||
"problemDetail.user.password.unsatisfied", null);
|
||||
}
|
||||
})
|
||||
.flatMap(requestBody -> initializationStateSupplier.userInitialized()
|
||||
.flatMap(result -> {
|
||||
if (result) {
|
||||
return Mono.error(new ResponseStatusException(HttpStatus.CONFLICT,
|
||||
"System has been initialized"));
|
||||
}
|
||||
return initializeSystem(requestBody);
|
||||
})
|
||||
)
|
||||
.then(ServerResponse.ok().bodyValue(true));
|
||||
}
|
||||
|
||||
private Mono<Void> initializeSystem(SystemInitializationRequest requestBody) {
|
||||
Mono<Void> initializeAdminUser = superAdminInitializer.initialize(
|
||||
SuperAdminInitializer.InitializationParam.builder()
|
||||
.username(requestBody.getUsername())
|
||||
.password(requestBody.getPassword())
|
||||
.email(requestBody.getEmail())
|
||||
.build());
|
||||
|
||||
Mono<Void> siteSetting =
|
||||
Mono.defer(() -> client.get(ConfigMap.class, SystemSetting.SYSTEM_CONFIG)
|
||||
.flatMap(config -> {
|
||||
Map<String, String> data = config.getData();
|
||||
if (data == null) {
|
||||
data = new LinkedHashMap<>();
|
||||
config.setData(data);
|
||||
}
|
||||
String basic = data.getOrDefault(SystemSetting.Basic.GROUP, "{}");
|
||||
SystemSetting.Basic basicSetting =
|
||||
JsonUtils.jsonToObject(basic, SystemSetting.Basic.class);
|
||||
basicSetting.setTitle(requestBody.getSiteTitle());
|
||||
data.put(SystemSetting.Basic.GROUP, JsonUtils.objectToJson(basicSetting));
|
||||
return client.update(config);
|
||||
}))
|
||||
.retryWhen(Retry.backoff(5, Duration.ofMillis(100))
|
||||
.filter(t -> t instanceof OptimisticLockingFailureException)
|
||||
)
|
||||
.then();
|
||||
return Mono.when(initializeAdminUser, siteSetting);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SystemInitializationRequest {
|
||||
|
||||
@Schema(requiredMode = REQUIRED, minLength = 1)
|
||||
private String username;
|
||||
|
||||
@Schema(requiredMode = REQUIRED, minLength = 3)
|
||||
private String password;
|
||||
|
||||
private String email;
|
||||
|
||||
private String siteTitle;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import static org.apache.commons.lang3.BooleanUtils.isTrue;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.MetadataUtil;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
/**
|
||||
* <p>A cache that caches system setup state.</p>
|
||||
* when setUp state changed, the cache will be updated.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.5.2
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultInitializationStateGetter implements InitializationStateGetter {
|
||||
private final ReactiveExtensionClient client;
|
||||
private final AtomicBoolean userInitialized = new AtomicBoolean(false);
|
||||
private final AtomicBoolean dataInitialized = new AtomicBoolean(false);
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> userInitialized() {
|
||||
// If user is initialized, return true directly.
|
||||
if (userInitialized.get()) {
|
||||
return Mono.just(true);
|
||||
}
|
||||
return hasUser()
|
||||
.doOnNext(userInitialized::set);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> dataInitialized() {
|
||||
if (dataInitialized.get()) {
|
||||
return Mono.just(true);
|
||||
}
|
||||
return client.fetch(ConfigMap.class, SystemState.SYSTEM_STATES_CONFIGMAP)
|
||||
.map(config -> {
|
||||
SystemState systemState = SystemState.deserialize(config);
|
||||
return isTrue(systemState.getIsSetup());
|
||||
})
|
||||
.defaultIfEmpty(false)
|
||||
.doOnNext(dataInitialized::set);
|
||||
}
|
||||
|
||||
private Mono<Boolean> hasUser() {
|
||||
return client.list(User.class,
|
||||
user -> {
|
||||
var labels = MetadataUtil.nullSafeLabels(user);
|
||||
return isNotTrue(labels.get("halo.run/hidden-user"));
|
||||
}, null, 1, 10)
|
||||
.map(result -> result.getTotal() > 0)
|
||||
.defaultIfEmpty(false);
|
||||
}
|
||||
|
||||
static boolean isNotTrue(String value) {
|
||||
return !Boolean.parseBoolean(value);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* <p>A interface that get system initialization state.</p>
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.9.0
|
||||
*/
|
||||
public interface InitializationStateGetter {
|
||||
|
||||
/**
|
||||
* Check if system user is initialized.
|
||||
*
|
||||
* @return <code>true</code> if system user is initialized, <code>false</code> otherwise.
|
||||
*/
|
||||
Mono<Boolean> userInitialized();
|
||||
|
||||
/**
|
||||
* Check if system basic data is initialized.
|
||||
*
|
||||
* @return <code>true</code> if system basic data is initialized, <code>false</code> otherwise.
|
||||
*/
|
||||
Mono<Boolean> dataInitialized();
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import io.micrometer.common.util.StringUtils;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.Data;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.controller.Controller;
|
||||
import run.halo.app.extension.controller.ControllerBuilder;
|
||||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* <p>A cache that caches system setup state.</p>
|
||||
* when setUp state changed, the cache will be updated.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.5.2
|
||||
*/
|
||||
@Component
|
||||
public class SetupStateCache implements Reconciler<Reconciler.Request>, Supplier<Boolean> {
|
||||
public static final String SYSTEM_STATES_CONFIGMAP = "system-states";
|
||||
private final ExtensionClient client;
|
||||
|
||||
private final InternalValueCache valueCache = new InternalValueCache();
|
||||
|
||||
public SetupStateCache(ExtensionClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Gets system setup state.</p>
|
||||
* Never return null.
|
||||
*
|
||||
* @return <code>true</code> if system is initialized, <code>false</code> otherwise.
|
||||
*/
|
||||
@NonNull
|
||||
@Override
|
||||
public Boolean get() {
|
||||
return valueCache.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result reconcile(Request request) {
|
||||
if (!SYSTEM_STATES_CONFIGMAP.equals(request.name())) {
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
valueCache.cache(isInitialized());
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Controller setupWith(ControllerBuilder builder) {
|
||||
return builder
|
||||
.extension(new ConfigMap())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if system is initialized.
|
||||
*
|
||||
* @return <code>true</code> if system is initialized, <code>false</code> otherwise.
|
||||
*/
|
||||
private boolean isInitialized() {
|
||||
return client.fetch(ConfigMap.class, SYSTEM_STATES_CONFIGMAP)
|
||||
.filter(configMap -> configMap.getData() != null)
|
||||
.map(ConfigMap::getData)
|
||||
.flatMap(map -> Optional.ofNullable(map.get(SystemStates.GROUP))
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.map(value -> JsonUtils.jsonToObject(value, SystemStates.class).getIsSetup())
|
||||
)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
@Data
|
||||
static class SystemStates {
|
||||
static final String GROUP = "states";
|
||||
Boolean isSetup;
|
||||
}
|
||||
|
||||
static class InternalValueCache {
|
||||
private final AtomicBoolean value = new AtomicBoolean(false);
|
||||
|
||||
public boolean cache(boolean newValue) {
|
||||
return value.getAndSet(newValue);
|
||||
}
|
||||
|
||||
public boolean get() {
|
||||
return value.get();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.github.fge.jsonpatch.JsonPatchException;
|
||||
import com.github.fge.jsonpatch.mergepatch.JsonMergePatch;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
import org.springframework.lang.NonNull;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.infra.utils.JsonParseException;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
/**
|
||||
* A model for system state deserialize from {@link run.halo.app.extension.ConfigMap}
|
||||
* named {@code system-states}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.8.0
|
||||
*/
|
||||
@Data
|
||||
public class SystemState {
|
||||
public static final String SYSTEM_STATES_CONFIGMAP = "system-states";
|
||||
|
||||
static final String GROUP = "states";
|
||||
|
||||
private Boolean isSetup;
|
||||
|
||||
/**
|
||||
* Deserialize from {@link ConfigMap}.
|
||||
*
|
||||
* @return config map
|
||||
*/
|
||||
public static SystemState deserialize(@NonNull ConfigMap configMap) {
|
||||
Map<String, String> data = configMap.getData();
|
||||
if (data == null) {
|
||||
return new SystemState();
|
||||
}
|
||||
return JsonUtils.jsonToObject(data.getOrDefault(GROUP, emptyJsonObject()),
|
||||
SystemState.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update modified system state to config map.
|
||||
*
|
||||
* @param systemState modified system state
|
||||
* @param configMap config map
|
||||
*/
|
||||
public static void update(@NonNull SystemState systemState, @NonNull ConfigMap configMap) {
|
||||
Map<String, String> data = configMap.getData();
|
||||
if (data == null) {
|
||||
data = new LinkedHashMap<>();
|
||||
configMap.setData(data);
|
||||
}
|
||||
JsonNode modifiedJson = JsonUtils.mapper()
|
||||
.convertValue(systemState, JsonNode.class);
|
||||
// original
|
||||
JsonNode sourceJson =
|
||||
JsonUtils.jsonToObject(data.getOrDefault(GROUP, emptyJsonObject()), JsonNode.class);
|
||||
try {
|
||||
// patch
|
||||
JsonMergePatch jsonMergePatch = JsonMergePatch.fromJson(modifiedJson);
|
||||
// apply patch to original
|
||||
JsonNode patchedNode = jsonMergePatch.apply(sourceJson);
|
||||
data.put(GROUP, JsonUtils.objectToJson(patchedNode));
|
||||
} catch (JsonPatchException e) {
|
||||
throw new JsonParseException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String emptyJsonObject() {
|
||||
return "{}";
|
||||
}
|
||||
}
|
|
@ -75,7 +75,5 @@ public class HaloProperties implements Validator {
|
|||
errors.rejectValue("externalUrl", "external-url.required.when-using-absolute-permalink",
|
||||
"External URL is required when property `use-absolute-permalink` is set to true.");
|
||||
}
|
||||
SecurityProperties.Initializer.validateUsername(props.getSecurity().getInitializer(),
|
||||
errors);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,17 +3,12 @@ package run.halo.app.infra.properties;
|
|||
import static org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.web.server.header.ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy;
|
||||
import org.springframework.security.web.server.header.XFrameOptionsServerHttpHeadersWriter.Mode;
|
||||
import org.springframework.validation.Errors;
|
||||
import run.halo.app.infra.ValidationUtils;
|
||||
|
||||
@Data
|
||||
public class SecurityProperties {
|
||||
|
||||
private final Initializer initializer = new Initializer();
|
||||
|
||||
private final FrameOptions frameOptions = new FrameOptions();
|
||||
|
||||
private final ReferrerOptions referrerOptions = new ReferrerOptions();
|
||||
|
@ -32,24 +27,4 @@ public class SecurityProperties {
|
|||
private ReferrerPolicy policy = STRICT_ORIGIN_WHEN_CROSS_ORIGIN;
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Initializer {
|
||||
|
||||
private boolean disabled;
|
||||
|
||||
private String superAdminUsername = "admin";
|
||||
|
||||
private String superAdminPassword;
|
||||
|
||||
static void validateUsername(@NonNull Initializer initializer, @NonNull Errors errors) {
|
||||
if (initializer.isDisabled() || ValidationUtils.validateName(
|
||||
initializer.getSuperAdminUsername())) {
|
||||
return;
|
||||
}
|
||||
errors.rejectValue("security.initializer.superAdminUsername",
|
||||
"initializer.superAdminUsername.invalid",
|
||||
ValidationUtils.NAME_VALIDATION_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
package run.halo.app.security;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.core.extension.RoleBinding;
|
||||
import run.halo.app.core.extension.RoleBinding.RoleRef;
|
||||
import run.halo.app.core.extension.RoleBinding.Subject;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.User.UserSpec;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultSuperAdminInitializer implements SuperAdminInitializer {
|
||||
|
||||
private static final String SUPER_ROLE_NAME = "super-role";
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Override
|
||||
public Mono<Void> initialize(InitializationParam param) {
|
||||
return client.fetch(User.class, param.getUsername())
|
||||
.switchIfEmpty(Mono.defer(() -> client.create(
|
||||
createAdmin(param.getUsername(), param.getPassword(), param.getEmail())))
|
||||
.flatMap(admin -> {
|
||||
var binding = bindAdminAndSuperRole(admin);
|
||||
return client.create(binding).thenReturn(admin);
|
||||
})
|
||||
)
|
||||
.then();
|
||||
}
|
||||
|
||||
RoleBinding bindAdminAndSuperRole(User admin) {
|
||||
String adminUserName = admin.getMetadata().getName();
|
||||
var metadata = new Metadata();
|
||||
String name =
|
||||
String.join("-", adminUserName, SUPER_ROLE_NAME, "binding");
|
||||
metadata.setName(name);
|
||||
var roleRef = new RoleRef();
|
||||
roleRef.setName(SUPER_ROLE_NAME);
|
||||
roleRef.setApiGroup(Role.GROUP);
|
||||
roleRef.setKind(Role.KIND);
|
||||
|
||||
var subject = new Subject();
|
||||
subject.setName(adminUserName);
|
||||
subject.setApiGroup(admin.groupVersionKind().group());
|
||||
subject.setKind(admin.groupVersionKind().kind());
|
||||
|
||||
var roleBinding = new RoleBinding();
|
||||
roleBinding.setMetadata(metadata);
|
||||
roleBinding.setRoleRef(roleRef);
|
||||
roleBinding.setSubjects(List.of(subject));
|
||||
|
||||
return roleBinding;
|
||||
}
|
||||
|
||||
User createAdmin(String username, String password, String email) {
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(username);
|
||||
|
||||
var spec = new UserSpec();
|
||||
spec.setDisplayName("Administrator");
|
||||
spec.setDisabled(false);
|
||||
spec.setRegisteredAt(Instant.now());
|
||||
spec.setTwoFactorAuthEnabled(false);
|
||||
spec.setEmail(email);
|
||||
spec.setPassword(passwordEncoder.encode(password));
|
||||
|
||||
var user = new User();
|
||||
user.setMetadata(metadata);
|
||||
user.setSpec(spec);
|
||||
return user;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package run.halo.app.security;
|
||||
|
||||
import java.net.URI;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.lang.NonNull;
|
||||
|
@ -14,7 +15,7 @@ import org.springframework.web.server.ServerWebExchange;
|
|||
import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.SetupStateCache;
|
||||
import run.halo.app.infra.InitializationStateGetter;
|
||||
|
||||
/**
|
||||
* A web filter that will redirect user to set up page if system is not initialized.
|
||||
|
@ -29,8 +30,9 @@ public class InitializeRedirectionWebFilter implements WebFilter {
|
|||
private final ServerWebExchangeMatcher redirectMatcher =
|
||||
new PathPatternParserServerWebExchangeMatcher("/", HttpMethod.GET);
|
||||
|
||||
private final SetupStateCache setupStateCache;
|
||||
private final InitializationStateGetter initializationStateGetter;
|
||||
|
||||
@Getter
|
||||
private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
|
||||
|
||||
@Override
|
||||
|
@ -38,18 +40,21 @@ public class InitializeRedirectionWebFilter implements WebFilter {
|
|||
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
|
||||
return redirectMatcher.matches(exchange)
|
||||
.flatMap(matched -> {
|
||||
if (!matched.isMatch() || setupStateCache.get()) {
|
||||
if (!matched.isMatch()) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
// Redirect to set up page if system is not initialized.
|
||||
return redirectStrategy.sendRedirect(exchange, location);
|
||||
return initializationStateGetter.userInitialized()
|
||||
.defaultIfEmpty(false)
|
||||
.flatMap(initialized -> {
|
||||
if (initialized) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
// Redirect to set up page if system is not initialized.
|
||||
return redirectStrategy.sendRedirect(exchange, location);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public ServerRedirectStrategy getRedirectStrategy() {
|
||||
return redirectStrategy;
|
||||
}
|
||||
|
||||
public void setRedirectStrategy(ServerRedirectStrategy redirectStrategy) {
|
||||
Assert.notNull(redirectStrategy, "redirectStrategy cannot be null");
|
||||
this.redirectStrategy = redirectStrategy;
|
||||
|
|
|
@ -1,99 +1,29 @@
|
|||
package run.halo.app.security;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.RandomStringUtils;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.core.extension.RoleBinding;
|
||||
import run.halo.app.core.extension.RoleBinding.RoleRef;
|
||||
import run.halo.app.core.extension.RoleBinding.Subject;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.User.UserSpec;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.properties.SecurityProperties.Initializer;
|
||||
|
||||
@Slf4j
|
||||
public class SuperAdminInitializer {
|
||||
/**
|
||||
* Super admin initializer.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.9.0
|
||||
*/
|
||||
public interface SuperAdminInitializer {
|
||||
|
||||
private static final String SUPER_ROLE_NAME = "super-role";
|
||||
/**
|
||||
* Initialize super admin.
|
||||
*
|
||||
* @param param super admin initialization param
|
||||
*/
|
||||
Mono<Void> initialize(InitializationParam param);
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
private final Initializer initializer;
|
||||
|
||||
public SuperAdminInitializer(ReactiveExtensionClient client, PasswordEncoder passwordEncoder,
|
||||
Initializer initializer) {
|
||||
this.client = client;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.initializer = initializer;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public Mono<Void> initialize(ApplicationReadyEvent readyEvent) {
|
||||
return client.fetch(User.class, initializer.getSuperAdminUsername())
|
||||
.switchIfEmpty(Mono.defer(() -> client.create(createAdmin())).flatMap(admin -> {
|
||||
var binding = bindAdminAndSuperRole(admin);
|
||||
return client.create(binding).thenReturn(admin);
|
||||
})).then();
|
||||
}
|
||||
|
||||
RoleBinding bindAdminAndSuperRole(User admin) {
|
||||
var metadata = new Metadata();
|
||||
String name =
|
||||
String.join("-", initializer.getSuperAdminUsername(), SUPER_ROLE_NAME, "binding");
|
||||
metadata.setName(name);
|
||||
var roleRef = new RoleRef();
|
||||
roleRef.setName(SUPER_ROLE_NAME);
|
||||
roleRef.setApiGroup(Role.GROUP);
|
||||
roleRef.setKind(Role.KIND);
|
||||
|
||||
var subject = new Subject();
|
||||
subject.setName(admin.getMetadata().getName());
|
||||
subject.setApiGroup(admin.groupVersionKind().group());
|
||||
subject.setKind(admin.groupVersionKind().kind());
|
||||
|
||||
var roleBinding = new RoleBinding();
|
||||
roleBinding.setMetadata(metadata);
|
||||
roleBinding.setRoleRef(roleRef);
|
||||
roleBinding.setSubjects(List.of(subject));
|
||||
|
||||
return roleBinding;
|
||||
}
|
||||
|
||||
User createAdmin() {
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(initializer.getSuperAdminUsername());
|
||||
|
||||
var spec = new UserSpec();
|
||||
spec.setDisplayName("Administrator");
|
||||
spec.setDisabled(false);
|
||||
spec.setRegisteredAt(Instant.now());
|
||||
spec.setTwoFactorAuthEnabled(false);
|
||||
spec.setEmail("admin@halo.run");
|
||||
spec.setPassword(passwordEncoder.encode(getPassword()));
|
||||
|
||||
var user = new User();
|
||||
user.setMetadata(metadata);
|
||||
user.setSpec(spec);
|
||||
return user;
|
||||
}
|
||||
|
||||
private String getPassword() {
|
||||
var password = this.initializer.getSuperAdminPassword();
|
||||
if (!StringUtils.hasText(password)) {
|
||||
// generate password
|
||||
password = RandomStringUtils.randomAlphanumeric(16);
|
||||
log.info("=== Generated random password: {} for super administrator: {} ===",
|
||||
password, this.initializer.getSuperAdminUsername());
|
||||
}
|
||||
return password;
|
||||
@Data
|
||||
@Builder
|
||||
class InitializationParam {
|
||||
private String username;
|
||||
private String password;
|
||||
private String email;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,10 +19,6 @@ halo:
|
|||
proxy:
|
||||
endpoint: http://localhost:3000/
|
||||
enabled: true
|
||||
security:
|
||||
initializer:
|
||||
super-admin-username: admin
|
||||
super-admin-password: admin
|
||||
plugin:
|
||||
runtime-mode: development # development, deployment
|
||||
work-dir: ${user.home}/halo2-dev
|
||||
|
|
|
@ -39,6 +39,8 @@ problemDetail.run.halo.app.infra.exception.DuplicateNameException=Duplicate name
|
|||
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin {0} already exists.
|
||||
problemDetail.run.halo.app.infra.exception.RateLimitExceededException=API rate limit exceeded, please try again later.
|
||||
|
||||
problemDetail.user.password.unsatisfied=The password does not meet the specifications.
|
||||
problemDetail.user.username.unsatisfied=The username does not meet the specifications.
|
||||
problemDetail.user.signUpFailed.disallowed=System does not allow new users to register.
|
||||
problemDetail.user.duplicateName=The username {0} already exists, please rename it and retry.
|
||||
problemDetail.comment.turnedOff=The comment function has been turned off.
|
||||
|
|
|
@ -16,6 +16,8 @@ problemDetail.run.halo.app.infra.exception.DuplicateNameException=检测到有
|
|||
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=插件 {0} 已经存。
|
||||
problemDetail.run.halo.app.infra.exception.RateLimitExceededException=请求过于频繁,请稍候再试。
|
||||
|
||||
problemDetail.user.password.unsatisfied=密码不符合规范。
|
||||
problemDetail.user.username.unsatisfied=用户名不符合规范。
|
||||
problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。
|
||||
problemDetail.user.duplicateName=用户名 {0} 已存在,请更换用户名后重试。
|
||||
problemDetail.plugin.version.unsatisfied.requires=插件要求一个最小的系统版本为 {0}, 但当前版本为 {1}。
|
||||
|
|
|
@ -23,6 +23,8 @@ rules:
|
|||
verbs: [ "create" ]
|
||||
- nonResourceURLs: [ "/actuator/globalinfo", "/actuator/health", "/actuator/health/*", "/login/public-key" ]
|
||||
verbs: [ "get" ]
|
||||
- nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/system/initialize" ]
|
||||
verbs: [ "create" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: "Role"
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
package run.halo.app.core.extension.endpoint;
|
||||
|
||||
import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
|
||||
/**
|
||||
* Tests for {@link SystemInitializationEndpoint}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.9.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SystemInitializationEndpointTest {
|
||||
|
||||
@InjectMocks
|
||||
SystemInitializationEndpoint initializationEndpoint;
|
||||
|
||||
WebTestClient webTestClient;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
webTestClient = bindToRouterFunction(initializationEndpoint.endpoint()).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialize() {
|
||||
webTestClient.post()
|
||||
.uri("/system/initialize")
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isBadRequest();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
|
||||
/**
|
||||
* Tests for {@link InitializationStateGetter}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.9.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InitializationStateGetterTest {
|
||||
@Mock
|
||||
private ReactiveExtensionClient client;
|
||||
|
||||
@InjectMocks
|
||||
private DefaultInitializationStateGetter initializationStateGetter;
|
||||
|
||||
@Test
|
||||
void userInitialized() {
|
||||
when(client.list(eq(User.class), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(Mono.empty());
|
||||
initializationStateGetter.userInitialized()
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(false)
|
||||
.verifyComplete();
|
||||
|
||||
User user = new User();
|
||||
user.setMetadata(new Metadata());
|
||||
user.getMetadata().setName("fake-hidden-user");
|
||||
user.getMetadata().setLabels(Map.of("halo.run/hidden-user", "true"));
|
||||
user.setSpec(new User.UserSpec());
|
||||
user.getSpec().setDisplayName("fake-hidden-user");
|
||||
ListResult<User> listResult = new ListResult<>(List.of(user));
|
||||
|
||||
when(client.list(eq(User.class), any(), any(), anyInt(), anyInt()))
|
||||
.thenReturn(Mono.just(listResult));
|
||||
initializationStateGetter.userInitialized()
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void dataInitialized() {
|
||||
ConfigMap configMap = new ConfigMap();
|
||||
configMap.setMetadata(new Metadata());
|
||||
configMap.getMetadata().setName(SystemState.SYSTEM_STATES_CONFIGMAP);
|
||||
configMap.setData(Map.of("states", "{\"isSetup\":true}"));
|
||||
when(client.fetch(eq(ConfigMap.class), eq(SystemState.SYSTEM_STATES_CONFIGMAP)))
|
||||
.thenReturn(Mono.just(configMap));
|
||||
initializationStateGetter.dataInitialized()
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
|
||||
// call again
|
||||
initializationStateGetter.dataInitialized()
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(true)
|
||||
.verifyComplete();
|
||||
// execute only once
|
||||
verify(client).fetch(eq(ConfigMap.class), eq(SystemState.SYSTEM_STATES_CONFIGMAP));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import run.halo.app.extension.ConfigMap;
|
||||
|
||||
/**
|
||||
* Tests for {@link SystemState}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.8.0
|
||||
*/
|
||||
class SystemStateTest {
|
||||
|
||||
@Test
|
||||
void deserialize() {
|
||||
ConfigMap configMap = new ConfigMap();
|
||||
SystemState systemState = SystemState.deserialize(configMap);
|
||||
assertThat(systemState).isNotNull();
|
||||
|
||||
configMap.setData(Map.of(SystemState.GROUP, "{\"isSetup\":true}"));
|
||||
systemState = SystemState.deserialize(configMap);
|
||||
assertThat(systemState.getIsSetup()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void update() {
|
||||
SystemState newSystemState = new SystemState();
|
||||
newSystemState.setIsSetup(true);
|
||||
|
||||
ConfigMap configMap = new ConfigMap();
|
||||
SystemState.update(newSystemState, configMap);
|
||||
assertThat(configMap.getData().get(SystemState.GROUP)).isEqualTo("{\"isSetup\":true}");
|
||||
|
||||
var data = new LinkedHashMap<String, String>();
|
||||
configMap.setData(data);
|
||||
data.put(SystemState.GROUP, "{\"isSetup\":false}");
|
||||
SystemState.update(newSystemState, configMap);
|
||||
assertThat(configMap.getData().get(SystemState.GROUP)).isEqualTo("{\"isSetup\":true}");
|
||||
|
||||
data.clear();
|
||||
data.put(SystemState.GROUP, "{\"isSetup\":true, \"foo\":\"bar\"}");
|
||||
newSystemState.setIsSetup(false);
|
||||
SystemState.update(newSystemState, configMap);
|
||||
assertThat(configMap.getData().get(SystemState.GROUP))
|
||||
.isEqualTo("{\"isSetup\":false,\"foo\":\"bar\"}");
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ import org.springframework.security.web.server.ServerRedirectStrategy;
|
|||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.infra.SetupStateCache;
|
||||
import run.halo.app.infra.InitializationStateGetter;
|
||||
|
||||
/**
|
||||
* Tests for {@link InitializeRedirectionWebFilter}.
|
||||
|
@ -32,7 +32,7 @@ import run.halo.app.infra.SetupStateCache;
|
|||
class InitializeRedirectionWebFilterTest {
|
||||
|
||||
@Mock
|
||||
private SetupStateCache setupStateCache;
|
||||
private InitializationStateGetter initializationStateGetter;
|
||||
|
||||
@Mock
|
||||
private ServerRedirectStrategy serverRedirectStrategy;
|
||||
|
@ -47,7 +47,7 @@ class InitializeRedirectionWebFilterTest {
|
|||
|
||||
@Test
|
||||
void shouldRedirectWhenSystemNotInitialized() {
|
||||
when(setupStateCache.get()).thenReturn(false);
|
||||
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(false));
|
||||
|
||||
WebFilterChain chain = mock(WebFilterChain.class);
|
||||
|
||||
|
@ -69,7 +69,7 @@ class InitializeRedirectionWebFilterTest {
|
|||
|
||||
@Test
|
||||
void shouldNotRedirectWhenSystemInitialized() {
|
||||
when(setupStateCache.get()).thenReturn(true);
|
||||
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
|
||||
|
||||
WebFilterChain chain = mock(WebFilterChain.class);
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ import org.springframework.web.reactive.function.server.RouterFunctions;
|
|||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.infra.SetupStateCache;
|
||||
import run.halo.app.infra.InitializationStateGetter;
|
||||
import run.halo.app.theme.ThemeContext;
|
||||
import run.halo.app.theme.ThemeResolver;
|
||||
|
||||
|
@ -46,14 +46,14 @@ public class ThemeMessageResolverIntegrationTest {
|
|||
private URL otherThemeUrl;
|
||||
|
||||
@SpyBean
|
||||
private SetupStateCache setupStateCache;
|
||||
private InitializationStateGetter initializationStateGetter;
|
||||
|
||||
@Autowired
|
||||
private WebTestClient webTestClient;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws FileNotFoundException, URISyntaxException {
|
||||
when(setupStateCache.get()).thenReturn(true);
|
||||
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
|
||||
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
|
||||
otherThemeUrl = ResourceUtils.getURL("classpath:themes/other");
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ api/api-console-halo-run-v1alpha1-post-api.ts
|
|||
api/api-console-halo-run-v1alpha1-reply-api.ts
|
||||
api/api-console-halo-run-v1alpha1-single-page-api.ts
|
||||
api/api-console-halo-run-v1alpha1-stats-api.ts
|
||||
api/api-console-halo-run-v1alpha1-system-api.ts
|
||||
api/api-console-halo-run-v1alpha1-theme-api.ts
|
||||
api/api-console-halo-run-v1alpha1-user-api.ts
|
||||
api/api-console-migration-halo-run-v1alpha1-migration-api.ts
|
||||
|
@ -219,6 +220,7 @@ models/snapshot.ts
|
|||
models/stats-vo.ts
|
||||
models/stats.ts
|
||||
models/subject.ts
|
||||
models/system-initialization-request.ts
|
||||
models/tag-list.ts
|
||||
models/tag-spec.ts
|
||||
models/tag-status.ts
|
||||
|
|
|
@ -21,6 +21,7 @@ export * from "./api/api-console-halo-run-v1alpha1-post-api";
|
|||
export * from "./api/api-console-halo-run-v1alpha1-reply-api";
|
||||
export * from "./api/api-console-halo-run-v1alpha1-single-page-api";
|
||||
export * from "./api/api-console-halo-run-v1alpha1-stats-api";
|
||||
export * from "./api/api-console-halo-run-v1alpha1-system-api";
|
||||
export * from "./api/api-console-halo-run-v1alpha1-theme-api";
|
||||
export * from "./api/api-console-halo-run-v1alpha1-user-api";
|
||||
export * from "./api/api-console-migration-halo-run-v1alpha1-migration-api";
|
||||
|
|
|
@ -0,0 +1,208 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Halo Next API
|
||||
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||
*
|
||||
* The version of the OpenAPI document: 2.0.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
import type { Configuration } from "../configuration";
|
||||
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
|
||||
import globalAxios from "axios";
|
||||
// Some imports not used depending on template conditions
|
||||
// @ts-ignore
|
||||
import {
|
||||
DUMMY_BASE_URL,
|
||||
assertParamExists,
|
||||
setApiKeyToObject,
|
||||
setBasicAuthToObject,
|
||||
setBearerAuthToObject,
|
||||
setOAuthToObject,
|
||||
setSearchParams,
|
||||
serializeDataIfNeeded,
|
||||
toPathString,
|
||||
createRequestFunction,
|
||||
} from "../common";
|
||||
// @ts-ignore
|
||||
import {
|
||||
BASE_PATH,
|
||||
COLLECTION_FORMATS,
|
||||
RequestArgs,
|
||||
BaseAPI,
|
||||
RequiredError,
|
||||
} from "../base";
|
||||
// @ts-ignore
|
||||
import { SystemInitializationRequest } from "../models";
|
||||
/**
|
||||
* ApiConsoleHaloRunV1alpha1SystemApi - axios parameter creator
|
||||
* @export
|
||||
*/
|
||||
export const ApiConsoleHaloRunV1alpha1SystemApiAxiosParamCreator = function (
|
||||
configuration?: Configuration
|
||||
) {
|
||||
return {
|
||||
/**
|
||||
* Initialize system
|
||||
* @param {SystemInitializationRequest} [systemInitializationRequest]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
initialize: async (
|
||||
systemInitializationRequest?: SystemInitializationRequest,
|
||||
options: AxiosRequestConfig = {}
|
||||
): Promise<RequestArgs> => {
|
||||
const localVarPath = `/apis/api.console.halo.run/v1alpha1/system/initialize`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = {
|
||||
method: "POST",
|
||||
...baseOptions,
|
||||
...options,
|
||||
};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication BasicAuth required
|
||||
// http basic authentication required
|
||||
setBasicAuthToObject(localVarRequestOptions, configuration);
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration);
|
||||
|
||||
localVarHeaderParameter["Content-Type"] = "application/json";
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions =
|
||||
baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {
|
||||
...localVarHeaderParameter,
|
||||
...headersFromBaseOptions,
|
||||
...options.headers,
|
||||
};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(
|
||||
systemInitializationRequest,
|
||||
localVarRequestOptions,
|
||||
configuration
|
||||
);
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* ApiConsoleHaloRunV1alpha1SystemApi - functional programming interface
|
||||
* @export
|
||||
*/
|
||||
export const ApiConsoleHaloRunV1alpha1SystemApiFp = function (
|
||||
configuration?: Configuration
|
||||
) {
|
||||
const localVarAxiosParamCreator =
|
||||
ApiConsoleHaloRunV1alpha1SystemApiAxiosParamCreator(configuration);
|
||||
return {
|
||||
/**
|
||||
* Initialize system
|
||||
* @param {SystemInitializationRequest} [systemInitializationRequest]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async initialize(
|
||||
systemInitializationRequest?: SystemInitializationRequest,
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<
|
||||
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<boolean>
|
||||
> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.initialize(
|
||||
systemInitializationRequest,
|
||||
options
|
||||
);
|
||||
return createRequestFunction(
|
||||
localVarAxiosArgs,
|
||||
globalAxios,
|
||||
BASE_PATH,
|
||||
configuration
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* ApiConsoleHaloRunV1alpha1SystemApi - factory interface
|
||||
* @export
|
||||
*/
|
||||
export const ApiConsoleHaloRunV1alpha1SystemApiFactory = function (
|
||||
configuration?: Configuration,
|
||||
basePath?: string,
|
||||
axios?: AxiosInstance
|
||||
) {
|
||||
const localVarFp = ApiConsoleHaloRunV1alpha1SystemApiFp(configuration);
|
||||
return {
|
||||
/**
|
||||
* Initialize system
|
||||
* @param {ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
initialize(
|
||||
requestParameters: ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest = {},
|
||||
options?: AxiosRequestConfig
|
||||
): AxiosPromise<boolean> {
|
||||
return localVarFp
|
||||
.initialize(requestParameters.systemInitializationRequest, options)
|
||||
.then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Request parameters for initialize operation in ApiConsoleHaloRunV1alpha1SystemApi.
|
||||
* @export
|
||||
* @interface ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest
|
||||
*/
|
||||
export interface ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest {
|
||||
/**
|
||||
*
|
||||
* @type {SystemInitializationRequest}
|
||||
* @memberof ApiConsoleHaloRunV1alpha1SystemApiInitialize
|
||||
*/
|
||||
readonly systemInitializationRequest?: SystemInitializationRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* ApiConsoleHaloRunV1alpha1SystemApi - object-oriented interface
|
||||
* @export
|
||||
* @class ApiConsoleHaloRunV1alpha1SystemApi
|
||||
* @extends {BaseAPI}
|
||||
*/
|
||||
export class ApiConsoleHaloRunV1alpha1SystemApi extends BaseAPI {
|
||||
/**
|
||||
* Initialize system
|
||||
* @param {ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof ApiConsoleHaloRunV1alpha1SystemApi
|
||||
*/
|
||||
public initialize(
|
||||
requestParameters: ApiConsoleHaloRunV1alpha1SystemApiInitializeRequest = {},
|
||||
options?: AxiosRequestConfig
|
||||
) {
|
||||
return ApiConsoleHaloRunV1alpha1SystemApiFp(this.configuration)
|
||||
.initialize(requestParameters.systemInitializationRequest, options)
|
||||
.then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
|
@ -154,6 +154,7 @@ export * from "./snapshot-list";
|
|||
export * from "./stats";
|
||||
export * from "./stats-vo";
|
||||
export * from "./subject";
|
||||
export * from "./system-initialization-request";
|
||||
export * from "./tag";
|
||||
export * from "./tag-list";
|
||||
export * from "./tag-spec";
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Halo Next API
|
||||
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
|
||||
*
|
||||
* The version of the OpenAPI document: 2.0.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface SystemInitializationRequest
|
||||
*/
|
||||
export interface SystemInitializationRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemInitializationRequest
|
||||
*/
|
||||
email?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemInitializationRequest
|
||||
*/
|
||||
password: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemInitializationRequest
|
||||
*/
|
||||
siteTitle?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SystemInitializationRequest
|
||||
*/
|
||||
username: string;
|
||||
}
|
|
@ -1,11 +1,10 @@
|
|||
<script lang="ts" setup>
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
import { computed, watch, ref, reactive, onMounted, inject } from "vue";
|
||||
import { computed, watch, reactive, onMounted, inject } from "vue";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { useFavicon } from "@vueuse/core";
|
||||
import { useSystemConfigMapStore } from "./stores/system-configmap";
|
||||
import { storeToRefs } from "pinia";
|
||||
import axios from "axios";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
useOverlayScrollbars,
|
||||
|
@ -14,10 +13,12 @@ import {
|
|||
import type { FormKitConfig } from "@formkit/core";
|
||||
import { i18n } from "./locales";
|
||||
import { AppName } from "./constants/app";
|
||||
import { useGlobalInfoStore } from "./stores/global-info";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { configMap } = storeToRefs(useSystemConfigMapStore());
|
||||
const globalInfoStore = useGlobalInfoStore();
|
||||
|
||||
const route = useRoute();
|
||||
const title = useTitle();
|
||||
|
@ -36,19 +37,6 @@ watch(
|
|||
|
||||
// Favicon
|
||||
const defaultFavicon = "/console/favicon.ico";
|
||||
const globalInfoFavicon = ref("");
|
||||
|
||||
(async () => {
|
||||
const { data } = await axios.get(
|
||||
`${import.meta.env.VITE_API_URL}/actuator/globalinfo`,
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
if (data?.favicon) {
|
||||
globalInfoFavicon.value = data.favicon;
|
||||
}
|
||||
})();
|
||||
|
||||
const favicon = computed(() => {
|
||||
if (configMap?.value?.data?.["basic"]) {
|
||||
|
@ -59,8 +47,8 @@ const favicon = computed(() => {
|
|||
}
|
||||
}
|
||||
|
||||
if (globalInfoFavicon.value) {
|
||||
return globalInfoFavicon.value;
|
||||
if (globalInfoStore.globalInfo?.favicon) {
|
||||
return globalInfoStore.globalInfo.favicon;
|
||||
}
|
||||
|
||||
return defaultFavicon;
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts" setup>
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { locales, getBrowserLanguage, i18n } from "@/locales";
|
||||
import { watch } from "vue";
|
||||
import MdiTranslate from "~icons/mdi/translate";
|
||||
|
||||
// setup locale
|
||||
const currentLocale = useLocalStorage(
|
||||
"locale",
|
||||
getBrowserLanguage() || locales[0].code
|
||||
);
|
||||
|
||||
watch(
|
||||
() => currentLocale.value,
|
||||
(value) => {
|
||||
i18n.global.locale.value = value;
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
for="locale"
|
||||
class="block flex-shrink-0 text-sm font-medium text-gray-600"
|
||||
>
|
||||
<MdiTranslate />
|
||||
</label>
|
||||
<select
|
||||
id="locale"
|
||||
v-model="currentLocale"
|
||||
class="block appearance-none rounded-md border-0 py-1.5 pl-3 pr-10 text-sm text-gray-800 outline-none ring-1 ring-inset ring-gray-200 focus:ring-primary"
|
||||
>
|
||||
<template v-for="locale in locales">
|
||||
<option v-if="locale.name" :key="locale.code" :value="locale.code">
|
||||
{{ locale.name }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
</template>
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import type { SocialAuthProvider } from "@/modules/system/actuator/types";
|
||||
import type { SocialAuthProvider } from "@/types";
|
||||
import { useRouteQuery } from "@vueuse/router";
|
||||
import { inject, ref } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { GlobalInfo } from "@/modules/system/actuator/types";
|
||||
import type { GlobalInfo } from "@/types";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import axios from "axios";
|
||||
|
||||
|
|
|
@ -1034,10 +1034,19 @@ core:
|
|||
submit:
|
||||
button: Setup
|
||||
toast_success: Setup successfully
|
||||
setup_initial_data:
|
||||
loading: Initializing data, please wait...
|
||||
fields:
|
||||
site_title:
|
||||
placeholder: Site title
|
||||
validation: Please enter the site title
|
||||
label: Site title
|
||||
email:
|
||||
label: Email
|
||||
username:
|
||||
label: Username
|
||||
password:
|
||||
label: Password
|
||||
confirm_password:
|
||||
label: Confirm password
|
||||
rbac:
|
||||
Attachments Management: Attachments
|
||||
Attachment Manage: Attachment Manage
|
||||
|
|
|
@ -1034,10 +1034,19 @@ core:
|
|||
submit:
|
||||
button: 初始化
|
||||
toast_success: 初始化成功
|
||||
setup_initial_data:
|
||||
loading: 正在初始化数据,请稍后...
|
||||
fields:
|
||||
site_title:
|
||||
placeholder: 站点名称
|
||||
validation: 请输入站点名称
|
||||
label: 站点名称
|
||||
email:
|
||||
label: 邮箱
|
||||
username:
|
||||
label: 用户名
|
||||
password:
|
||||
label: 密码
|
||||
confirm_password:
|
||||
label: 确认密码
|
||||
rbac:
|
||||
Attachments Management: 附件
|
||||
Attachment Manage: 附件管理
|
||||
|
|
|
@ -1034,10 +1034,19 @@ core:
|
|||
submit:
|
||||
button: 初始化
|
||||
toast_success: 初始化成功
|
||||
setup_initial_data:
|
||||
loading: 正在初始化資料,請稍後...
|
||||
fields:
|
||||
site_title:
|
||||
placeholder: 站點名稱
|
||||
validation: 請輸入站點名稱
|
||||
label: 站點名稱
|
||||
email:
|
||||
label: 電子郵箱
|
||||
username:
|
||||
label: 用戶名
|
||||
password:
|
||||
label: 密碼
|
||||
confirm_password:
|
||||
label: 確認密碼
|
||||
rbac:
|
||||
Attachments Management: 附件
|
||||
Attachment Manage: 附件管理
|
||||
|
|
|
@ -18,10 +18,10 @@ import { hasPermission } from "@/utils/permission";
|
|||
import { useRoleStore } from "@/stores/role";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import { useThemeStore } from "./stores/theme";
|
||||
import { useSystemStatesStore } from "./stores/system-states";
|
||||
import { useUserStore } from "./stores/user";
|
||||
import { useSystemConfigMapStore } from "./stores/system-configmap";
|
||||
import { setupVueQuery } from "./setup/setupVueQuery";
|
||||
import { useGlobalInfoStore } from "./stores/global-info";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
|
@ -248,6 +248,9 @@ async function initApp() {
|
|||
i18n.global.locale.value =
|
||||
localStorage.getItem("locale") || getBrowserLanguage();
|
||||
|
||||
const globalInfoStore = useGlobalInfoStore();
|
||||
await globalInfoStore.fetchGlobalInfo();
|
||||
|
||||
if (userStore.isAnonymous) {
|
||||
return;
|
||||
}
|
||||
|
@ -260,15 +263,11 @@ async function initApp() {
|
|||
console.error("Failed to load plugins", e);
|
||||
}
|
||||
|
||||
// load system setup state
|
||||
const systemStateStore = useSystemStatesStore();
|
||||
await systemStateStore.fetchSystemStates();
|
||||
|
||||
// load system configMap
|
||||
const systemConfigMapStore = useSystemConfigMapStore();
|
||||
await systemConfigMapStore.fetchSystemConfigMap();
|
||||
|
||||
if (systemStateStore.states.isSetup) {
|
||||
if (globalInfoStore.globalInfo?.userInitialized) {
|
||||
await loadActivatedTheme();
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
VDescriptionItem,
|
||||
} from "@halo-dev/components";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import type { Info, GlobalInfo, Startup } from "./types";
|
||||
import type { Info, GlobalInfo, Startup } from "@/types";
|
||||
import axios from "axios";
|
||||
import { formatDatetime } from "@/utils/date";
|
||||
import { useClipboard } from "@vueuse/core";
|
||||
|
|
|
@ -9,10 +9,8 @@ import { useGlobalInfoFetch } from "@/composables/use-global-info";
|
|||
import { useTitle } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AppName } from "@/constants/app";
|
||||
import { locales, getBrowserLanguage, i18n } from "@/locales";
|
||||
import MdiTranslate from "~icons/mdi/translate";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import MdiKeyboardBackspace from "~icons/mdi/keyboard-backspace";
|
||||
import LocaleChange from "@/components/common/LocaleChange.vue";
|
||||
|
||||
const { globalInfo } = useGlobalInfoFetch();
|
||||
const { t } = useI18n();
|
||||
|
@ -42,22 +40,6 @@ watch(
|
|||
title.value = [routeTitle, AppName].join(" - ");
|
||||
}
|
||||
);
|
||||
|
||||
// setup locale
|
||||
const currentLocale = useLocalStorage(
|
||||
"locale",
|
||||
getBrowserLanguage() || locales[0].code
|
||||
);
|
||||
|
||||
watch(
|
||||
() => currentLocale.value,
|
||||
(value) => {
|
||||
i18n.global.locale.value = value;
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
|
||||
|
@ -101,23 +83,7 @@ watch(
|
|||
<div
|
||||
class="bottom-0 mb-10 mt-auto flex items-center justify-center gap-2.5"
|
||||
>
|
||||
<label
|
||||
for="locale"
|
||||
class="block flex-shrink-0 text-sm font-medium text-gray-600"
|
||||
>
|
||||
<MdiTranslate />
|
||||
</label>
|
||||
<select
|
||||
id="locale"
|
||||
v-model="currentLocale"
|
||||
class="block appearance-none rounded-md border-0 py-1.5 pl-3 pr-10 text-sm text-gray-800 outline-none ring-1 ring-inset ring-gray-200 focus:ring-primary"
|
||||
>
|
||||
<template v-for="locale in locales">
|
||||
<option v-if="locale.name" :key="locale.code" :value="locale.code">
|
||||
{{ locale.name }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<LocaleChange />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,22 +1,37 @@
|
|||
import { useSystemStatesStore } from "@/stores/system-states";
|
||||
import { useGlobalInfoStore } from "@/stores/global-info";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import type { Router } from "vue-router";
|
||||
|
||||
const whiteList = ["Setup", "Login", "Binding"];
|
||||
|
||||
export function setupCheckStatesGuard(router: Router) {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
if (whiteList.includes(to.name as string)) {
|
||||
next();
|
||||
const userStore = useUserStore();
|
||||
const { globalInfo } = useGlobalInfoStore();
|
||||
const { userInitialized, dataInitialized } = globalInfo || {};
|
||||
|
||||
if (to.name === "Setup" && userInitialized) {
|
||||
next({ name: "Dashboard" });
|
||||
return;
|
||||
}
|
||||
|
||||
const systemStateStore = useSystemStatesStore();
|
||||
if (to.name === "SetupInitialData" && dataInitialized) {
|
||||
next({ name: "Dashboard" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!systemStateStore.states.isSetup) {
|
||||
if (userInitialized === false && to.name !== "Setup") {
|
||||
next({ name: "Setup" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
dataInitialized === false &&
|
||||
!userStore.isAnonymous &&
|
||||
to.name !== "SetupInitialData"
|
||||
) {
|
||||
next({ name: "SetupInitialData" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -22,8 +22,8 @@ const router = createRouter({
|
|||
},
|
||||
});
|
||||
|
||||
setupCheckStatesGuard(router);
|
||||
setupAuthCheckGuard(router);
|
||||
setupPermissionGuard(router);
|
||||
setupCheckStatesGuard(router);
|
||||
|
||||
export default router;
|
||||
|
|
|
@ -5,6 +5,7 @@ import BasicLayout from "@/layouts/BasicLayout.vue";
|
|||
import Setup from "@/views/system/Setup.vue";
|
||||
import Redirect from "@/views/system/Redirect.vue";
|
||||
import type { MenuGroupType } from "@halo-dev/console-shared";
|
||||
import SetupInitialData from "@/views/system/SetupInitialData.vue";
|
||||
|
||||
export const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
|
@ -31,6 +32,14 @@ export const routes: Array<RouteRecordRaw> = [
|
|||
title: "core.setup.title",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/setup-initial-data",
|
||||
name: "SetupInitialData",
|
||||
component: SetupInitialData,
|
||||
meta: {
|
||||
title: "core.setup.title",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/redirect",
|
||||
name: "Redirect",
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { defineStore } from "pinia";
|
||||
import type { GlobalInfo } from "@/types";
|
||||
import { ref } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
export const useGlobalInfoStore = defineStore("global-info", () => {
|
||||
const globalInfo = ref<GlobalInfo>();
|
||||
|
||||
async function fetchGlobalInfo() {
|
||||
const { data } = await axios.get<GlobalInfo>(
|
||||
`${import.meta.env.VITE_API_URL}/actuator/globalinfo`,
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
globalInfo.value = data;
|
||||
}
|
||||
|
||||
return { globalInfo, fetchGlobalInfo };
|
||||
});
|
|
@ -1,40 +0,0 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
||||
interface SystemState {
|
||||
isSetup: boolean;
|
||||
}
|
||||
|
||||
interface SystemStatesState {
|
||||
states: SystemState;
|
||||
}
|
||||
|
||||
export const useSystemStatesStore = defineStore({
|
||||
id: "system-states",
|
||||
state: (): SystemStatesState => ({
|
||||
states: {
|
||||
isSetup: false,
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
async fetchSystemStates() {
|
||||
try {
|
||||
const { data } =
|
||||
await apiClient.extension.configMap.getv1alpha1ConfigMap(
|
||||
{
|
||||
name: "system-states",
|
||||
},
|
||||
{ mute: true }
|
||||
);
|
||||
|
||||
if (data.data) {
|
||||
this.states = JSON.parse(data.data["states"]);
|
||||
return;
|
||||
}
|
||||
this.states.isSetup = false;
|
||||
} catch (error) {
|
||||
this.states.isSetup = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
|
@ -7,6 +7,9 @@ export interface GlobalInfo {
|
|||
allowRegistration: boolean;
|
||||
socialAuthProviders: SocialAuthProvider[];
|
||||
useAbsolutePermalink: boolean;
|
||||
userInitialized: boolean;
|
||||
dataInitialized: boolean;
|
||||
favicon?: string;
|
||||
}
|
||||
|
||||
export interface Info {
|
|
@ -0,0 +1 @@
|
|||
export * from "./actuator";
|
|
@ -10,6 +10,7 @@ import {
|
|||
ApiConsoleHaloRunV1alpha1AttachmentApi,
|
||||
ApiConsoleHaloRunV1alpha1IndicesApi,
|
||||
ApiConsoleHaloRunV1alpha1AuthProviderApi,
|
||||
ApiConsoleHaloRunV1alpha1SystemApi,
|
||||
ContentHaloRunV1alpha1CategoryApi,
|
||||
ContentHaloRunV1alpha1CommentApi,
|
||||
ContentHaloRunV1alpha1PostApi,
|
||||
|
@ -218,6 +219,7 @@ function setupApiClient(axios: AxiosInstance) {
|
|||
baseURL,
|
||||
axios
|
||||
),
|
||||
system: new ApiConsoleHaloRunV1alpha1SystemApi(undefined, baseURL, axios),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,201 +1,119 @@
|
|||
<script lang="ts" setup>
|
||||
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
|
||||
import { useSettingForm } from "@/composables/use-setting-form";
|
||||
import { useSystemStatesStore } from "@/stores/system-states";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { Toast, VButton } from "@halo-dev/components";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import category from "./setup-data/category.json";
|
||||
import tag from "./setup-data/tag.json";
|
||||
import post from "./setup-data/post.json";
|
||||
import singlePage from "./setup-data/singlePage.json";
|
||||
import menu from "./setup-data/menu.json";
|
||||
import menuItems from "./setup-data/menu-items.json";
|
||||
import type {
|
||||
Category,
|
||||
Plugin,
|
||||
PostRequest,
|
||||
SinglePageRequest,
|
||||
Tag,
|
||||
} from "@halo-dev/api-client";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import type { SystemInitializationRequest } from "@halo-dev/api-client";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useGlobalInfoStore } from "@/stores/global-info";
|
||||
import LocaleChange from "@/components/common/LocaleChange.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const {
|
||||
configMapFormData,
|
||||
handleSaveConfigMap,
|
||||
handleFetchSettings,
|
||||
handleFetchConfigMap,
|
||||
} = useSettingForm(ref("system"), ref("system"));
|
||||
|
||||
const siteTitle = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
const { mutate: pluginInstallMutate } = useMutation({
|
||||
mutationKey: ["plugin-install"],
|
||||
mutationFn: async (plugin: Plugin) => {
|
||||
const { data } = await apiClient.plugin.installPlugin(
|
||||
{
|
||||
source: "PRESET",
|
||||
presetName: plugin.metadata.name as string,
|
||||
},
|
||||
{
|
||||
mute: true,
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
retry: 3,
|
||||
retryDelay: 1000,
|
||||
onSuccess(data) {
|
||||
pluginStartMutate(data);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: pluginStartMutate } = useMutation({
|
||||
mutationKey: ["plugin-start"],
|
||||
mutationFn: async (plugin: Plugin) => {
|
||||
const { data: pluginToUpdate } =
|
||||
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
|
||||
name: plugin.metadata.name,
|
||||
});
|
||||
|
||||
pluginToUpdate.spec.enabled = true;
|
||||
|
||||
return apiClient.extension.plugin.updatepluginHaloRunV1alpha1Plugin(
|
||||
{
|
||||
name: plugin.metadata.name,
|
||||
plugin: pluginToUpdate,
|
||||
},
|
||||
{
|
||||
mute: true,
|
||||
}
|
||||
);
|
||||
},
|
||||
retry: 3,
|
||||
retryDelay: 1000,
|
||||
const formState = ref<SystemInitializationRequest>({
|
||||
siteTitle: "",
|
||||
username: "",
|
||||
password: "",
|
||||
email: "",
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
loading.value = true;
|
||||
|
||||
// Set site title
|
||||
if (configMapFormData.value) {
|
||||
configMapFormData.value["basic"].title = siteTitle.value;
|
||||
await handleSaveConfigMap();
|
||||
}
|
||||
|
||||
// Create category / tag / post
|
||||
await apiClient.extension.category.createcontentHaloRunV1alpha1Category({
|
||||
category: category as Category,
|
||||
});
|
||||
await apiClient.extension.tag.createcontentHaloRunV1alpha1Tag({
|
||||
tag: tag as Tag,
|
||||
});
|
||||
const { data: postData } = await apiClient.post.draftPost({
|
||||
postRequest: post as PostRequest,
|
||||
});
|
||||
await apiClient.post.publishPost({ name: postData.metadata.name });
|
||||
|
||||
// Create singlePage
|
||||
const { data: singlePageData } = await apiClient.singlePage.draftSinglePage(
|
||||
{
|
||||
singlePageRequest: singlePage as SinglePageRequest,
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.singlePage.publishSinglePage({
|
||||
name: singlePageData.metadata.name,
|
||||
});
|
||||
|
||||
// Create menu and menu items
|
||||
const menuItemPromises = menuItems.map((item) => {
|
||||
return apiClient.extension.menuItem.createv1alpha1MenuItem({
|
||||
menuItem: item,
|
||||
});
|
||||
});
|
||||
await Promise.all(menuItemPromises);
|
||||
await apiClient.extension.menu.createv1alpha1Menu({ menu: menu });
|
||||
|
||||
// Install preset plugins
|
||||
const { data: presetPlugins } = await apiClient.plugin.listPluginPresets();
|
||||
|
||||
for (let i = 0; i < presetPlugins.length; i++) {
|
||||
pluginInstallMutate(presetPlugins[i]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize preset data", error);
|
||||
}
|
||||
|
||||
// Create system-states ConfigMap
|
||||
await apiClient.extension.configMap.createv1alpha1ConfigMap({
|
||||
configMap: {
|
||||
metadata: {
|
||||
name: "system-states",
|
||||
},
|
||||
kind: "ConfigMap",
|
||||
apiVersion: "v1alpha1",
|
||||
data: {
|
||||
states: JSON.stringify({ isSetup: true }),
|
||||
},
|
||||
},
|
||||
await apiClient.system.initialize({
|
||||
systemInitializationRequest: formState.value,
|
||||
});
|
||||
|
||||
const systemStateStore = useSystemStatesStore();
|
||||
await systemStateStore.fetchSystemStates();
|
||||
const themeStore = useThemeStore();
|
||||
await themeStore.fetchActivatedTheme();
|
||||
const globalInfoStore = useGlobalInfoStore();
|
||||
await globalInfoStore.fetchGlobalInfo();
|
||||
|
||||
loading.value = false;
|
||||
|
||||
router.push({ name: "Dashboard" });
|
||||
router.push({ name: "Login" });
|
||||
|
||||
Toast.success(t("core.setup.operations.submit.toast_success"));
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const systemStatesStore = useSystemStatesStore();
|
||||
|
||||
if (systemStatesStore.states.isSetup) {
|
||||
router.push({ name: "Dashboard" });
|
||||
return;
|
||||
}
|
||||
|
||||
handleFetchSettings();
|
||||
handleFetchConfigMap();
|
||||
});
|
||||
const inputClasses = {
|
||||
outer: "!py-3 first:!pt-0 last:!pb-0",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col items-center justify-center">
|
||||
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
|
||||
<IconLogo class="mb-8" />
|
||||
<div class="flex w-72 flex-col gap-4">
|
||||
<div class="flex w-72 flex-col">
|
||||
<FormKit
|
||||
id="setup-form"
|
||||
v-model="formState"
|
||||
name="setup-form"
|
||||
:actions="false"
|
||||
:classes="{
|
||||
form: '!divide-none',
|
||||
}"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
type="form"
|
||||
@submit="handleSubmit"
|
||||
@keyup.enter="$formkit.submit('setup-form')"
|
||||
>
|
||||
<FormKit
|
||||
v-model="siteTitle"
|
||||
:validation-messages="{
|
||||
required: $t('core.setup.fields.site_title.validation'),
|
||||
}"
|
||||
name="siteTitle"
|
||||
:classes="inputClasses"
|
||||
:autofocus="true"
|
||||
type="text"
|
||||
:placeholder="$t('core.setup.fields.site_title.placeholder')"
|
||||
validation="required|length:0,100"
|
||||
:validation-label="$t('core.setup.fields.site_title.label')"
|
||||
:placeholder="$t('core.setup.fields.site_title.label')"
|
||||
validation="required:trim|length:0,100"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
name="email"
|
||||
:classes="inputClasses"
|
||||
type="text"
|
||||
:validation-label="$t('core.setup.fields.email.label')"
|
||||
:placeholder="$t('core.setup.fields.email.label')"
|
||||
validation="required|email|length:0,100"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
name="username"
|
||||
:classes="inputClasses"
|
||||
type="text"
|
||||
:validation-label="$t('core.setup.fields.username.label')"
|
||||
:placeholder="$t('core.setup.fields.username.label')"
|
||||
:validation="[
|
||||
['required'],
|
||||
['length:0,63'],
|
||||
[
|
||||
'matches',
|
||||
/^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/,
|
||||
],
|
||||
]"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
name="password"
|
||||
:classes="inputClasses"
|
||||
type="password"
|
||||
:validation-label="$t('core.setup.fields.password.label')"
|
||||
:placeholder="$t('core.setup.fields.password.label')"
|
||||
validation="required:trim|length:5,100|matches:/^\S.*\S$/"
|
||||
autocomplete="current-password"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
name="password_confirm"
|
||||
:classes="inputClasses"
|
||||
type="password"
|
||||
:validation-label="$t('core.setup.fields.confirm_password.label')"
|
||||
:placeholder="$t('core.setup.fields.confirm_password.label')"
|
||||
validation="confirm|required:trim|length:5,100|matches:/^\S.*\S$/"
|
||||
autocomplete="current-password"
|
||||
></FormKit>
|
||||
</FormKit>
|
||||
<VButton
|
||||
block
|
||||
class="mt-8"
|
||||
type="secondary"
|
||||
:loading="loading"
|
||||
@click="$formkit.submit('setup-form')"
|
||||
|
@ -203,5 +121,10 @@ onMounted(async () => {
|
|||
{{ $t("core.setup.operations.submit.button") }}
|
||||
</VButton>
|
||||
</div>
|
||||
<div
|
||||
class="bottom-0 mb-10 mt-auto flex items-center justify-center gap-2.5"
|
||||
>
|
||||
<LocaleChange />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,165 @@
|
|||
<script lang="ts" setup>
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { VLoading } from "@halo-dev/components";
|
||||
import { useMutation } from "@tanstack/vue-query";
|
||||
import type {
|
||||
Category,
|
||||
Plugin,
|
||||
PostRequest,
|
||||
SinglePageRequest,
|
||||
Tag,
|
||||
} from "@halo-dev/api-client";
|
||||
import { onMounted } from "vue";
|
||||
import category from "./setup-data/category.json";
|
||||
import tag from "./setup-data/tag.json";
|
||||
import post from "./setup-data/post.json";
|
||||
import singlePage from "./setup-data/singlePage.json";
|
||||
import menu from "./setup-data/menu.json";
|
||||
import menuItems from "./setup-data/menu-items.json";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useGlobalInfoStore } from "@/stores/global-info";
|
||||
import { ref } from "vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
const router = useRouter();
|
||||
const globalInfoStore = useGlobalInfoStore();
|
||||
|
||||
const { mutate: pluginInstallMutate } = useMutation({
|
||||
mutationKey: ["plugin-install"],
|
||||
mutationFn: async (plugin: Plugin) => {
|
||||
const { data } = await apiClient.plugin.installPlugin(
|
||||
{
|
||||
source: "PRESET",
|
||||
presetName: plugin.metadata.name as string,
|
||||
},
|
||||
{
|
||||
mute: true,
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
retry: 3,
|
||||
retryDelay: 1000,
|
||||
onSuccess(data) {
|
||||
pluginStartMutate(data);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: pluginStartMutate } = useMutation({
|
||||
mutationKey: ["plugin-start"],
|
||||
mutationFn: async (plugin: Plugin) => {
|
||||
const { data: pluginToUpdate } =
|
||||
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
|
||||
name: plugin.metadata.name,
|
||||
});
|
||||
|
||||
pluginToUpdate.spec.enabled = true;
|
||||
|
||||
return apiClient.extension.plugin.updatepluginHaloRunV1alpha1Plugin(
|
||||
{
|
||||
name: plugin.metadata.name,
|
||||
plugin: pluginToUpdate,
|
||||
},
|
||||
{
|
||||
mute: true,
|
||||
}
|
||||
);
|
||||
},
|
||||
retry: 3,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
const processing = ref(false);
|
||||
|
||||
async function setupInitialData() {
|
||||
try {
|
||||
processing.value = true;
|
||||
|
||||
// Create category / tag / post
|
||||
await apiClient.extension.category.createcontentHaloRunV1alpha1Category({
|
||||
category: category as Category,
|
||||
});
|
||||
await apiClient.extension.tag.createcontentHaloRunV1alpha1Tag({
|
||||
tag: tag as Tag,
|
||||
});
|
||||
const { data: postData } = await apiClient.post.draftPost({
|
||||
postRequest: post as PostRequest,
|
||||
});
|
||||
await apiClient.post.publishPost({ name: postData.metadata.name });
|
||||
|
||||
// Create singlePage
|
||||
const { data: singlePageData } = await apiClient.singlePage.draftSinglePage(
|
||||
{
|
||||
singlePageRequest: singlePage as SinglePageRequest,
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.singlePage.publishSinglePage({
|
||||
name: singlePageData.metadata.name,
|
||||
});
|
||||
|
||||
// Create menu and menu items
|
||||
const menuItemPromises = menuItems.map((item) => {
|
||||
return apiClient.extension.menuItem.createv1alpha1MenuItem({
|
||||
menuItem: item,
|
||||
});
|
||||
});
|
||||
await Promise.all(menuItemPromises);
|
||||
await apiClient.extension.menu.createv1alpha1Menu({ menu: menu });
|
||||
|
||||
// Install preset plugins
|
||||
const { data: presetPlugins } = await apiClient.plugin.listPluginPresets();
|
||||
|
||||
for (let i = 0; i < presetPlugins.length; i++) {
|
||||
pluginInstallMutate(presetPlugins[i]);
|
||||
}
|
||||
|
||||
await apiClient.extension.configMap.createv1alpha1ConfigMap({
|
||||
configMap: {
|
||||
metadata: {
|
||||
name: "system-states",
|
||||
},
|
||||
kind: "ConfigMap",
|
||||
apiVersion: "v1alpha1",
|
||||
data: {
|
||||
states: JSON.stringify({ isSetup: true }),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
|
||||
await globalInfoStore.fetchGlobalInfo();
|
||||
|
||||
router.push({ name: "Dashboard" });
|
||||
}
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
onMounted(async () => {
|
||||
await globalInfoStore.fetchGlobalInfo();
|
||||
|
||||
if (
|
||||
globalInfoStore.globalInfo &&
|
||||
globalInfoStore.globalInfo.dataInitialized === false &&
|
||||
!userStore.isAnonymous
|
||||
) {
|
||||
setupInitialData();
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({ name: "Dashboard" });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen flex-col items-center justify-center">
|
||||
<VLoading />
|
||||
<div v-if="processing" class="text-xs text-gray-600">
|
||||
{{ $t("core.setup.operations.setup_initial_data.loading") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/**/*.json"],
|
||||
"exclude": ["src/**/__tests__/*", "packages/**/*"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
|
|
Loading…
Reference in New Issue