diff --git a/controller/channel.go b/controller/channel.go index 21735170..c59e492a 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -1218,7 +1218,7 @@ func CopyChannel(c *gin.Context) { } // insert - if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil { + if err := clone.Insert(); err != nil { common.SysError("failed to clone channel: " + err.Error()) c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"}) return diff --git a/controller/misc.go b/controller/misc.go index 344cda77..eada4909 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -88,6 +88,7 @@ func GetStatus(c *gin.Context) { "demo_site_enabled": operation_setting.DemoSiteEnabled, "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, "register_enabled": common.RegisterEnabled, + "password_login_enabled": common.PasswordLoginEnabled, "password_register_enabled": common.PasswordRegisterEnabled, "default_use_auto_group": setting.DefaultUseAutoGroup, diff --git a/controller/user.go b/controller/user.go index c174c798..afebc6d4 100644 --- a/controller/user.go +++ b/controller/user.go @@ -251,8 +251,20 @@ func GetAllUsers(c *gin.Context) { func SearchUsers(c *gin.Context) { keyword := c.Query("keyword") group := c.Query("group") + var role *int + if roleStr := c.Query("role"); roleStr != "" { + if parsed, err := strconv.Atoi(roleStr); err == nil { + role = &parsed + } + } + var status *int + if statusStr := c.Query("status"); statusStr != "" { + if parsed, err := strconv.Atoi(statusStr); err == nil { + status = &parsed + } + } pageInfo := common.GetPageQuery(c) - users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + users, total, err := model.SearchUsers(keyword, group, role, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) if err != nil { common.ApiError(c, err) return diff --git a/model/user.go b/model/user.go index 8a650315..0eb4f53a 100644 --- a/model/user.go +++ b/model/user.go @@ -225,7 +225,7 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err return users, total, nil } -func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) { +func SearchUsers(keyword string, group string, role *int, status *int, startIdx int, num int) ([]*User, int64, error) { var users []*User var total int64 var err error @@ -246,28 +246,25 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, // 构建搜索条件 likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" + likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"} // 尝试将关键字转换为整数ID keywordInt, err := strconv.Atoi(keyword) if err == nil { // 如果是数字,同时搜索ID和其他字段 likeCondition = "id = ? OR " + likeCondition - if group != "" { - query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", - keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) - } else { - query = query.Where(likeCondition, - keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") - } - } else { - // 非数字关键字,只搜索字符串字段 - if group != "" { - query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) - } else { - query = query.Where(likeCondition, - "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") - } + likeArgs = append([]interface{}{keywordInt}, likeArgs...) + } + + query = query.Where("("+likeCondition+")", likeArgs...) + if group != "" { + query = query.Where(commonGroupCol+" = ?", group) + } + if role != nil { + query = query.Where("role = ?", *role) + } + if status != nil { + query = query.Where("status = ?", *status) } // 获取总数 diff --git a/web/default/bun.lock b/web/default/bun.lock index 7c04ad7a..88e1bf82 100644 --- a/web/default/bun.lock +++ b/web/default/bun.lock @@ -24,9 +24,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.3.0", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "i18next": "^26.2.0", - "i18next-browser-languagedetector": "^8.2.0", + "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", "lucide-react": "^1.16.0", "motion": "^12.40.0", @@ -38,7 +38,7 @@ "react-dom": "^19.2.6", "react-hook-form": "^7.76.1", "react-i18next": "^17.0.8", - "react-icons": "^5.5.0", + "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.2", "react-top-loading-bar": "^3.0.2", @@ -47,8 +47,8 @@ "remark-gfm": "^4.0.1", "shiki": "^4.1.0", "sonner": "^2.0.7", - "sse.js": "^2.7.2", - "streamdown": "^2.0.1", + "sse.js": "^2.8.0", + "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "tokenlens": "^1.3.1", @@ -94,6 +94,7 @@ "js-cookie": "3.0.7", "mermaid": "11.15.0", "minimist": "1.2.8", + "postcss": "8.5.15", "qs": "6.15.2", "uuid": "14.0.0", }, diff --git a/web/default/package.json b/web/default/package.json index 244b5293..1e228d51 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -38,9 +38,9 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.3.0", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "i18next": "^26.2.0", - "i18next-browser-languagedetector": "^8.2.0", + "i18next-browser-languagedetector": "^8.2.1", "input-otp": "^1.4.2", "lucide-react": "^1.16.0", "motion": "^12.40.0", @@ -52,7 +52,7 @@ "react-dom": "^19.2.6", "react-hook-form": "^7.76.1", "react-i18next": "^17.0.8", - "react-icons": "^5.5.0", + "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.2", "react-top-loading-bar": "^3.0.2", @@ -61,8 +61,8 @@ "remark-gfm": "^4.0.1", "shiki": "^4.1.0", "sonner": "^2.0.7", - "sse.js": "^2.7.2", - "streamdown": "^2.0.1", + "sse.js": "^2.8.0", + "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.0", "tokenlens": "^1.3.1", @@ -106,6 +106,7 @@ "js-cookie": "3.0.7", "mermaid": "11.15.0", "minimist": "1.2.8", + "postcss": "8.5.15", "qs": "6.15.2", "uuid": "14.0.0" } diff --git a/web/default/src/components/layout/components/authenticated-layout.tsx b/web/default/src/components/layout/components/authenticated-layout.tsx index a39b4e19..129625ee 100644 --- a/web/default/src/components/layout/components/authenticated-layout.tsx +++ b/web/default/src/components/layout/components/authenticated-layout.tsx @@ -45,6 +45,7 @@ export function AuthenticatedLayout(props: AuthenticatedLayoutProps) { className={cn( '@container/content', 'h-[calc(100svh-var(--app-header-height,0px))]', + 'min-h-0 overflow-hidden', 'peer-data-[variant=inset]:h-[calc(100svh-var(--app-header-height,0px)-(var(--spacing)*4))]' )} > diff --git a/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx b/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx index 9df5f481..d36dccaf 100644 --- a/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx +++ b/web/default/src/features/auth/forgot-password/components/forgot-password-form.tsx @@ -67,6 +67,7 @@ export function ForgotPasswordForm({ resolver: zodResolver(forgotPasswordFormSchema), defaultValues: { email: '' }, }) + const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken) async function onSubmit(data: z.infer) { if (!validateTurnstile()) return @@ -78,6 +79,8 @@ export function ForgotPasswordForm({ form.reset() startCountdown() toast.success(t('Reset email sent, please check your inbox')) + } else { + toast.error(res?.message || t('Failed to send reset email')) } } catch (_error) { // Errors are handled by global interceptor @@ -107,8 +110,14 @@ export function ForgotPasswordForm({ )} /> - diff --git a/web/default/src/features/auth/hooks/use-email-verification.ts b/web/default/src/features/auth/hooks/use-email-verification.ts index d973de6e..e88038ed 100644 --- a/web/default/src/features/auth/hooks/use-email-verification.ts +++ b/web/default/src/features/auth/hooks/use-email-verification.ts @@ -61,6 +61,9 @@ export function useEmailVerification(options?: UseEmailVerificationOptions) { toast.success(i18next.t('Verification email sent')) return true } + toast.error( + res?.message || i18next.t('Failed to send verification email') + ) return false } catch (_error) { // Errors are handled by global interceptor diff --git a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx index 1b4e2348..46591ccc 100644 --- a/web/default/src/features/auth/sign-in/components/user-auth-form.tsx +++ b/web/default/src/features/auth/sign-in/components/user-auth-form.tsx @@ -81,6 +81,10 @@ export function UserAuthForm({ const passkeyLoginEnabled = Boolean( status?.passkey_login ?? status?.data?.passkey_login ) + const passwordLoginEnabled = + (status?.password_login_enabled ?? + status?.data?.password_login_enabled ?? + true) !== false const { isTurnstileEnabled, turnstileSiteKey, @@ -98,6 +102,16 @@ export function UserAuthForm({ !passkeySupported || (requiresLegalConsent && !agreedToLegal) const hasWeChatLogin = Boolean(status?.wechat_login) + const hasOAuthLogin = Boolean( + status?.github_oauth || + status?.discord_oauth || + status?.oidc_enabled || + status?.linuxdo_oauth || + status?.telegram_oauth || + (status?.custom_oauth_providers?.length ?? 0) > 0 + ) + const hasAlternativeLogin = + passkeyLoginEnabled || hasWeChatLogin || hasOAuthLogin useEffect(() => { if (requiresLegalConsent) { @@ -275,6 +289,42 @@ export function UserAuthForm({ } } + const alternativeLoginMethods = ( + <> + {passkeyLoginEnabled && ( +
+ + {!passkeySupported && ( +

+ {t('Passkey is not supported on this device.')} +

+ )} +
+ )} + + {/* OAuth Providers */} + + + ) + return (
- {/* Username Field */} - ( - - {t('Username or Email')} - - - - - - )} - /> + {hasAlternativeLogin && alternativeLoginMethods} - {/* Password Field */} - ( - - {t('Password')} - - - - - - {t('Forgot password?')} - - - )} - /> - - {/* Submit Button */} - - - {/* Turnstile */} - {isTurnstileEnabled && ( -
- + {/* Username Field */} + ( + + {t('Username or Email')} + + + + + + )} /> -
+ + {/* Password Field */} + ( + + {t('Password')} + + + + + + {t('Forgot password?')} + + + )} + /> + + {/* Submit Button */} + + + {/* Turnstile */} + {isTurnstileEnabled && ( +
+ +
+ )} + )} - {passkeyLoginEnabled && ( -
- - {!passkeySupported && ( -

- {t('Passkey is not supported on this device.')} -

- )} -
- )} - - {/* OAuth Providers */} - + {!hasAlternativeLogin && alternativeLoginMethods} {hasWeChatLogin && ( diff --git a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx index 1e7cb5a8..9466c15e 100644 --- a/web/default/src/features/auth/sign-up/components/sign-up-form.tsx +++ b/web/default/src/features/auth/sign-up/components/sign-up-form.tsx @@ -53,7 +53,10 @@ import { registerFormSchema } from '@/features/auth/constants' import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect' import { useEmailVerification } from '@/features/auth/hooks/use-email-verification' import { useTurnstile } from '@/features/auth/hooks/use-turnstile' -import { getAffiliateCode } from '@/features/auth/lib/storage' +import { + getAffiliateCode, + saveAffiliateCode, +} from '@/features/auth/lib/storage' export function SignUpForm({ className, @@ -107,6 +110,7 @@ export function SignUpForm({ status?.data?.oauth_register_enabled ?? true const hasWeChatLogin = Boolean(status?.wechat_login) + const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken) const wechatQrCodeUrl = useMemo(() => { return ( @@ -130,6 +134,13 @@ export function SignUpForm({ } }, [requiresLegalConsent]) + useEffect(() => { + const aff = new URLSearchParams(window.location.search).get('aff')?.trim() + if (aff) { + saveAffiliateCode(aff) + } + }, []) + async function onSubmit(data: z.infer) { if (requiresLegalConsent && !agreedToLegal) { toast.error(legalConsentErrorMessage) @@ -164,6 +175,8 @@ export function SignUpForm({ if (res?.success) { toast.success(t('Account created! Please sign in')) redirectToLogin() + } else { + toast.error(res?.message || t('Failed to create account')) } } catch (_error) { // Errors are handled by global interceptor @@ -307,7 +320,13 @@ export function SignUpForm({