Unverified Commit cf750541 authored by boojack's avatar boojack Committed by GitHub

feat: add system setting to allow user signup (#407)

parent 4ed98722
......@@ -5,4 +5,6 @@ import "github.com/usememos/memos/server/profile"
type SystemStatus struct {
Host *User `json:"host"`
Profile *profile.Profile `json:"profile"`
// System settings
AllowSignUp bool `json:"allowSignUp"`
}
package api
import (
"encoding/json"
"fmt"
)
type SystemSettingName string
const (
// SystemSettingAllowSignUpName is the key type of allow signup setting.
SystemSettingAllowSignUpName SystemSettingName = "allowSignUp"
SystemSettingPlaceholderName SystemSettingName = "placeholder"
)
func (key SystemSettingName) String() string {
switch key {
case SystemSettingAllowSignUpName:
return "allowSignUp"
case SystemSettingPlaceholderName:
return "placeholder"
}
return ""
}
var (
SystemSettingAllowSignUpValue = []bool{true, false}
)
type SystemSetting struct {
Name SystemSettingName
// Value is a JSON string with basic value
Value string
Description string
}
type SystemSettingUpsert struct {
Name SystemSettingName `json:"name"`
Value string `json:"value"`
Description string `json:"description"`
}
func (upsert SystemSettingUpsert) Validate() error {
if upsert.Name == SystemSettingAllowSignUpName {
value := false
err := json.Unmarshal([]byte(upsert.Value), &value)
if err != nil {
return fmt.Errorf("failed to unmarshal system setting allow signup value")
}
invalid := true
for _, v := range SystemSettingAllowSignUpValue {
if value == v {
invalid = false
break
}
}
if invalid {
return fmt.Errorf("invalid system setting allow signup value")
}
} else {
return fmt.Errorf("invalid system setting name")
}
return nil
}
type SystemSettingFind struct {
Name *SystemSettingName `json:"name"`
}
......@@ -45,7 +45,7 @@ func run(profile *profile.Profile) error {
println(greetingBanner)
fmt.Printf("Version %s has started at :%d\n", profile.Version, profile.Port)
metricCollector.Collect(ctx, &metric.Metric{
Name: "servive started",
Name: "service started",
})
return serverInstance.Run()
......
......@@ -8,7 +8,9 @@ import (
metric "github.com/usememos/memos/plugin/metrics"
)
var _ metric.Collector = (*collector)(nil)
var (
sessionUUID = uuid.NewString()
)
// collector is the metrics collector https://segment.com/.
type collector struct {
......@@ -33,7 +35,7 @@ func (c *collector) Collect(metric *metric.Metric) error {
return c.client.Enqueue(analytics.Track{
Event: string(metric.Name),
AnonymousId: uuid.NewString(),
AnonymousId: sessionUUID,
Properties: properties,
Timestamp: time.Now().UTC(),
})
......
......@@ -70,7 +70,11 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
g.POST("/auth/signup", func(c echo.Context) error {
ctx := c.Request().Context()
// Don't allow to signup by this api if site host existed.
signup := &api.Signup{}
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
}
hostUserType := api.Host
hostUserFind := api.UserFind{
Role: &hostUserType,
......@@ -79,13 +83,27 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find host user").SetInternal(err)
}
if hostUser != nil {
if signup.Role == api.Host && hostUser != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
}
signup := &api.Signup{}
if err := json.NewDecoder(c.Request().Body).Decode(signup); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signup request").SetInternal(err)
systemSettingAllowSignUpName := api.SystemSettingAllowSignUpName
allowSignUpSetting, err := s.Store.FindSystemSetting(ctx, &api.SystemSettingFind{
Name: &systemSettingAllowSignUpName,
})
if err != nil && common.ErrorCode(err) != common.NotFound {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting").SetInternal(err)
}
allowSignUpSettingValue := false
if allowSignUpSetting != nil {
err = json.Unmarshal([]byte(allowSignUpSetting.Value), &allowSignUpSettingValue)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting allow signup").SetInternal(err)
}
}
if !allowSignUpSettingValue && hostUser != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Site Host existed, please contact the site host to signin account firstly.").SetInternal(err)
}
userCreate := &api.UserCreate{
......
......@@ -38,8 +38,25 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
}
systemStatus := api.SystemStatus{
Host: hostUser,
Profile: s.Profile,
Host: hostUser,
Profile: s.Profile,
AllowSignUp: false,
}
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
}
for _, systemSetting := range systemSettingList {
var value interface{}
err = json.Unmarshal([]byte(systemSetting.Value), &value)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unmarshal system setting").SetInternal(err)
}
if systemSetting.Name == api.SystemSettingAllowSignUpName {
systemStatus.AllowSignUp = value.(bool)
}
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
......@@ -48,4 +65,57 @@ func (s *Server) registerSystemRoutes(g *echo.Group) {
}
return nil
})
g.POST("/system/setting", func(c echo.Context) error {
ctx := c.Request().Context()
userID, ok := c.Get(getUserIDContextKey()).(int)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
}
user, err := s.Store.FindUser(ctx, &api.UserFind{
ID: &userID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "Current signin user not found")
} else if user.Role != api.Host {
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
}
systemSettingUpsert := &api.SystemSettingUpsert{}
if err := json.NewDecoder(c.Request().Body).Decode(systemSettingUpsert); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post system setting request").SetInternal(err)
}
if err := systemSettingUpsert.Validate(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "system setting invalidate").SetInternal(err)
}
systemSetting, err := s.Store.UpsertSystemSetting(ctx, systemSettingUpsert)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert system setting").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(systemSetting)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode system setting response").SetInternal(err)
}
return nil
})
g.GET("/system/setting", func(c echo.Context) error {
ctx := c.Request().Context()
systemSettingList, err := s.Store.FindSystemSettingList(ctx, &api.SystemSettingFind{})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find system setting list").SetInternal(err)
}
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(systemSettingList)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode system setting list response").SetInternal(err)
}
return nil
})
}
INSERT INTO
system_setting (
`name`,
`value`,
`description`
)
VALUES
(
'allowSignUp',
'true',
''
);
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/usememos/memos/api"
"github.com/usememos/memos/common"
)
type systemSettingRaw struct {
Name api.SystemSettingName
Value string
Description string
}
func (raw *systemSettingRaw) toSystemSetting() *api.SystemSetting {
return &api.SystemSetting{
Name: raw.Name,
Value: raw.Value,
Description: raw.Description,
}
}
func (s *Store) UpsertSystemSetting(ctx context.Context, upsert *api.SystemSettingUpsert) (*api.SystemSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
systemSettingRaw, err := upsertSystemSetting(ctx, tx, upsert)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
systemSetting := systemSettingRaw.toSystemSetting()
return systemSetting, nil
}
func (s *Store) FindSystemSettingList(ctx context.Context, find *api.SystemSettingFind) ([]*api.SystemSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
systemSettingRawList, err := findSystemSettingList(ctx, tx, find)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
list := []*api.SystemSetting{}
for _, raw := range systemSettingRawList {
list = append(list, raw.toSystemSetting())
}
return list, nil
}
func (s *Store) FindSystemSetting(ctx context.Context, find *api.SystemSettingFind) (*api.SystemSetting, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, FormatError(err)
}
defer tx.Rollback()
systemSettingRawList, err := findSystemSettingList(ctx, tx, find)
if err != nil {
return nil, err
}
if len(systemSettingRawList) == 0 {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found")}
}
return systemSettingRawList[0].toSystemSetting(), nil
}
func upsertSystemSetting(ctx context.Context, tx *sql.Tx, upsert *api.SystemSettingUpsert) (*systemSettingRaw, error) {
query := `
INSERT INTO system_setting (
name, value, description
)
VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE
SET
value = EXCLUDED.value,
description = EXCLUDED.description
RETURNING name, value, description
`
var systemSettingRaw systemSettingRaw
if err := tx.QueryRowContext(ctx, query, upsert.Name, upsert.Value, upsert.Description).Scan(
&systemSettingRaw.Name,
&systemSettingRaw.Value,
&systemSettingRaw.Description,
); err != nil {
return nil, FormatError(err)
}
return &systemSettingRaw, nil
}
func findSystemSettingList(ctx context.Context, tx *sql.Tx, find *api.SystemSettingFind) ([]*systemSettingRaw, error) {
where, args := []string{"1 = 1"}, []interface{}{}
if v := find.Name; v != nil {
where, args = append(where, "name = ?"), append(args, v.String())
}
query := `
SELECT
name,
value,
description
FROM system_setting
WHERE ` + strings.Join(where, " AND ")
rows, err := tx.QueryContext(ctx, query, args...)
if err != nil {
return nil, FormatError(err)
}
defer rows.Close()
systemSettingRawList := make([]*systemSettingRaw, 0)
for rows.Next() {
var systemSettingRaw systemSettingRaw
if err := rows.Scan(
&systemSettingRaw.Name,
&systemSettingRaw.Value,
&systemSettingRaw.Description,
); err != nil {
return nil, FormatError(err)
}
systemSettingRawList = append(systemSettingRawList, &systemSettingRaw)
}
if err := rows.Err(); err != nil {
return nil, FormatError(err)
}
return systemSettingRawList, nil
}
......@@ -155,8 +155,6 @@ func (s *Store) FindUser(ctx context.Context, find *api.UserFind) (*api.User, er
if len(list) == 0 {
return nil, &common.Error{Code: common.NotFound, Err: fmt.Errorf("not found user with filter %+v", find)}
} else if len(list) > 1 {
return nil, &common.Error{Code: common.Conflict, Err: fmt.Errorf("found %d users with filter %+v, expect 1", len(list), find)}
}
userRaw := list[0]
......
......@@ -8,6 +8,9 @@
"test": "jest --passWithNoTests"
},
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@mui/joy": "^5.0.0-alpha.52",
"@reduxjs/toolkit": "^1.8.1",
"axios": "^0.27.2",
"copy-to-clipboard": "^3.3.2",
......
import { CssVarsProvider } from "@mui/joy/styles";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { RouterProvider } from "react-router-dom";
......@@ -26,7 +27,11 @@ function App() {
});
}, [global.locale]);
return <RouterProvider router={router} />;
return (
<CssVarsProvider>
<RouterProvider router={router} />
</CssVarsProvider>
);
}
export default App;
......@@ -6,11 +6,12 @@ import { generateDialog } from "./Dialog";
import MyAccountSection from "./Settings/MyAccountSection";
import PreferencesSection from "./Settings/PreferencesSection";
import MemberSection from "./Settings/MemberSection";
import SystemSection from "./Settings/SystemSection";
import "../less/setting-dialog.less";
type Props = DialogProps;
type SettingSection = "my-account" | "preferences" | "member";
type SettingSection = "my-account" | "preferences" | "member" | "system";
interface State {
selectedSection: SettingSection;
......@@ -61,6 +62,12 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
>
<span className="icon-text">👤</span> {t("setting.member")}
</span>
<span
onClick={() => handleSectionSelectorItemClick("system")}
className={`section-item ${state.selectedSection === "system" ? "selected" : ""}`}
>
<span className="icon-text">🧑‍🔧</span> System Setting
</span>
</div>
</>
) : null}
......@@ -72,6 +79,8 @@ const SettingDialog: React.FC<Props> = (props: Props) => {
<PreferencesSection />
) : state.selectedSection === "member" ? (
<MemberSection />
) : state.selectedSection === "system" ? (
<SystemSection />
) : null}
</div>
</div>
......
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Switch from "@mui/joy/Switch";
import * as api from "../../helpers/api";
import { globalService, userService } from "../../services";
import Selector from "../common/Selector";
import "../../less/settings/preferences-section.less";
const localeSelectorItems = [
{
text: "English",
value: "en",
},
{
text: "中文",
value: "zh",
},
{
text: "Tiếng Việt",
value: "vi",
},
];
interface State {
allowSignUp: boolean;
}
const SystemSection = () => {
const { t } = useTranslation();
const [state, setState] = useState<State>({
allowSignUp: false,
});
useEffect(() => {
api.getSystemStatus().then(({ data }) => {
const { data: status } = data;
setState({
allowSignUp: status.allowSignUp,
});
});
}, []);
const handleAllowSignUpChanged = async (value: boolean) => {
setState({
...state,
allowSignUp: value,
});
await api.upsertSystemSetting({
name: "allowSignUp",
value: JSON.stringify(value),
});
};
return (
<div className="section-container preferences-section-container">
<p className="title-text">{t("common.basic")}</p>
<label className="form-label selector">
<span className="normal-text">Allow user signUp</span>
<Switch size="sm" checked={state.allowSignUp} onChange={(event) => handleAllowSignUpChanged(event.target.checked)} />
</label>
</div>
);
};
export default SystemSection;
......@@ -10,6 +10,10 @@ export function getSystemStatus() {
return axios.get<ResponseObject<SystemStatus>>("/api/status");
}
export function upsertSystemSetting(systemSetting: SystemSetting) {
return axios.post<ResponseObject<SystemSetting>>("/api/system/setting", systemSetting);
}
export function signin(email: string, password: string) {
return axios.post<ResponseObject<User>>("/api/auth/signin", {
email,
......
......@@ -59,6 +59,10 @@
> .btn {
@apply flex flex-row justify-center items-center px-1 py-2 text-sm rounded hover:opacity-80;
&.signup-btn {
@apply px-3;
}
&.signin-btn {
@apply bg-green-600 text-white px-3 shadow;
}
......
......@@ -28,6 +28,7 @@
"admin": "Admin",
"explore": "Explore",
"sign-in": "Sign in",
"sign-up": "Sign up",
"sign-out": "Sign out",
"back-to-home": "Back to Home",
"type": "Type",
......
......@@ -28,6 +28,7 @@
"admin": "Admin",
"explore": "Khám phá",
"sign-in": "Đăng nhập",
"sign-up": "Sign up",
"sign-out": "Đăng xuất",
"back-to-home": "Về trang chủ",
"type": "Kiểu",
......@@ -173,4 +174,4 @@
"resource-filename-updated": "Tên tệp tài nguyên đã thay đổi.",
"invalid-resource-filename": "Tên tệp không hợp lệ."
}
}
\ No newline at end of file
}
......@@ -28,6 +28,7 @@
"admin": "管理员",
"explore": "探索",
"sign-in": "登录",
"sign-up": "注册",
"sign-out": "退出登录",
"back-to-home": "回到主页",
"type": "类型",
......
......@@ -5,7 +5,6 @@ import * as api from "../helpers/api";
import { validate, ValidatorConfig } from "../helpers/validator";
import useLoading from "../hooks/useLoading";
import { globalService, userService } from "../services";
import Icon from "../components/Icon";
import toastHelper from "../components/Toast";
import "../less/auth.less";
......@@ -20,7 +19,7 @@ const Auth = () => {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const pageLoadingState = useLoading(true);
const [siteHost, setSiteHost] = useState<User>();
const [systemStatus, setSystemStatus] = useState<SystemStatus>();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const actionBtnLoadingState = useLoading(false);
......@@ -28,7 +27,7 @@ const Auth = () => {
useEffect(() => {
api.getSystemStatus().then(({ data }) => {
const { data: status } = data;
setSiteHost(status.host);
setSystemStatus(status);
if (status.profile.mode === "dev") {
setEmail("demo@usememos.com");
setPassword("secret");
......@@ -47,9 +46,7 @@ const Auth = () => {
setPassword(text);
};
const handleSigninBtnsClick = async (e: React.FormEvent<EventTarget>) => {
e.preventDefault();
const handleSigninBtnsClick = async () => {
if (actionBtnLoadingState.isLoading) {
return;
}
......@@ -77,14 +74,12 @@ const Auth = () => {
}
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
toastHelper.error(error.response.data.error);
}
actionBtnLoadingState.setFinish();
};
const handleSignUpAsHostBtnsClick = async (e: React.FormEvent<EventTarget>) => {
e.preventDefault();
const handleSignUpBtnsClick = async (role: UserRole) => {
if (actionBtnLoadingState.isLoading) {
return;
}
......@@ -103,7 +98,7 @@ const Auth = () => {
try {
actionBtnLoadingState.setLoading();
await api.signup(email, password, "HOST");
await api.signup(email, password, role);
const user = await userService.doSignIn();
if (user) {
navigate("/");
......@@ -112,7 +107,7 @@ const Auth = () => {
}
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
toastHelper.error(error.response.data.error);
}
actionBtnLoadingState.setFinish();
};
......@@ -124,7 +119,7 @@ const Auth = () => {
return (
<div className="page-wrapper auth">
<div className="page-container">
<form className="auth-form-wrapper" onSubmit={(e) => (siteHost ? handleSigninBtnsClick(e) : handleSignUpAsHostBtnsClick(e))}>
<div className="auth-form-wrapper">
<div className="page-header-container">
<div className="title-container">
<img className="logo-img" src="/logo-full.webp" alt="" />
......@@ -143,16 +138,39 @@ const Auth = () => {
</div>
<div className="action-btns-container">
{!pageLoadingState.isLoading && (
<button className={`btn signin-btn ${actionBtnLoadingState.isLoading ? "requesting" : ""}`} type="submit">
{actionBtnLoadingState.isLoading && <Icon.Loader className="img-icon" />}
{siteHost ? t("common.sign-in") : t("auth.signup-as-host")}
</button>
<>
{systemStatus?.host ? (
<>
{systemStatus?.allowSignUp && (
<button
className={`btn signup-btn ${actionBtnLoadingState.isLoading ? "requesting" : ""}`}
onClick={() => handleSignUpBtnsClick("USER")}
>
{t("common.sign-up")}
</button>
)}
<button
className={`btn signin-btn ${actionBtnLoadingState.isLoading ? "requesting" : ""}`}
onClick={handleSigninBtnsClick}
>
{t("common.sign-in")}
</button>
</>
) : (
<>
<button
className={`btn signin-btn ${actionBtnLoadingState.isLoading ? "requesting" : ""}`}
onClick={() => handleSignUpBtnsClick("HOST")}
>
{t("auth.signup-as-host")}
</button>
</>
)}
</>
)}
</div>
<p className={`tip-text ${siteHost || pageLoadingState.isLoading ? "" : "host-tip"}`}>
{siteHost || pageLoadingState.isLoading ? t("auth.not-host-tip") : t("auth.host-tip")}
</p>
</form>
{!systemStatus?.host && <p className="tip-text">{t("auth.host-tip")}</p>}
</div>
<div className="footer-container">
<div className="language-container">
<span className={`locale-item ${i18n.language === "en" ? "active" : ""}`} onClick={() => handleLocaleItemClick("en")}>
......
......@@ -6,4 +6,11 @@ interface Profile {
interface SystemStatus {
host: User;
profile: Profile;
// System settings
allowSignUp: boolean;
}
interface SystemSetting {
name: string;
value: string;
}
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment