From 6f854ebbf7141bc921cc777293e0ebaa00979bf5 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:35:00 +0700 Subject: [PATCH 01/53] fix(auth): log and surface oauth error parameters on oidc callback --- internal/handlers/api/auth_handler.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go index 92073151..4acd476b 100644 --- a/internal/handlers/api/auth_handler.go +++ b/internal/handlers/api/auth_handler.go @@ -7,6 +7,7 @@ import ( "agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/httpx/params" "agent-desk/internal/services" + "log/slog" "net/http" "net/url" "strings" @@ -97,16 +98,34 @@ func OIDCLogin(ctx *gin.Context) { } func OIDCCallback(ctx *gin.Context) { + if oauthErr := ctx.Query("error"); oauthErr != "" { + desc := ctx.Query("error_description") + slog.Warn("oidc callback returned oauth error", "error", oauthErr, "description", desc) + errMsg := oauthErr + if desc != "" { + errMsg += ": " + desc + } + ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(errMsg)) + return + } + + code := ctx.Query("code") + state := ctx.Query("state") + if strings.TrimSpace(code) == "" { + slog.Warn("oidc callback missing code", "rawQuery", ctx.Request.URL.RawQuery) + } + cfg := config.Current() ticket, next, err := services.OIDCLoginService.LoginByOIDC( ctx.Request.Context(), - ctx.Query("code"), - ctx.Query("state"), + code, + state, cfg.Auth, ctx.ClientIP(), ctx.GetHeader("User-Agent"), ) if err != nil { + slog.Error("oidc callback login failed", "error", err) ctx.Redirect(http.StatusFound, "/dashboard/login?oidcError="+url.QueryEscape(loginErrorMessage(err.Error()))) return } From e870f2a86f4fbf07602f8d4a45e56a924c25f73c Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:56:56 +0700 Subject: [PATCH 02/53] feat(auth): add PKCE S256 code challenge and verifier support for OIDC authorization --- internal/oidcclient/oidcclient.go | 54 ++++++++++++++++++------- internal/services/oidc_login_service.go | 4 +- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/internal/oidcclient/oidcclient.go b/internal/oidcclient/oidcclient.go index c828a920..2ba71bde 100644 --- a/internal/oidcclient/oidcclient.go +++ b/internal/oidcclient/oidcclient.go @@ -54,6 +54,7 @@ type Profile struct { type statePayload struct { Next string `json:"next"` Nonce string `json:"nonce"` + Verifier string `json:"verifier,omitempty"` ExpiredAt int64 `json:"expiredAt"` } @@ -132,16 +133,29 @@ func BuildAuthCodeURL(next string) (string, error) { if !Enabled() { return "", errorsx.BusinessErrorI18n(1, "error.oidc.loginDisabled") } - state, err := CreateState(next) + + rawVerifier := make([]byte, 32) + if _, err := rand.Read(rawVerifier); err != nil { + return "", err + } + verifier := base64.RawURLEncoding.EncodeToString(rawVerifier) + h := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(h[:]) + + state, err := CreateState(next, verifier) if err != nil { return "", err } oidcMu.Lock() defer oidcMu.Unlock() - return oauthConfig.AuthCodeURL(state), nil + return oauthConfig.AuthCodeURL( + state, + oauth2.SetAuthURLParam("code_challenge", challenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ), nil } -func ExchangeCode(ctx context.Context, code string) (*Profile, error) { +func ExchangeCode(ctx context.Context, code string, verifier string) (*Profile, error) { ensureInitialized(ctx) if !Enabled() { return nil, errorsx.BusinessErrorI18n(1, "error.oidc.loginDisabled") @@ -152,12 +166,17 @@ func ExchangeCode(ctx context.Context, code string) (*Profile, error) { } oidcMu.Lock() - verifier := idTokenVerifier + idVerifier := idTokenVerifier oauthCfg := oauthConfig prov := provider oidcMu.Unlock() - token, err := oauthCfg.Exchange(ctx, code) + var opts []oauth2.AuthCodeOption + if strings.TrimSpace(verifier) != "" { + opts = append(opts, oauth2.SetAuthURLParam("code_verifier", strings.TrimSpace(verifier))) + } + + token, err := oauthCfg.Exchange(ctx, code, opts...) if err != nil { return nil, err } @@ -165,7 +184,7 @@ func ExchangeCode(ctx context.Context, code string) (*Profile, error) { if !ok || strings.TrimSpace(rawIDToken) == "" { return nil, errorsx.UnauthorizedI18n("error.e0038") } - idToken, err := verifier.Verify(ctx, rawIDToken) + idToken, err := idVerifier.Verify(ctx, rawIDToken) if err != nil { return nil, err } @@ -182,7 +201,7 @@ func ExchangeCode(ctx context.Context, code string) (*Profile, error) { return profile, nil } -func CreateState(next string) (string, error) { +func CreateState(next string, verifier ...string) (string, error) { secret := stateSecret() if secret == "" { return "", errorsx.BusinessErrorI18n(2, "error.oidc.stateSecretMissing") @@ -191,9 +210,14 @@ func CreateState(next string) (string, error) { if err != nil { return "", err } + v := "" + if len(verifier) > 0 { + v = verifier[0] + } payload := statePayload{ Next: sanitizeNextPath(next), Nonce: nonce, + Verifier: v, ExpiredAt: time.Now().Add(StateTTL).Unix(), } body, err := json.Marshal(payload) @@ -204,30 +228,30 @@ func CreateState(next string) (string, error) { return encoded + "." + signState(encoded, secret), nil } -func ParseState(state string) (string, error) { +func ParseState(state string) (string, string, error) { secret := stateSecret() if secret == "" { - return "", errorsx.UnauthorizedI18n("error.e0046") + return "", "", errorsx.UnauthorizedI18n("error.e0046") } parts := strings.Split(strings.TrimSpace(state), ".") if len(parts) != 2 { - return "", errorsx.UnauthorizedI18n("error.e0046") + return "", "", errorsx.UnauthorizedI18n("error.e0046") } if !hmac.Equal([]byte(parts[1]), []byte(signState(parts[0], secret))) { - return "", errorsx.UnauthorizedI18n("error.e0046") + return "", "", errorsx.UnauthorizedI18n("error.e0046") } body, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { - return "", errorsx.UnauthorizedI18n("error.e0046") + return "", "", errorsx.UnauthorizedI18n("error.e0046") } payload := statePayload{} if err = json.Unmarshal(body, &payload); err != nil { - return "", errorsx.UnauthorizedI18n("error.e0046") + return "", "", errorsx.UnauthorizedI18n("error.e0046") } if payload.ExpiredAt <= time.Now().Unix() { - return "", errorsx.UnauthorizedI18n("error.e0046") + return "", "", errorsx.UnauthorizedI18n("error.e0046") } - return sanitizeNextPath(payload.Next), nil + return sanitizeNextPath(payload.Next), payload.Verifier, nil } func IssueLoginTicket(loginResp *response.LoginResponse) (string, error) { diff --git a/internal/services/oidc_login_service.go b/internal/services/oidc_login_service.go index 8aaf540e..9827c4d7 100644 --- a/internal/services/oidc_login_service.go +++ b/internal/services/oidc_login_service.go @@ -37,11 +37,11 @@ func (s *oidcLoginService) BuildOIDCLoginURL(next string) (string, error) { } func (s *oidcLoginService) LoginByOIDC(ctx context.Context, code, state string, authCfg config.AuthConfig, clientIP, userAgent string) (string, string, error) { - next, err := oidcclient.ParseState(state) + next, verifier, err := oidcclient.ParseState(state) if err != nil { return "", "", err } - profile, err := oidcclient.ExchangeCode(ctx, code) + profile, err := oidcclient.ExchangeCode(ctx, code, verifier) if err != nil { return "", "", err } From 12bb354af8baa3483260f27355a0e44294af25ce Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:06:57 +0700 Subject: [PATCH 03/53] feat(auth): add passwordLoginEnabled setting to disable username/password login form --- config/config.example.yaml | 3 + docker/agent-desk.supabase.example.yaml | 1 + internal/bootstrap/server_route_test.go | 74 ++++++++++++++- internal/handlers/api/auth_handler.go | 12 ++- internal/pkg/config/config.go | 14 ++- internal/pkg/dto/response/auth_response.go | 7 +- internal/pkg/i18nx/locales/en-US.yml | 1 + internal/pkg/i18nx/locales/zh-CN.yml | 1 + web/components/login-form.tsx | 100 +++++++++++---------- web/lib/api/config.ts | 1 + 10 files changed, 157 insertions(+), 57 deletions(-) diff --git a/config/config.example.yaml b/config/config.example.yaml index fc55d638..94bcbb1d 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -36,6 +36,9 @@ logger: addSource: false auth: + # Enable username and password login. When set to false, users must sign in with SSO (e.g. OIDC or WeCom). + # Default is true. + passwordLoginEnabled: true # Login access token lifetime, in hours. Values <= 0 fall back to 12 hours. # Applies to password login, OIDC login, and WeCom login sessions. tokenTTLHours: 12 diff --git a/docker/agent-desk.supabase.example.yaml b/docker/agent-desk.supabase.example.yaml index 9a4bf1f6..165a4bb6 100644 --- a/docker/agent-desk.supabase.example.yaml +++ b/docker/agent-desk.supabase.example.yaml @@ -24,6 +24,7 @@ logger: addSource: false auth: + passwordLoginEnabled: false tokenTTLHours: 12 maxFailedAttempts: 5 credentialLockMinute: 15 diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index af2d0c67..d64edb8c 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -139,9 +141,10 @@ func TestNewServerExposesPublicConfig(t *testing.T) { var body struct { Success bool `json:"success"` Data struct { - Language string `json:"language"` - WxWorkEnabled bool `json:"wxworkEnabled"` - OIDCEnabled bool `json:"oidcEnabled"` + Language string `json:"language"` + PasswordLoginEnabled bool `json:"passwordLoginEnabled"` + WxWorkEnabled bool `json:"wxworkEnabled"` + OIDCEnabled bool `json:"oidcEnabled"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { @@ -153,6 +156,9 @@ func TestNewServerExposesPublicConfig(t *testing.T) { if body.Data.Language != "zh-CN" { t.Fatalf("language=%q want zh-CN", body.Data.Language) } + if !body.Data.PasswordLoginEnabled { + t.Fatalf("passwordLoginEnabled=false want true") + } if !body.Data.WxWorkEnabled { t.Fatalf("wxworkEnabled=false want true") } @@ -187,7 +193,69 @@ func TestNewServerDoesNotExposeLegacyAuthOptions(t *testing.T) { } } +func TestNewServerPasswordLoginDisabled(t *testing.T) { + disabled := false + config.SetCurrent(&config.Config{ + Auth: config.AuthConfig{ + PasswordLoginEnabled: &disabled, + }, + Storage: config.StorageConfig{ + Local: config.LocalStorageConfig{ + Root: "storage", + BaseURL: "/storage", + }, + }, + }) + + app, err := NewServer() + if err != nil { + t.Fatalf("NewServer() error = %v", err) + } + + // 1. /api/config should return passwordLoginEnabled=false + rec := httptest.NewRecorder() + app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/config", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d want %d", rec.Code, http.StatusOK) + } + var configBody struct { + Data struct { + PasswordLoginEnabled bool `json:"passwordLoginEnabled"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &configBody); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if configBody.Data.PasswordLoginEnabled { + t.Fatalf("expected passwordLoginEnabled=false, got true") + } + + // 2. /api/auth/login should be rejected + rec = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"admin","password":"secret"}`)) + req.Header.Set("Content-Type", "application/json") + app.ServeHTTP(rec, req) + + var loginBody struct { + Success bool `json:"success"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &loginBody); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if loginBody.Success { + t.Fatalf("expected login failure when password login is disabled") + } +} + func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) { + for _, rootDir := range []string{"web/out", "../web/out", "../../web/out"} { + _ = os.MkdirAll(rootDir, 0o755) + _ = os.WriteFile(filepath.Join(rootDir, "index.html"), []byte("spa"), 0o644) + defer func(d string) { + _ = os.Remove(filepath.Join(d, "index.html")) + }(rootDir) + } + config.SetCurrent(&config.Config{ Storage: config.StorageConfig{ Local: config.LocalStorageConfig{ diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go index 4acd476b..9e5e7c9b 100644 --- a/internal/handlers/api/auth_handler.go +++ b/internal/handlers/api/auth_handler.go @@ -4,6 +4,7 @@ import ( "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/httpx/params" "agent-desk/internal/services" @@ -17,6 +18,10 @@ import ( func Login(ctx *gin.Context) { cfg := config.Current() + if !cfg.Auth.IsPasswordLoginEnabled() { + httpx.WriteJSON(ctx, errorsx.ForbiddenI18n("error.auth.passwordLoginDisabled")) + return + } req := request.LoginRequest{} if err := params.ReadJSON(ctx, &req); err != nil { httpx.WriteJSON(ctx, err) @@ -34,9 +39,10 @@ func Login(ctx *gin.Context) { func PublicConfig(ctx *gin.Context) { cfg := config.Current() httpx.WriteJSON(ctx, &response.PublicConfigResponse{ - Language: cfg.LanguageOrDefault(), - WxWorkEnabled: cfg.WxWork.Enabled, - OIDCEnabled: cfg.OIDC.Enabled, + Language: cfg.LanguageOrDefault(), + PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(), + WxWorkEnabled: cfg.WxWork.Enabled, + OIDCEnabled: cfg.OIDC.Enabled, }) } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 3870ac21..259041ad 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -76,9 +76,17 @@ type LoggerConfig struct { } type AuthConfig struct { - TokenTTLHours int `yaml:"tokenTTLHours"` - MaxFailedAttempts int `yaml:"maxFailedAttempts"` - CredentialLockMinute int `yaml:"credentialLockMinute"` + PasswordLoginEnabled *bool `yaml:"passwordLoginEnabled"` + TokenTTLHours int `yaml:"tokenTTLHours"` + MaxFailedAttempts int `yaml:"maxFailedAttempts"` + CredentialLockMinute int `yaml:"credentialLockMinute"` +} + +func (a AuthConfig) IsPasswordLoginEnabled() bool { + if a.PasswordLoginEnabled == nil { + return true + } + return *a.PasswordLoginEnabled } type CustomerSessionConfig struct { diff --git a/internal/pkg/dto/response/auth_response.go b/internal/pkg/dto/response/auth_response.go index f2202aba..c4b023b4 100644 --- a/internal/pkg/dto/response/auth_response.go +++ b/internal/pkg/dto/response/auth_response.go @@ -21,7 +21,8 @@ type LoginResponse struct { } type PublicConfigResponse struct { - Language string `json:"language"` - WxWorkEnabled bool `json:"wxworkEnabled"` - OIDCEnabled bool `json:"oidcEnabled"` + Language string `json:"language"` + PasswordLoginEnabled bool `json:"passwordLoginEnabled"` + WxWorkEnabled bool `json:"wxworkEnabled"` + OIDCEnabled bool `json:"oidcEnabled"` } diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index eaba4824..ca0adeb7 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -230,6 +230,7 @@ error.e0230: "Invalid service mode." error.e0231: "No matching WeCom channel was found." error.e0232: "No schedules were generated." error.auth.expired: "Your session has expired. Please sign in again." +error.auth.passwordLoginDisabled: "Username and password login is disabled. Please use SSO to sign in." error.e0234: "No available AI configuration is configured." error.e0235: "No available embedding model is configured." error.e0236: "Permission not found." diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index 37c3f435..e7c63410 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -230,6 +230,7 @@ error.e0230: "服务模式不合法" error.e0231: "未找到匹配的企业微信接入渠道" error.e0232: "未生成任何排班" error.auth.expired: "未登录或登录已过期" +error.auth.passwordLoginDisabled: "账号密码登录已禁用,请使用第三方登录。" error.e0234: "未配置可用的 AI 配置" error.e0235: "未配置可用的 Embedding 模型" error.e0236: "权限不存在" diff --git a/web/components/login-form.tsx b/web/components/login-form.tsx index 8129e6e3..71f98b55 100644 --- a/web/components/login-form.tsx +++ b/web/components/login-form.tsx @@ -50,6 +50,7 @@ export function LoginForm({ nextPath && nextPath.startsWith("/") ? nextPath : "/dashboard" const enabledProviderCount = Number(publicConfig?.wxworkEnabled) + Number(publicConfig?.oidcEnabled) + const isPasswordLoginEnabled = publicConfig?.passwordLoginEnabled !== false useEffect(() => { if (session) { @@ -97,6 +98,9 @@ export function LoginForm({ async function handleSubmit(event: React.FormEvent) { event.preventDefault() + if (!isPasswordLoginEnabled) { + return + } const formData = new FormData(event.currentTarget) const username = formData.get("username")?.toString().trim() ?? "" const password = formData.get("password")?.toString() ?? "" @@ -162,49 +166,55 @@ export function LoginForm({ {t("auth.loginDescription", { brand: t("app.brand") })}

- - {t("auth.username")} - - - -
- {t("auth.password")} - - {t("auth.forgotPassword")} - -
- -
- - - + {isPasswordLoginEnabled ? ( + <> + + {t("auth.username")} + + + +
+ {t("auth.password")} + + {t("auth.forgotPassword")} + +
+ +
+ + + + + ) : null} {enabledProviderCount > 0 ? ( <> - - {t("auth.continueWith")} - + {isPasswordLoginEnabled ? ( + + {t("auth.continueWith")} + + ) : null} {publicConfig.wxworkEnabled ? ( @@ -218,14 +228,14 @@ export function LoginForm({ : "/api/auth/wxwork_qr_login" window.location.href = `${path}?next=${encodeURIComponent(redirectPath)}` }} - > - + + height={16} + className="size-4 shrink-0" + /> {t("auth.wxworkSignIn")} ) : null} diff --git a/web/lib/api/config.ts b/web/lib/api/config.ts index e96af102..2fe187e2 100644 --- a/web/lib/api/config.ts +++ b/web/lib/api/config.ts @@ -2,6 +2,7 @@ import { request } from "@/lib/api/client" export type PublicConfig = { language: string + passwordLoginEnabled?: boolean wxworkEnabled: boolean oidcEnabled: boolean } From 56e5393a4a78d8e6c2233daa4215f32460415c3b Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:27:21 +0700 Subject: [PATCH 04/53] feat(config): add .env file support and standard environment variable aliases --- .env.example | 43 ++++++++++++ internal/pkg/config/config.go | 105 +++++++++++++++++++++++++++-- internal/pkg/config/config_test.go | 68 +++++++++++++++++++ 3 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..d553e0ee --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# ============================================================================== +# AgentDesk Environment Variables Configuration +# Copy this file to .env and adjust the configuration values as needed. +# ============================================================================== + +# Server Configuration +PORT=8083 +# AGENT_DESK_SERVER_CORS_ALLOWEDORIGINS="http://localhost:3000,http://127.0.0.1:8083" + +# Database Configuration +# Driver options: sqlite, mysql, postgres +DB_TYPE=sqlite +DATABASE_URL=file:./data/app.db?_busy_timeout=5000 +# MySQL example: +# DATABASE_URL="cs_ai_agent:cs_ai_agent_password@tcp(127.0.0.1:3306)/cs_ai_agent?charset=utf8mb4&parseTime=True&multiStatements=true&loc=Local" +# PostgreSQL example: +# DATABASE_URL="postgres://postgres:password@127.0.0.1:5432/cs_ai_agent?sslmode=disable" + +# Auth & Security +PASSWORD_LOGIN_ENABLED=true +# AUTH_TOKEN_TTL_HOURS=12 +# CUSTOMER_SESSION_SECRET=replace-with-a-random-secret-at-least-32-chars + +# Storage (local or oss) +STORAGE_DEFAULT=local +STORAGE_LOCAL_ROOT=data/storage +STORAGE_LOCAL_BASE_URL=/storage + +# Vector Database (Qdrant) +VECTOR_DB_TYPE=qdrant +QDRANT_HOST=127.0.0.1 +QDRANT_GRPC_PORT=6334 +# QDRANT_API_KEY= + +# Single Sign-On (OIDC / OAuth 2.0) +# OIDC_ENABLED=true +# OIDC_ISSUER=https://auth.example.com +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URL=http://localhost:8083/api/auth/oidc_callback + +# Webhook & Organization Sync +# ORG_SYNC_SECRET=your-webhook-hmac-secret diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 259041ad..236ad251 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -2,10 +2,14 @@ package config import ( "agent-desk/internal/pkg/enums" + "errors" "fmt" + "os" + "path/filepath" "strings" "github.com/spf13/viper" + "github.com/subosito/gotenv" ) type Config struct { @@ -225,20 +229,113 @@ type WebhookConfig struct { } func Load(path string) (*Config, error) { + loadDotEnv(path) + v := viper.New() - v.SetConfigFile(path) - v.SetConfigType("yaml") + bindConfigDefaults(v) + + if strings.TrimSpace(path) != "" { + v.SetConfigFile(path) + v.SetConfigType("yaml") + } + v.SetEnvPrefix("AGENT_DESK") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() + bindEnvironmentAliases(v) - if err := v.ReadInConfig(); err != nil { - return nil, err + if strings.TrimSpace(path) != "" { + if err := v.ReadInConfig(); err != nil { + var configFileNotFoundError viper.ConfigFileNotFoundError + if !os.IsNotExist(err) && !errors.As(err, &configFileNotFoundError) { + return nil, err + } + } } cfg := &Config{} if err := v.Unmarshal(cfg); err != nil { return nil, err } + normalizeLoadedConfig(cfg) return cfg, nil } + +func loadDotEnv(configPath string) { + if envFile := os.Getenv("AGENT_DESK_ENV_FILE"); envFile != "" { + _ = gotenv.Load(envFile) + return + } + if envFile := os.Getenv("ENV_FILE"); envFile != "" { + _ = gotenv.Load(envFile) + return + } + _ = gotenv.Load(".env") + if configPath != "" { + dir := filepath.Dir(configPath) + if dir != "." && dir != "" { + _ = gotenv.Load(filepath.Join(dir, ".env")) + } + } +} + +func bindConfigDefaults(v *viper.Viper) { + v.SetDefault("language", "zh-CN") + v.SetDefault("server.port", 8083) + v.SetDefault("server.cors.allowedOrigins", []string{}) + v.SetDefault("db.type", "sqlite") + v.SetDefault("db.dsn", "file:./data/app.db?_busy_timeout=5000") + v.SetDefault("db.maxIdleConns", 5) + v.SetDefault("db.maxOpenConns", 20) + v.SetDefault("db.connMaxIdleTimeSeconds", 300) + v.SetDefault("db.connMaxLifetimeSeconds", 1800) + v.SetDefault("logger.level", "info") + v.SetDefault("logger.format", "text") + v.SetDefault("logger.addSource", false) + v.SetDefault("auth.tokenTTLHours", 12) + v.SetDefault("auth.maxFailedAttempts", 5) + v.SetDefault("auth.credentialLockMinute", 15) + v.SetDefault("customerSession.ttlMinutes", 120) + v.SetDefault("customerSession.refreshThresholdMinutes", 30) + v.SetDefault("storage.default", "local") + v.SetDefault("storage.maxUploadSizeMB", 20) + v.SetDefault("storage.local.root", "data/storage") + v.SetDefault("storage.local.baseUrl", "/storage") + v.SetDefault("vectorDB.type", "qdrant") + v.SetDefault("vectorDB.qdrant.host", "127.0.0.1") + v.SetDefault("vectorDB.qdrant.grpcPort", 6334) + v.SetDefault("mcp.enabled", true) +} + +func bindEnvironmentAliases(v *viper.Viper) { + _ = v.BindEnv("server.port", "PORT", "SERVER_PORT", "AGENT_DESK_SERVER_PORT") + _ = v.BindEnv("db.type", "DATABASE_TYPE", "DB_TYPE", "AGENT_DESK_DB_TYPE") + _ = v.BindEnv("db.dsn", "DATABASE_URL", "DB_DSN", "AGENT_DESK_DB_DSN") + _ = v.BindEnv("auth.passwordLoginEnabled", "PASSWORD_LOGIN_ENABLED", "AGENT_DESK_AUTH_PASSWORDLOGINENABLED") + _ = v.BindEnv("auth.tokenTTLHours", "AUTH_TOKEN_TTL_HOURS", "AGENT_DESK_AUTH_TOKENTTLHOURS") + _ = v.BindEnv("customerSession.secret", "CUSTOMER_SESSION_SECRET", "SESSION_SECRET", "JWT_SECRET", "AGENT_DESK_CUSTOMERSESSION_SECRET") + _ = v.BindEnv("storage.default", "STORAGE_DEFAULT", "STORAGE_TYPE", "AGENT_DESK_STORAGE_DEFAULT") + _ = v.BindEnv("storage.local.root", "STORAGE_LOCAL_ROOT", "AGENT_DESK_STORAGE_LOCAL_ROOT") + _ = v.BindEnv("storage.local.baseUrl", "STORAGE_LOCAL_BASE_URL", "AGENT_DESK_STORAGE_LOCAL_BASEURL") + _ = v.BindEnv("vectorDB.type", "VECTOR_DB_TYPE", "AGENT_DESK_VECTORDB_TYPE") + _ = v.BindEnv("vectorDB.qdrant.host", "QDRANT_HOST", "AGENT_DESK_VECTORDB_QDRANT_HOST") + _ = v.BindEnv("vectorDB.qdrant.grpcPort", "QDRANT_GRPC_PORT", "QDRANT_PORT", "AGENT_DESK_VECTORDB_QDRANT_GRPCPORT") + _ = v.BindEnv("vectorDB.qdrant.apiKey", "QDRANT_API_KEY", "AGENT_DESK_VECTORDB_QDRANT_APIKEY") + _ = v.BindEnv("oidc.enabled", "OIDC_ENABLED", "AGENT_DESK_OIDC_ENABLED") + _ = v.BindEnv("oidc.issuer", "OIDC_ISSUER", "AGENT_DESK_OIDC_ISSUER") + _ = v.BindEnv("oidc.clientId", "OIDC_CLIENT_ID", "CUSTOM_OAUTH_CLIENT_ID", "AGENT_DESK_OIDC_CLIENTID") + _ = v.BindEnv("oidc.clientSecret", "OIDC_CLIENT_SECRET", "CUSTOM_OAUTH_CLIENT_SECRET", "AGENT_DESK_OIDC_CLIENTSECRET") + _ = v.BindEnv("oidc.redirectUrl", "OIDC_REDIRECT_URL", "CUSTOM_OAUTH_REDIRECT_URI", "AGENT_DESK_OIDC_REDIRECTURL") + _ = v.BindEnv("webhook.orgSyncSecret", "ORG_SYNC_SECRET", "WEBHOOK_SECRET", "AGENT_DESK_WEBHOOK_ORGSYNCSECRET") +} + +func normalizeLoadedConfig(cfg *Config) { + if cfg == nil { + return + } + if cfg.DB.Type == "sqlite" && (strings.HasPrefix(cfg.DB.DSN, "postgres://") || strings.HasPrefix(cfg.DB.DSN, "postgresql://")) { + cfg.DB.Type = "postgres" + } else if cfg.DB.Type == "sqlite" && strings.Contains(cfg.DB.DSN, "@tcp(") { + cfg.DB.Type = "mysql" + } +} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 07c46d65..1f8b42cb 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -80,3 +80,71 @@ mcp: t.Fatalf("MCP system endpoint=%q", cfg.MCP.Servers["system"].Endpoint) } } + +func TestLoadFromDotEnvAndStandardEnvAliases(t *testing.T) { + tempDir := t.TempDir() + envPath := filepath.Join(tempDir, ".env") + envContent := []byte(`PORT=9090 +DATABASE_URL=postgres://user:pass@localhost:5432/mydb?sslmode=disable +PASSWORD_LOGIN_ENABLED=false +JWT_SECRET=super-secret-key-12345 +QDRANT_HOST=10.0.0.5 +QDRANT_PORT=6334 +OIDC_ENABLED=true +OIDC_ISSUER=https://auth.example.com +OIDC_CLIENT_ID=client-123 +OIDC_CLIENT_SECRET=secret-456 +OIDC_REDIRECT_URL=https://desk.example.com/api/auth/oidc_callback +ORG_SYNC_SECRET=webhook-secret-789 +`) + if err := os.WriteFile(envPath, envContent, 0600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + t.Setenv("ENV_FILE", envPath) + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.Server.Port != 9090 { + t.Fatalf("Server.Port=%d want 9090", cfg.Server.Port) + } + if cfg.DB.Type != "postgres" { + t.Fatalf("DB.Type=%q want postgres", cfg.DB.Type) + } + if cfg.DB.DSN != "postgres://user:pass@localhost:5432/mydb?sslmode=disable" { + t.Fatalf("DB.DSN=%q", cfg.DB.DSN) + } + if cfg.Auth.IsPasswordLoginEnabled() { + t.Fatalf("expected PasswordLoginEnabled to be false") + } + if cfg.CustomerSession.Secret != "super-secret-key-12345" { + t.Fatalf("CustomerSession.Secret=%q", cfg.CustomerSession.Secret) + } + if cfg.VectorDB.Qdrant.Host != "10.0.0.5" { + t.Fatalf("Qdrant.Host=%q", cfg.VectorDB.Qdrant.Host) + } + if cfg.VectorDB.Qdrant.GrpcPort != 6334 { + t.Fatalf("Qdrant.GrpcPort=%d", cfg.VectorDB.Qdrant.GrpcPort) + } + if !cfg.OIDC.Enabled { + t.Fatalf("expected OIDC.Enabled=true") + } + if cfg.OIDC.Issuer != "https://auth.example.com" { + t.Fatalf("OIDC.Issuer=%q", cfg.OIDC.Issuer) + } + if cfg.OIDC.ClientID != "client-123" { + t.Fatalf("OIDC.ClientID=%q", cfg.OIDC.ClientID) + } + if cfg.OIDC.ClientSecret != "secret-456" { + t.Fatalf("OIDC.ClientSecret=%q", cfg.OIDC.ClientSecret) + } + if cfg.OIDC.RedirectURL != "https://desk.example.com/api/auth/oidc_callback" { + t.Fatalf("OIDC.RedirectURL=%q", cfg.OIDC.RedirectURL) + } + if cfg.Webhook.OrgSyncSecret != "webhook-secret-789" { + t.Fatalf("Webhook.OrgSyncSecret=%q", cfg.Webhook.OrgSyncSecret) + } +} From a0ab3f9b97f3dc0e10591179a836f66bb248c73f Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:59:12 +0700 Subject: [PATCH 05/53] feat(tenant): add organization CRUD dialogs, member management, and COMPANY_NAME config --- internal/bootstrap/routes.go | 5 + internal/bootstrap/server_route_test.go | 5 + internal/handlers/api/auth_handler.go | 2 + .../dashboard/organization_handler.go | 130 ++++++- internal/pkg/config/config.go | 10 +- .../pkg/dto/request/organization_request.go | 20 ++ internal/pkg/dto/response/auth_response.go | 2 + .../pkg/dto/response/organization_response.go | 12 + internal/services/organization_service.go | 339 ++++++++++++++++++ .../services/webhook_sync_service_test.go | 103 +++++- web/components/login-form.tsx | 2 +- web/components/organization-dialogs.tsx | 337 +++++++++++++++++ web/components/workspace-switcher.tsx | 223 ++++++++---- web/lib/api/config.ts | 2 + web/lib/api/organization.ts | 46 +++ 15 files changed, 1148 insertions(+), 90 deletions(-) create mode 100644 web/components/organization-dialogs.tsx diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index 08bb4435..72d692ec 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -86,7 +86,12 @@ func registerDashboardUserRoutes(group *gin.RouterGroup) { func registerDashboardOrganizationRoutes(group *gin.RouterGroup) { group.GET("/my_list", dashboard.OrganizationUserList) + group.POST("/create", dashboard.OrganizationPostCreate) group.POST("/switch", dashboard.OrganizationSwitch) + group.GET("/members", dashboard.OrganizationGetMembers) + group.POST("/add_member", dashboard.OrganizationPostAddMember) + group.POST("/remove_member", dashboard.OrganizationPostRemoveMember) + group.POST("/update", dashboard.OrganizationPostUpdate) } func registerDashboardCompanyRoutes(group *gin.RouterGroup) { diff --git a/internal/bootstrap/server_route_test.go b/internal/bootstrap/server_route_test.go index d64edb8c..ac4dee53 100644 --- a/internal/bootstrap/server_route_test.go +++ b/internal/bootstrap/server_route_test.go @@ -43,7 +43,12 @@ func TestNewServerRegistersGinRoutes(t *testing.T) { http.MethodGet + " /api/auth/profile", http.MethodPost + " /api/webhooks/org-sync", http.MethodGet + " /api/dashboard/organization/my_list", + http.MethodPost + " /api/dashboard/organization/create", http.MethodPost + " /api/dashboard/organization/switch", + http.MethodGet + " /api/dashboard/organization/members", + http.MethodPost + " /api/dashboard/organization/add_member", + http.MethodPost + " /api/dashboard/organization/remove_member", + http.MethodPost + " /api/dashboard/organization/update", http.MethodGet + " /api/dashboard/user/list", http.MethodGet + " /api/dashboard/user/:id", http.MethodPost + " /api/dashboard/user/create", diff --git a/internal/handlers/api/auth_handler.go b/internal/handlers/api/auth_handler.go index 9e5e7c9b..c9ea341c 100644 --- a/internal/handlers/api/auth_handler.go +++ b/internal/handlers/api/auth_handler.go @@ -40,6 +40,8 @@ func PublicConfig(ctx *gin.Context) { cfg := config.Current() httpx.WriteJSON(ctx, &response.PublicConfigResponse{ Language: cfg.LanguageOrDefault(), + CompanyName: cfg.Server.CompanyName, + CompanyLogoURL: cfg.Server.CompanyLogoURL, PasswordLoginEnabled: cfg.Auth.IsPasswordLoginEnabled(), WxWorkEnabled: cfg.WxWork.Enabled, OIDCEnabled: cfg.OIDC.Enabled, diff --git a/internal/handlers/dashboard/organization_handler.go b/internal/handlers/dashboard/organization_handler.go index 7267ac48..36ed67e1 100644 --- a/internal/handlers/dashboard/organization_handler.go +++ b/internal/handlers/dashboard/organization_handler.go @@ -2,6 +2,7 @@ package dashboard import ( "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/errorsx" "agent-desk/internal/pkg/httpx" "agent-desk/internal/pkg/httpx/params" "agent-desk/internal/services" @@ -24,10 +25,31 @@ func OrganizationUserList(ctx *gin.Context) { httpx.WriteJSON(ctx, ret) } +func OrganizationPostCreate(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + req := request.OrganizationCreateRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + ret, err := services.OrganizationService.CreateOrganization(principal.UserID, req) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, ret) +} + func OrganizationSwitch(ctx *gin.Context) { principal := services.AuthService.GetAuthPrincipal(ctx) if principal == nil { - httpx.WriteJSON(ctx, nil) + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) return } @@ -45,3 +67,109 @@ func OrganizationSwitch(ctx *gin.Context) { httpx.WriteJSON(ctx, org) } + +func OrganizationGetMembers(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + activeOrg := services.OrganizationService.GetActiveOrganization(principal) + if activeOrg == nil { + httpx.WriteJSON(ctx, []any{}) + return + } + + members, err := services.OrganizationService.GetOrganizationMembers(principal.UserID, activeOrg.ID) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, members) +} + +func OrganizationPostAddMember(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + activeOrg := services.OrganizationService.GetActiveOrganization(principal) + if activeOrg == nil { + httpx.WriteJSON(ctx, errorsx.InvalidParam("no active organization selected")) + return + } + + req := request.OrganizationAddMemberRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + member, err := services.OrganizationService.AddMember(principal.UserID, activeOrg.ID, req) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, member) +} + +func OrganizationPostRemoveMember(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + activeOrg := services.OrganizationService.GetActiveOrganization(principal) + if activeOrg == nil { + httpx.WriteJSON(ctx, errorsx.InvalidParam("no active organization selected")) + return + } + + req := request.OrganizationRemoveMemberRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + if req.UserID <= 0 { + httpx.WriteJSON(ctx, errorsx.InvalidParam("invalid user id")) + return + } + + if err := services.OrganizationService.RemoveMember(principal.UserID, activeOrg.ID, req.UserID); err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, gin.H{"success": true}) +} + +func OrganizationPostUpdate(ctx *gin.Context) { + principal := services.AuthService.GetAuthPrincipal(ctx) + if principal == nil { + httpx.WriteJSON(ctx, errorsx.UnauthorizedI18n("error.auth.expired")) + return + } + + activeOrg := services.OrganizationService.GetActiveOrganization(principal) + if activeOrg == nil { + httpx.WriteJSON(ctx, errorsx.InvalidParam("no active organization selected")) + return + } + + req := request.OrganizationUpdateRequest{} + if err := params.ReadJSON(ctx, &req); err != nil { + httpx.WriteJSON(ctx, err) + return + } + + org, err := services.OrganizationService.UpdateOrganization(principal.UserID, activeOrg.ID, req) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, org) +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 236ad251..8de33eac 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -47,8 +47,10 @@ type WxWorkNotifyConfig struct { } type ServerConfig struct { - Port int `yaml:"port"` - CORS CORSConfig `yaml:"cors"` + Port int `yaml:"port"` + CompanyName string `yaml:"companyName"` + CompanyLogoURL string `yaml:"companyLogoUrl"` + CORS CORSConfig `yaml:"cors"` } func (s ServerConfig) Address() string { @@ -282,6 +284,8 @@ func loadDotEnv(configPath string) { func bindConfigDefaults(v *viper.Viper) { v.SetDefault("language", "zh-CN") v.SetDefault("server.port", 8083) + v.SetDefault("server.companyName", "") + v.SetDefault("server.companyLogoUrl", "") v.SetDefault("server.cors.allowedOrigins", []string{}) v.SetDefault("db.type", "sqlite") v.SetDefault("db.dsn", "file:./data/app.db?_busy_timeout=5000") @@ -309,6 +313,8 @@ func bindConfigDefaults(v *viper.Viper) { func bindEnvironmentAliases(v *viper.Viper) { _ = v.BindEnv("server.port", "PORT", "SERVER_PORT", "AGENT_DESK_SERVER_PORT") + _ = v.BindEnv("server.companyName", "COMPANY_NAME", "NEXT_PUBLIC_COMPANY_NAME", "BRAND_NAME", "BRAND_COMPANY_NAME", "AGENT_DESK_SERVER_COMPANYNAME") + _ = v.BindEnv("server.companyLogoUrl", "COMPANY_LOGO_URL", "NEXT_PUBLIC_COMPANY_LOGO_URL", "BRAND_LOGO_URL", "AGENT_DESK_SERVER_COMPANYLOGOURL") _ = v.BindEnv("db.type", "DATABASE_TYPE", "DB_TYPE", "AGENT_DESK_DB_TYPE") _ = v.BindEnv("db.dsn", "DATABASE_URL", "DB_DSN", "AGENT_DESK_DB_DSN") _ = v.BindEnv("auth.passwordLoginEnabled", "PASSWORD_LOGIN_ENABLED", "AGENT_DESK_AUTH_PASSWORDLOGINENABLED") diff --git a/internal/pkg/dto/request/organization_request.go b/internal/pkg/dto/request/organization_request.go index 303c0432..1ffdec7b 100644 --- a/internal/pkg/dto/request/organization_request.go +++ b/internal/pkg/dto/request/organization_request.go @@ -3,3 +3,23 @@ package request type OrganizationSwitchRequest struct { OrganizationID int64 `json:"organizationId"` } + +type OrganizationCreateRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Logo string `json:"logo"` +} + +type OrganizationUpdateRequest struct { + Name string `json:"name"` + Logo string `json:"logo"` +} + +type OrganizationAddMemberRequest struct { + EmailOrUsername string `json:"emailOrUsername"` + Role string `json:"role"` +} + +type OrganizationRemoveMemberRequest struct { + UserID int64 `json:"userId"` +} diff --git a/internal/pkg/dto/response/auth_response.go b/internal/pkg/dto/response/auth_response.go index c4b023b4..07d05a7a 100644 --- a/internal/pkg/dto/response/auth_response.go +++ b/internal/pkg/dto/response/auth_response.go @@ -22,6 +22,8 @@ type LoginResponse struct { type PublicConfigResponse struct { Language string `json:"language"` + CompanyName string `json:"companyName,omitempty"` + CompanyLogoURL string `json:"companyLogoUrl,omitempty"` PasswordLoginEnabled bool `json:"passwordLoginEnabled"` WxWorkEnabled bool `json:"wxworkEnabled"` OIDCEnabled bool `json:"oidcEnabled"` diff --git a/internal/pkg/dto/response/organization_response.go b/internal/pkg/dto/response/organization_response.go index 0b7e1543..d2f2f59f 100644 --- a/internal/pkg/dto/response/organization_response.go +++ b/internal/pkg/dto/response/organization_response.go @@ -17,6 +17,18 @@ type OrganizationResponse struct { CreatedAt time.Time `json:"createdAt"` } +type OrganizationMemberResponse struct { + ID int64 `json:"id"` + UserID int64 `json:"userId"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Role string `json:"role"` + Status int `json:"status"` + CreatedAt time.Time `json:"createdAt"` +} + type UserOrganizationListResponse struct { CurrentOrganizationID int64 `json:"currentOrganizationId"` Organizations []OrganizationResponse `json:"organizations"` diff --git a/internal/services/organization_service.go b/internal/services/organization_service.go index 9ead70e9..de952513 100644 --- a/internal/services/organization_service.go +++ b/internal/services/organization_service.go @@ -3,10 +3,17 @@ package services import ( "agent-desk/internal/models" "agent-desk/internal/pkg/dto" + "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/repositories" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + "unicode" "github.com/mlogclub/simple/sqls" ) @@ -63,6 +70,90 @@ func (s *organizationService) GetUserOrganizations(userID int64) (*response.User }, nil } +func (s *organizationService) CreateOrganization(userID int64, req request.OrganizationCreateRequest) (*response.OrganizationResponse, error) { + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParam("organization name is required") + } + + user := repositories.UserRepository.Get(sqls.DB(), userID) + if user == nil { + return nil, errorsx.InvalidAccountI18n("error.e0260") + } + + code := strings.TrimSpace(req.Code) + if code == "" { + code = s.generateUniqueOrgCode(name) + } + + var createdOrg *models.Organization + now := time.Now() + + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if existing := repositories.OrganizationRepository.GetByCode(ctx.Tx, code); existing != nil { + return errorsx.InvalidParam("organization code already in use") + } + + createdOrg = &models.Organization{ + Code: code, + Name: name, + Logo: strings.TrimSpace(req.Logo), + Plan: "free", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + } + + if err := repositories.OrganizationRepository.Create(ctx.Tx, createdOrg); err != nil { + return err + } + + member := &models.OrganizationMember{ + OrganizationID: createdOrg.ID, + UserID: user.ID, + Role: "OWNER", + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: user.ID, + CreateUserName: user.Username, + UpdatedAt: now, + UpdateUserID: user.ID, + UpdateUserName: user.Username, + }, + } + + if err := repositories.OrganizationMemberRepository.Create(ctx.Tx, member); err != nil { + return err + } + + _ = repositories.UserRepository.UpdateColumn(ctx.Tx, user.ID, "active_org_id", createdOrg.ID) + return nil + }) + + if err != nil { + return nil, err + } + + return &response.OrganizationResponse{ + ID: createdOrg.ID, + Code: createdOrg.Code, + Name: createdOrg.Name, + Logo: createdOrg.Logo, + Plan: createdOrg.Plan, + Status: createdOrg.Status, + Role: "OWNER", + IsActive: true, + CreatedAt: createdOrg.CreatedAt, + }, nil +} + func (s *organizationService) SwitchActiveOrganization(userID int64, orgID int64) (*models.Organization, error) { user := repositories.UserRepository.Get(sqls.DB(), userID) if user == nil { @@ -86,6 +177,221 @@ func (s *organizationService) SwitchActiveOrganization(userID int64, orgID int64 return org, nil } +func (s *organizationService) GetOrganizationMembers(currentUserID int64, orgID int64) ([]response.OrganizationMemberResponse, error) { + member := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, currentUserID) + if member == nil || member.Status != enums.StatusOk { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + memberships := repositories.OrganizationMemberRepository.Find(sqls.DB(), sqls.NewCnd().Eq("organization_id", orgID).Eq("status", enums.StatusOk)) + if len(memberships) == 0 { + return []response.OrganizationMemberResponse{}, nil + } + + userIDs := make([]int64, 0, len(memberships)) + for _, m := range memberships { + userIDs = append(userIDs, m.UserID) + } + + users := repositories.UserRepository.Find(sqls.DB(), sqls.NewCnd().In("id", userIDs)) + userMap := make(map[int64]models.User, len(users)) + for _, u := range users { + userMap[u.ID] = u + } + + res := make([]response.OrganizationMemberResponse, 0, len(memberships)) + for _, m := range memberships { + u := userMap[m.UserID] + email := "" + if u.Email != nil { + email = *u.Email + } + res = append(res, response.OrganizationMemberResponse{ + ID: m.ID, + UserID: m.UserID, + Username: u.Username, + Nickname: u.Nickname, + Email: email, + Avatar: u.Avatar, + Role: m.Role, + Status: int(m.Status), + CreatedAt: m.CreatedAt, + }) + } + + return res, nil +} + +func (s *organizationService) AddMember(currentUserID int64, orgID int64, req request.OrganizationAddMemberRequest) (*response.OrganizationMemberResponse, error) { + currentMember := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, currentUserID) + if currentMember == nil || currentMember.Status != enums.StatusOk || (currentMember.Role != "OWNER" && currentMember.Role != "ADMIN") { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + query := strings.TrimSpace(req.EmailOrUsername) + if query == "" { + return nil, errorsx.InvalidParam("email or username is required") + } + + targetUser := repositories.UserRepository.GetByUsername(sqls.DB(), query) + if targetUser == nil { + targetUser = repositories.UserRepository.GetByEmail(sqls.DB(), strings.ToLower(query)) + } + if targetUser == nil { + return nil, errorsx.InvalidParam("user not found") + } + + role := strings.ToUpper(strings.TrimSpace(req.Role)) + if role != "ADMIN" && role != "MEMBER" { + role = "MEMBER" + } + + now := time.Now() + var member *models.OrganizationMember + + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + existing := repositories.OrganizationMemberRepository.GetByOrgAndUser(ctx.Tx, orgID, targetUser.ID) + if existing == nil { + member = &models.OrganizationMember{ + OrganizationID: orgID, + UserID: targetUser.ID, + Role: role, + Status: enums.StatusOk, + AuditFields: models.AuditFields{ + CreatedAt: now, + CreateUserID: currentUserID, + CreateUserName: "", + UpdatedAt: now, + UpdateUserID: currentUserID, + UpdateUserName: "", + }, + } + if err := repositories.OrganizationMemberRepository.Create(ctx.Tx, member); err != nil { + return err + } + } else { + member = existing + member.Role = role + member.Status = enums.StatusOk + _ = repositories.OrganizationMemberRepository.Updates(ctx.Tx, member.ID, map[string]any{ + "role": role, + "status": enums.StatusOk, + "update_user_id": currentUserID, + "update_user_name": "", + "updated_at": now, + }) + } + + if targetUser.ActiveOrgID == 0 { + _ = repositories.UserRepository.UpdateColumn(ctx.Tx, targetUser.ID, "active_org_id", orgID) + } + return nil + }) + + if err != nil { + return nil, err + } + + email := "" + if targetUser.Email != nil { + email = *targetUser.Email + } + + return &response.OrganizationMemberResponse{ + ID: member.ID, + UserID: targetUser.ID, + Username: targetUser.Username, + Nickname: targetUser.Nickname, + Email: email, + Avatar: targetUser.Avatar, + Role: member.Role, + Status: int(member.Status), + CreatedAt: member.CreatedAt, + }, nil +} + +func (s *organizationService) RemoveMember(currentUserID int64, orgID int64, targetUserID int64) error { + currentMember := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, currentUserID) + if currentMember == nil || currentMember.Status != enums.StatusOk { + return errorsx.ForbiddenI18n("error.e0225") + } + + if currentUserID != targetUserID && currentMember.Role != "OWNER" && currentMember.Role != "ADMIN" { + return errorsx.ForbiddenI18n("error.e0225") + } + + targetMember := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, targetUserID) + if targetMember == nil || targetMember.Status != enums.StatusOk { + return errorsx.InvalidParam("member not found in organization") + } + + if targetMember.Role == "OWNER" { + owners := repositories.OrganizationMemberRepository.Find(sqls.DB(), sqls.NewCnd().Eq("organization_id", orgID).Eq("role", "OWNER").Eq("status", enums.StatusOk)) + if len(owners) <= 1 { + return errorsx.InvalidParam("cannot remove the only owner of the organization") + } + } + + return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + if err := repositories.OrganizationMemberRepository.UpdateColumn(ctx.Tx, targetMember.ID, "status", enums.StatusDeleted); err != nil { + return err + } + + targetUser := repositories.UserRepository.Get(ctx.Tx, targetUserID) + if targetUser != nil && targetUser.ActiveOrgID == orgID { + remaining := repositories.OrganizationMemberRepository.Find(ctx.Tx, sqls.NewCnd().Eq("user_id", targetUserID).Eq("status", enums.StatusOk).Where("organization_id <> ?", orgID)) + var newActiveOrgID int64 = 0 + if len(remaining) > 0 { + newActiveOrgID = remaining[0].OrganizationID + } + _ = repositories.UserRepository.UpdateColumn(ctx.Tx, targetUserID, "active_org_id", newActiveOrgID) + } + return nil + }) +} + +func (s *organizationService) UpdateOrganization(currentUserID int64, orgID int64, req request.OrganizationUpdateRequest) (*response.OrganizationResponse, error) { + currentMember := repositories.OrganizationMemberRepository.GetByOrgAndUser(sqls.DB(), orgID, currentUserID) + if currentMember == nil || currentMember.Status != enums.StatusOk || (currentMember.Role != "OWNER" && currentMember.Role != "ADMIN") { + return nil, errorsx.ForbiddenI18n("error.e0225") + } + + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, errorsx.InvalidParam("organization name cannot be empty") + } + + org := repositories.OrganizationRepository.Get(sqls.DB(), orgID) + if org == nil || org.Status != enums.StatusOk { + return nil, errorsx.InvalidParam("organization not found") + } + + updates := map[string]any{ + "name": name, + "logo": strings.TrimSpace(req.Logo), + "update_user_id": currentUserID, + "update_user_name": "", + "updated_at": time.Now(), + } + + if err := repositories.OrganizationRepository.Updates(sqls.DB(), orgID, updates); err != nil { + return nil, err + } + + org = repositories.OrganizationRepository.Get(sqls.DB(), orgID) + return &response.OrganizationResponse{ + ID: org.ID, + Code: org.Code, + Name: org.Name, + Logo: org.Logo, + Plan: org.Plan, + Status: org.Status, + Role: currentMember.Role, + IsActive: true, + CreatedAt: org.CreatedAt, + }, nil +} + func (s *organizationService) GetActiveOrganization(principal *dto.AuthPrincipal) *models.Organization { if principal == nil || principal.UserID <= 0 { return nil @@ -96,3 +402,36 @@ func (s *organizationService) GetActiveOrganization(principal *dto.AuthPrincipal } return repositories.OrganizationRepository.Get(sqls.DB(), user.ActiveOrgID) } + +func (s *organizationService) generateUniqueOrgCode(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } else if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") { + b.WriteString("-") + } + } + slug := strings.Trim(b.String(), "-") + if slug == "" { + slug = "org" + } + if len(slug) > 30 { + slug = slug[:30] + } + + if repositories.OrganizationRepository.GetByCode(sqls.DB(), slug) == nil { + return slug + } + + for i := 1; i < 100; i++ { + candidate := fmt.Sprintf("%s-%d", slug, i) + if repositories.OrganizationRepository.GetByCode(sqls.DB(), candidate) == nil { + return candidate + } + } + + randBuf := make([]byte, 4) + _, _ = rand.Read(randBuf) + return fmt.Sprintf("%s-%s", slug, hex.EncodeToString(randBuf)) +} diff --git a/internal/services/webhook_sync_service_test.go b/internal/services/webhook_sync_service_test.go index d7f30267..0c776f38 100644 --- a/internal/services/webhook_sync_service_test.go +++ b/internal/services/webhook_sync_service_test.go @@ -14,17 +14,17 @@ func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) { svc := newWebhookSyncService() // 1. Test org.created - err := svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err := svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.created", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_001", OrgName: "Tingee Corporation", Plan: "pro", }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.created failed: %v", err) + t.Fatalf("HandleOrgSync org.created failed: %v", err) } org := repositories.OrganizationRepository.GetByCode(db, "org_tingee_001") @@ -33,17 +33,17 @@ func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) { } // 2. Test org.updated - err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.updated", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_001", OrgName: "Tingee Global Corp", Plan: "enterprise", }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.updated failed: %v", err) + t.Fatalf("HandleOrgSync org.updated failed: %v", err) } org = repositories.OrganizationRepository.GetByCode(db, "org_tingee_001") @@ -52,15 +52,15 @@ func TestWebhookDOSOrgSync_OrgEvents(t *testing.T) { } // 3. Test org.deleted - err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.deleted", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_001", }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.deleted failed: %v", err) + t.Fatalf("HandleOrgSync org.deleted failed: %v", err) } org = repositories.OrganizationRepository.GetByCode(db, "org_tingee_001") @@ -74,10 +74,10 @@ func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) { svc := newWebhookSyncService() // 1. Test org.member_added - err := svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err := svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.member_added", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_002", OrgName: "Tingee R&D", UserID: "usr_dos_001", @@ -87,7 +87,7 @@ func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) { }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.member_added failed: %v", err) + t.Fatalf("HandleOrgSync org.member_added failed: %v", err) } org := repositories.OrganizationRepository.GetByCode(db, "org_tingee_002") @@ -110,17 +110,17 @@ func TestWebhookDOSOrgSync_MemberEvents(t *testing.T) { } // 2. Test org.member_removed - err = svc.HandleDOSOrgSync(request.DOSOrgSyncWebhookRequest{ + err = svc.HandleOrgSync(request.OrgSyncWebhookRequest{ Event: "org.member_removed", Timestamp: time.Now().Format(time.RFC3339), - Data: request.DOSOrgSyncEventData{ + Data: request.OrgSyncEventData{ OrgID: "org_tingee_002", UserID: "usr_dos_001", UserEmail: "member@tingee.com", }, }) if err != nil { - t.Fatalf("HandleDOSOrgSync org.member_removed failed: %v", err) + t.Fatalf("HandleOrgSync org.member_removed failed: %v", err) } member = repositories.OrganizationMemberRepository.GetByOrgAndUser(db, org.ID, user.ID) @@ -176,3 +176,76 @@ func TestOrganizationService_SwitchAndList(t *testing.T) { t.Fatalf("expected active_org_id %d, got %d", org2.ID, updatedUser.ActiveOrgID) } } + +func TestOrganizationService_CreateAndManageMembers(t *testing.T) { + db := setupAuthServiceTestDB(t) + owner := createAuthTestUser(t, db, "owner_user", "secret") + memberUser := createAuthTestUser(t, db, "member_user", "secret") + email := "member_user@example.com" + _ = repositories.UserRepository.UpdateColumn(db, memberUser.ID, "email", email) + + // 1. Create Organization + created, err := OrganizationService.CreateOrganization(owner.ID, request.OrganizationCreateRequest{ + Name: "Acme Support Org", + Code: "acme-support", + }) + if err != nil { + t.Fatalf("CreateOrganization failed: %v", err) + } + if created == nil || created.Name != "Acme Support Org" || created.Role != "OWNER" { + t.Fatalf("unexpected created org: %+v", created) + } + + updatedOwner := repositories.UserRepository.Get(db, owner.ID) + if updatedOwner.ActiveOrgID != created.ID { + t.Fatalf("expected owner active_org_id = %d, got %d", created.ID, updatedOwner.ActiveOrgID) + } + + // 2. Get Members + members, err := OrganizationService.GetOrganizationMembers(owner.ID, created.ID) + if err != nil { + t.Fatalf("GetOrganizationMembers failed: %v", err) + } + if len(members) != 1 || members[0].UserID != owner.ID || members[0].Role != "OWNER" { + t.Fatalf("unexpected members: %+v", members) + } + + // 3. Add Member by email + added, err := OrganizationService.AddMember(owner.ID, created.ID, request.OrganizationAddMemberRequest{ + EmailOrUsername: "member_user@example.com", + Role: "ADMIN", + }) + if err != nil { + t.Fatalf("AddMember failed: %v", err) + } + if added == nil || added.UserID != memberUser.ID || added.Role != "ADMIN" { + t.Fatalf("unexpected added member: %+v", added) + } + + members, _ = OrganizationService.GetOrganizationMembers(owner.ID, created.ID) + if len(members) != 2 { + t.Fatalf("expected 2 members, got %d", len(members)) + } + + // 4. Update Organization + updatedOrg, err := OrganizationService.UpdateOrganization(owner.ID, created.ID, request.OrganizationUpdateRequest{ + Name: "Acme Global Support", + }) + if err != nil { + t.Fatalf("UpdateOrganization failed: %v", err) + } + if updatedOrg.Name != "Acme Global Support" { + t.Fatalf("expected name to be Acme Global Support, got %s", updatedOrg.Name) + } + + // 5. Remove Member + err = OrganizationService.RemoveMember(owner.ID, created.ID, memberUser.ID) + if err != nil { + t.Fatalf("RemoveMember failed: %v", err) + } + + members, _ = OrganizationService.GetOrganizationMembers(owner.ID, created.ID) + if len(members) != 1 { + t.Fatalf("expected 1 member after removal, got %d", len(members)) + } +} diff --git a/web/components/login-form.tsx b/web/components/login-form.tsx index 71f98b55..0b13c96a 100644 --- a/web/components/login-form.tsx +++ b/web/components/login-form.tsx @@ -163,7 +163,7 @@ export function LoginForm({

{t("auth.welcome")}

- {t("auth.loginDescription", { brand: t("app.brand") })} + {t("auth.loginDescription", { brand: publicConfig?.companyName || t("app.brand") })}

{isPasswordLoginEnabled ? ( diff --git a/web/components/organization-dialogs.tsx b/web/components/organization-dialogs.tsx new file mode 100644 index 00000000..240904c1 --- /dev/null +++ b/web/components/organization-dialogs.tsx @@ -0,0 +1,337 @@ +"use client" + +import { Building2Icon, Loader2Icon, PlusIcon, Trash2Icon, UserPlusIcon, UsersIcon } from "lucide-react" +import { useEffect, useState } from "react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + addOrganizationMember, + createOrganization, + getOrganizationMembers, + removeOrganizationMember, + updateOrganization, + type OrganizationItem, + type OrganizationMemberItem, +} from "@/lib/api/organization" + +export function CreateOrganizationDialog({ + open, + onOpenChange, + onCreated, +}: { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (org: OrganizationItem) => void +}) { + const [name, setName] = useState("") + const [code, setCode] = useState("") + const [isPending, setIsPending] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) { + toast.error("Organization name is required") + return + } + setIsPending(true) + try { + const org = await createOrganization({ + name: name.trim(), + code: code.trim() || undefined, + }) + toast.success(`Organization "${org.name}" created successfully`) + setName("") + setCode("") + onOpenChange(false) + onCreated(org) + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to create organization") + } finally { + setIsPending(false) + } + } + + return ( + + +
+ + + + Create Organization + + + Create a new organization / workspace for your team. + + + + + + Organization Name + setName(e.target.value)} + placeholder="e.g. Acme Corp" + required + autoFocus + /> + + + Slug / Code (Optional) + setCode(e.target.value)} + placeholder="e.g. acme-corp" + /> + + + + + + + +
+
+
+ ) +} + +export function ManageOrganizationDialog({ + open, + onOpenChange, + organization, + onUpdated, +}: { + open: boolean + onOpenChange: (open: boolean) => void + organization: OrganizationItem | null + onUpdated?: () => void +}) { + const [members, setMembers] = useState([]) + const [loadingMembers, setLoadingMembers] = useState(false) + const [emailOrUsername, setEmailOrUsername] = useState("") + const [role, setRole] = useState("MEMBER") + const [isAdding, setIsAdding] = useState(false) + const [orgName, setOrgName] = useState("") + const [isUpdatingName, setIsUpdatingName] = useState(false) + + useEffect(() => { + if (open && organization) { + setOrgName(organization.name) + setLoadingMembers(true) + getOrganizationMembers() + .then((res) => { + setMembers(res || []) + }) + .catch(() => { + setMembers([]) + }) + .finally(() => { + setLoadingMembers(false) + }) + } + }, [open, organization]) + + const handleUpdateName = async (e: React.FormEvent) => { + e.preventDefault() + if (!orgName.trim() || orgName.trim() === organization?.name) return + setIsUpdatingName(true) + try { + await updateOrganization({ name: orgName.trim() }) + toast.success("Organization name updated") + onUpdated?.() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to update name") + } finally { + setIsUpdatingName(false) + } + } + + const handleAddMember = async (e: React.FormEvent) => { + e.preventDefault() + if (!emailOrUsername.trim()) return + setIsAdding(true) + try { + const added = await addOrganizationMember({ + emailOrUsername: emailOrUsername.trim(), + role, + }) + setMembers((prev) => { + const filtered = prev.filter((m) => m.userId !== added.userId) + return [...filtered, added] + }) + setEmailOrUsername("") + toast.success(`Member added successfully`) + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to add member") + } finally { + setIsAdding(false) + } + } + + const handleRemoveMember = async (userId: number, username: string) => { + if (!confirm(`Are you sure you want to remove ${username}?`)) return + try { + await removeOrganizationMember(userId) + setMembers((prev) => prev.filter((m) => m.userId !== userId)) + toast.success(`Member removed`) + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to remove member") + } + } + + if (!organization) return null + + const isOwnerOrAdmin = organization.role === "OWNER" || organization.role === "ADMIN" + + return ( + + + + + + {organization.name} — Settings & Members + + + Manage organization details, team members, and access roles. + + + + {isOwnerOrAdmin ? ( +
+ + Organization Name +
+ setOrgName(e.target.value)} + placeholder="Organization Name" + required + /> + +
+
+
+ ) : null} + +
+
+

+ + Members ({members.length}) +

+
+ + {isOwnerOrAdmin ? ( +
+ setEmailOrUsername(e.target.value)} + placeholder="Username or email address..." + className="flex-1 text-xs" + required + /> + + +
+ ) : null} + + {loadingMembers ? ( +
+ +
+ ) : ( +
+ {members.map((m) => ( +
+
+ {m.nickname || m.username} + {m.email || `@${m.username}`} +
+
+ + {m.role} + + {isOwnerOrAdmin && m.role !== "OWNER" ? ( + + ) : null} +
+
+ ))} + {members.length === 0 ? ( +
No members found.
+ ) : null} +
+ )} +
+ + + + +
+
+ ) +} diff --git a/web/components/workspace-switcher.tsx b/web/components/workspace-switcher.tsx index 441e87a9..d31167d9 100644 --- a/web/components/workspace-switcher.tsx +++ b/web/components/workspace-switcher.tsx @@ -1,11 +1,21 @@ "use client" -import { Building2Icon, CheckIcon, ChevronsUpDownIcon, LayoutDashboardIcon, WrenchIcon } from "lucide-react" +import { + Building2Icon, + CheckIcon, + ChevronsUpDownIcon, + LayoutDashboardIcon, + PlusIcon, + SettingsIcon, + WrenchIcon, +} from "lucide-react" import Link from "next/link" import { useEffect, useState, type ReactElement } from "react" import { toast } from "sonner" +import { CreateOrganizationDialog, ManageOrganizationDialog } from "@/components/organization-dialogs" import { useI18n } from "@/i18n/provider" +import { fetchPublicConfig, type PublicConfig } from "@/lib/api/config" import { listMyOrganizations, switchOrganization, type OrganizationItem } from "@/lib/api/organization" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -60,17 +70,29 @@ export function WorkspaceSwitcher({ const [orgs, setOrgs] = useState([]) const [activeOrgId, setActiveOrgId] = useState(null) const [switching, setSwitching] = useState(false) + const [publicConfig, setPublicConfig] = useState(null) + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [manageDialogOpen, setManageDialogOpen] = useState(false) - useEffect(() => { - let mounted = true + const loadOrgs = () => { listMyOrganizations() .then((res) => { - if (mounted && res?.organizations) { + if (res?.organizations) { setOrgs(res.organizations) setActiveOrgId(res.currentOrganizationId || res.organizations[0]?.id || null) } }) .catch(() => {}) + } + + useEffect(() => { + let mounted = true + loadOrgs() + fetchPublicConfig() + .then((cfg) => { + if (mounted) setPublicConfig(cfg) + }) + .catch(() => {}) return () => { mounted = false } @@ -80,6 +102,9 @@ export function WorkspaceSwitcher({ workspaceOptions.find((item) => item.key === currentWorkspace) ?? workspaceOptions[0] const currentOrg = orgs.find((o) => o.id === activeOrgId) || orgs[0] + const brandName = publicConfig?.companyName || t("app.brand") + const brandLogo = publicConfig?.companyLogoUrl || "/images/logo.svg" + const handleSwitchOrg = async (orgId: number) => { if (orgId === activeOrgId || switching) return setSwitching(true) @@ -113,28 +138,28 @@ export function WorkspaceSwitcher({ variant === "rail" ? ( <> {t("app.brand")} - {currentOrg?.name || t("app.brand")} - {t(currentOption.labelKey)} + {currentOrg?.name || brandName} - {t(currentOption.labelKey)} ) : ( <> {t("app.brand")}
- {currentOrg?.name || t("app.brand")} + {currentOrg?.name || brandName} {t(currentOption.labelKey)} {currentOrg?.role ? `• ${currentOrg.role}` : ""} @@ -149,67 +174,123 @@ export function WorkspaceSwitcher({ ) return ( - - - } - > - {triggerContent} - - - {orgs.length > 0 ? ( - <> - - - - Organizations / Workspaces - - {orgs.map((org) => { - const isActive = org.id === activeOrgId - return ( - handleSwitchOrg(org.id)} - > -
- {org.name} - - {org.role || "Member"} {org.plan ? `• ${org.plan}` : ""} - -
- {isActive ? : null} -
- ) - })} -
- - - ) : null} + <> + + + } + > + {triggerContent} + + + {orgs.length > 0 ? ( + <> + + + + + Organizations / Workspaces + + {currentOrg ? ( + + ) : null} + + {orgs.map((org) => { + const isActive = org.id === activeOrgId + return ( + handleSwitchOrg(org.id)} + > +
+ {org.name} + + {org.role || "Member"} {org.plan ? `• ${org.plan}` : ""} + +
+ {isActive ? : null} +
+ ) + })} + setCreateDialogOpen(true)} + > + + Create Organization + +
+ + + ) : ( + <> + + setCreateDialogOpen(true)} + > + + Create Organization + + + + + )} + + + {t("workspace.switchWorkspace")} + {workspaceOptions.map((item) => ( + } + className="cursor-pointer gap-2" + > + + {t(item.labelKey)} + {item.key === currentWorkspace ? ( + + ) : null} + + ))} + +
+
+ + { + loadOrgs() + setActiveOrgId(newOrg.id) + window.location.reload() + }} + /> - - {t("workspace.switchWorkspace")} - {workspaceOptions.map((item) => ( - } - className="cursor-pointer gap-2" - > - - {t(item.labelKey)} - {item.key === currentWorkspace ? ( - - ) : null} - - ))} - -
-
+ { + loadOrgs() + }} + /> + ) } diff --git a/web/lib/api/config.ts b/web/lib/api/config.ts index 2fe187e2..088677fb 100644 --- a/web/lib/api/config.ts +++ b/web/lib/api/config.ts @@ -2,6 +2,8 @@ import { request } from "@/lib/api/client" export type PublicConfig = { language: string + companyName?: string + companyLogoUrl?: string passwordLoginEnabled?: boolean wxworkEnabled: boolean oidcEnabled: boolean diff --git a/web/lib/api/organization.ts b/web/lib/api/organization.ts index 285b8f74..025c2a2c 100644 --- a/web/lib/api/organization.ts +++ b/web/lib/api/organization.ts @@ -12,6 +12,18 @@ export type OrganizationItem = { createdAt: string } +export type OrganizationMemberItem = { + id: number + userId: number + username: string + nickname: string + email: string + avatar: string + role: string + status: number + createdAt: string +} + export type UserOrganizationListResponse = { currentOrganizationId: number organizations: OrganizationItem[] @@ -23,9 +35,43 @@ export async function listMyOrganizations() { }) } +export async function createOrganization(data: { name: string; code?: string; logo?: string }) { + return request("/api/dashboard/organization/create", { + method: "POST", + body: JSON.stringify(data), + }) +} + export async function switchOrganization(organizationId: number) { return request("/api/dashboard/organization/switch", { method: "POST", body: JSON.stringify({ organizationId }), }) } + +export async function getOrganizationMembers() { + return request("/api/dashboard/organization/members", { + method: "GET", + }) +} + +export async function addOrganizationMember(data: { emailOrUsername: string; role?: string }) { + return request("/api/dashboard/organization/add_member", { + method: "POST", + body: JSON.stringify(data), + }) +} + +export async function removeOrganizationMember(userId: number) { + return request<{ success: boolean }>("/api/dashboard/organization/remove_member", { + method: "POST", + body: JSON.stringify({ userId }), + }) +} + +export async function updateOrganization(data: { name: string; logo?: string }) { + return request("/api/dashboard/organization/update", { + method: "POST", + body: JSON.stringify(data), + }) +} From 1ab6565e13f94d7813a478aacf62d99b6d5eb087 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:22:59 +0700 Subject: [PATCH 06/53] fix(ui): redirect to /dashboard upon creating or switching organization --- web/components/workspace-switcher.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/workspace-switcher.tsx b/web/components/workspace-switcher.tsx index d31167d9..9ed0f353 100644 --- a/web/components/workspace-switcher.tsx +++ b/web/components/workspace-switcher.tsx @@ -112,7 +112,7 @@ export function WorkspaceSwitcher({ await switchOrganization(orgId) setActiveOrgId(orgId) toast.success("Switched organization successfully") - window.location.reload() + window.location.href = "/dashboard" } catch { toast.error("Failed to switch organization") } finally { @@ -279,7 +279,7 @@ export function WorkspaceSwitcher({ onCreated={(newOrg) => { loadOrgs() setActiveOrgId(newOrg.id) - window.location.reload() + window.location.href = "/dashboard" }} /> From 0160433be9f87a650258c29626ddd4b3f8cdfd8e Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:20:42 +0700 Subject: [PATCH 07/53] feat(sync): add outbound organization webhook sync dispatcher --- internal/pkg/config/config.go | 2 + internal/pkg/config/runtime.go | 4 ++ internal/services/organization_service.go | 60 ++++++++++++++++- internal/services/webhook_sync_service.go | 79 ++++++++++++++++++++++- 4 files changed, 141 insertions(+), 4 deletions(-) diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 8de33eac..7fac1214 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -228,6 +228,7 @@ type WxWorkConfig struct { type WebhookConfig struct { OrgSyncSecret string `yaml:"orgSyncSecret"` DOSOrgSyncSecret string `yaml:"dosOrgSyncSecret"` + OutboundURL string `yaml:"outboundUrl"` } func Load(path string) (*Config, error) { @@ -333,6 +334,7 @@ func bindEnvironmentAliases(v *viper.Viper) { _ = v.BindEnv("oidc.clientSecret", "OIDC_CLIENT_SECRET", "CUSTOM_OAUTH_CLIENT_SECRET", "AGENT_DESK_OIDC_CLIENTSECRET") _ = v.BindEnv("oidc.redirectUrl", "OIDC_REDIRECT_URL", "CUSTOM_OAUTH_REDIRECT_URI", "AGENT_DESK_OIDC_REDIRECTURL") _ = v.BindEnv("webhook.orgSyncSecret", "ORG_SYNC_SECRET", "WEBHOOK_SECRET", "AGENT_DESK_WEBHOOK_ORGSYNCSECRET") + _ = v.BindEnv("webhook.outboundUrl", "ORG_SYNC_OUTBOUND_URL", "DOS_ORG_SYNC_URL", "WEBHOOK_OUTBOUND_URL", "AGENT_DESK_WEBHOOK_OUTBOUNDURL") } func normalizeLoadedConfig(cfg *Config) { diff --git a/internal/pkg/config/runtime.go b/internal/pkg/config/runtime.go index 18a8afa9..6badbf58 100644 --- a/internal/pkg/config/runtime.go +++ b/internal/pkg/config/runtime.go @@ -6,6 +6,10 @@ func SetCurrent(cfg *Config) { current = cfg } +func GetCurrent() *Config { + return current +} + func Current() Config { if current == nil { panic("config not initialized") diff --git a/internal/services/organization_service.go b/internal/services/organization_service.go index de952513..c50cf604 100644 --- a/internal/services/organization_service.go +++ b/internal/services/organization_service.go @@ -141,6 +141,20 @@ func (s *organizationService) CreateOrganization(userID int64, req request.Organ return nil, err } + userEmail := "" + if user.Email != nil { + userEmail = *user.Email + } + WebhookSyncService.DispatchOutboundOrgEvent("org.created", request.OrgSyncEventData{ + OrgID: createdOrg.Code, + OrgName: createdOrg.Name, + UserID: user.Username, + UserEmail: userEmail, + UserName: user.Nickname, + Role: "OWNER", + Plan: createdOrg.Plan, + }) + return &response.OrganizationResponse{ ID: createdOrg.ID, Code: createdOrg.Code, @@ -292,11 +306,24 @@ func (s *organizationService) AddMember(currentUserID int64, orgID int64, req re return nil, err } + orgCode := "" + if targetOrg := repositories.OrganizationRepository.Get(sqls.DB(), orgID); targetOrg != nil { + orgCode = targetOrg.Code + } + email := "" if targetUser.Email != nil { email = *targetUser.Email } + WebhookSyncService.DispatchOutboundOrgEvent("org.member_added", request.OrgSyncEventData{ + OrgID: orgCode, + UserID: targetUser.Username, + UserEmail: email, + UserName: targetUser.Nickname, + Role: member.Role, + }) + return &response.OrganizationMemberResponse{ ID: member.ID, UserID: targetUser.ID, @@ -332,7 +359,7 @@ func (s *organizationService) RemoveMember(currentUserID int64, orgID int64, tar } } - return sqls.WithTransaction(func(ctx *sqls.TxContext) error { + err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { if err := repositories.OrganizationMemberRepository.UpdateColumn(ctx.Tx, targetMember.ID, "status", enums.StatusDeleted); err != nil { return err } @@ -348,6 +375,30 @@ func (s *organizationService) RemoveMember(currentUserID int64, orgID int64, tar } return nil }) + + if err != nil { + return err + } + + orgCode := "" + if targetOrg := repositories.OrganizationRepository.Get(sqls.DB(), orgID); targetOrg != nil { + orgCode = targetOrg.Code + } + email := "" + username := "" + if targetUser := repositories.UserRepository.Get(sqls.DB(), targetUserID); targetUser != nil { + username = targetUser.Username + if targetUser.Email != nil { + email = *targetUser.Email + } + } + WebhookSyncService.DispatchOutboundOrgEvent("org.member_removed", request.OrgSyncEventData{ + OrgID: orgCode, + UserID: username, + UserEmail: email, + }) + + return nil } func (s *organizationService) UpdateOrganization(currentUserID int64, orgID int64, req request.OrganizationUpdateRequest) (*response.OrganizationResponse, error) { @@ -379,6 +430,13 @@ func (s *organizationService) UpdateOrganization(currentUserID int64, orgID int6 } org = repositories.OrganizationRepository.Get(sqls.DB(), orgID) + + WebhookSyncService.DispatchOutboundOrgEvent("org.updated", request.OrgSyncEventData{ + OrgID: org.Code, + OrgName: org.Name, + Plan: org.Plan, + }) + return &response.OrganizationResponse{ ID: org.ID, Code: org.Code, diff --git a/internal/services/webhook_sync_service.go b/internal/services/webhook_sync_service.go index cc6436ac..061ccd26 100644 --- a/internal/services/webhook_sync_service.go +++ b/internal/services/webhook_sync_service.go @@ -7,9 +7,13 @@ import ( "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "agent-desk/internal/repositories" + "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" + "encoding/json" + "log/slog" + "net/http" "strings" "time" @@ -25,12 +29,16 @@ func newWebhookSyncService() *webhookSyncService { type webhookSyncService struct{} func (s *webhookSyncService) VerifySignature(payload []byte, signature string) bool { - secret := strings.TrimSpace(config.Current().Webhook.OrgSyncSecret) + cfg := config.GetCurrent() + if cfg == nil { + return true + } + secret := strings.TrimSpace(cfg.Webhook.OrgSyncSecret) if secret == "" { - secret = strings.TrimSpace(config.Current().Webhook.DOSOrgSyncSecret) + secret = strings.TrimSpace(cfg.Webhook.DOSOrgSyncSecret) } if secret == "" { - secret = strings.TrimSpace(config.Current().OIDC.ClientSecret) + secret = strings.TrimSpace(cfg.OIDC.ClientSecret) } if secret == "" { return true @@ -78,6 +86,71 @@ func (s *webhookSyncService) HandleDOSOrgSync(req request.DOSOrgSyncWebhookReque return s.HandleOrgSync(req) } +func (s *webhookSyncService) DispatchOutboundOrgEvent(event string, data request.OrgSyncEventData) { + cfg := config.GetCurrent() + if cfg == nil { + return + } + outboundURL := strings.TrimSpace(cfg.Webhook.OutboundURL) + if outboundURL == "" { + return + } + + payload := request.OrgSyncWebhookRequest{ + Event: event, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Data: data, + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + slog.Error("failed to marshal outbound org event", "event", event, "error", err) + return + } + + secret := strings.TrimSpace(cfg.Webhook.OrgSyncSecret) + if secret == "" { + secret = strings.TrimSpace(cfg.Webhook.DOSOrgSyncSecret) + } + if secret == "" { + secret = strings.TrimSpace(cfg.OIDC.ClientSecret) + } + + var signature string + if secret != "" { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(bodyBytes) + signature = "sha256=" + hex.EncodeToString(mac.Sum(nil)) + } + + go func() { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodPost, outboundURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + slog.Error("failed to create outbound org sync request", "url", outboundURL, "error", err) + return + } + req.Header.Set("Content-Type", "application/json") + if signature != "" { + req.Header.Set("X-DOS-Signature", signature) + req.Header.Set("X-Webhook-Signature", signature) + } + + resp, err := client.Do(req) + if err != nil { + slog.Error("failed to dispatch outbound org sync event", "event", event, "url", outboundURL, "error", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + slog.Warn("outbound org sync event returned non-2xx status", "event", event, "status", resp.StatusCode) + } else { + slog.Info("outbound org sync event dispatched successfully", "event", event, "orgId", data.OrgID) + } + }() +} + func (s *webhookSyncService) handleOrgUpsert(data request.OrgSyncEventData) error { now := time.Now() orgCode := strings.TrimSpace(data.OrgID) From 0e3b82d9a3cb8c92f3f6131041df174562057585 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:29:07 +0700 Subject: [PATCH 08/53] style(ui): fix font-sans resolution by loading Inter font and setting proper font fallbacks --- web/app/(dashboard)/dashboard.css | 14 +++++++++----- web/app/(dashboard)/layout.tsx | 11 ++++++----- web/app/(support)/layout.tsx | 15 ++++++++------- web/app/(support)/support.css | 16 ++++++++++------ 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/web/app/(dashboard)/dashboard.css b/web/app/(dashboard)/dashboard.css index c3f4cb55..8a22a690 100644 --- a/web/app/(dashboard)/dashboard.css +++ b/web/app/(dashboard)/dashboard.css @@ -7,8 +7,8 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); + --font-sans: var(--font-inter), var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -359,10 +359,14 @@ * { @apply border-border outline-ring/50; } + html { + font-family: var(--font-inter), var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } body { @apply bg-background text-foreground; - } - html { - @apply font-sans; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; } } diff --git a/web/app/(dashboard)/layout.tsx b/web/app/(dashboard)/layout.tsx index 8ca0a3d7..9fd18915 100644 --- a/web/app/(dashboard)/layout.tsx +++ b/web/app/(dashboard)/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next" -import { Geist, Geist_Mono } from "next/font/google" +import { Inter, Geist_Mono } from "next/font/google" import { AuthProvider } from "@/components/auth-provider" import { ApiErrorProvider } from "@/components/api-error-provider" @@ -14,9 +14,10 @@ import "./dashboard.css" import "md-editor-rt/lib/style.css" import "@/styles/main.scss" -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin", "vietnamese"], + display: "swap", }) const geistMono = Geist_Mono({ @@ -46,7 +47,7 @@ export default function DashboardRootLayout({ return (