Add user registration with jingrow cloud integration
This commit is contained in:
parent
03d6b988e2
commit
a288a74a6f
@ -10,6 +10,12 @@ const router = createRouter({
|
||||
component: () => import('../../views/auth/Login.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
name: 'Signup',
|
||||
component: () => import('../../views/auth/Signup.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'AppLayout',
|
||||
@ -250,7 +256,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.isLoggedIn) {
|
||||
} else if ((to.path === '/login' || to.path === '/signup') && authStore.isLoggedIn) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
|
||||
@ -28,13 +28,27 @@
|
||||
"Username": "用户名",
|
||||
"Password": "密码",
|
||||
"Login": "登录",
|
||||
"Sign up": "注册",
|
||||
"Connect to Jingrow SaaS platform": "连接到 Jingrow SaaS 平台",
|
||||
"Please enter username": "请输入用户名",
|
||||
"Please enter password": "请输入密码",
|
||||
"Password must be at least 6 characters": "密码长度不能少于6位",
|
||||
"Username must be at least 3 characters": "用户名至少需要3个字符",
|
||||
"Login successful": "登录成功",
|
||||
"Login failed": "登录失败",
|
||||
"Login failed, please check username and password": "登录失败,请检查用户名和密码",
|
||||
"Create your account": "创建您的账户",
|
||||
"Confirm Password": "确认密码",
|
||||
"Please confirm password": "请确认密码",
|
||||
"Passwords do not match": "两次输入的密码不一致",
|
||||
"Email (Optional)": "邮箱(可选)",
|
||||
"Phone Number (Optional)": "手机号(可选)",
|
||||
"Please enter a valid email address": "请输入有效的邮箱地址",
|
||||
"Please enter a valid phone number": "请输入有效的手机号码",
|
||||
"Sign up successful": "注册成功",
|
||||
"Sign up failed": "注册失败",
|
||||
"Sign up failed, please try again": "注册失败,请重试",
|
||||
"Already have an account?": "已有账户?",
|
||||
"System and personal settings": "系统配置和个人设置",
|
||||
"Personal Settings": "个人设置",
|
||||
"Email": "邮箱",
|
||||
|
||||
@ -110,6 +110,47 @@ export const logoutApi = async (): Promise<void> => {
|
||||
}
|
||||
}
|
||||
|
||||
// 注册接口
|
||||
export interface SignupRequest {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
phone_number?: string
|
||||
}
|
||||
|
||||
export interface SignupResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const signupApi = async (data: SignupRequest): Promise<SignupResponse> => {
|
||||
const response = await fetch(`/jingrow/signup`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
|
||||
const result = await response.json().catch(() => ({}))
|
||||
|
||||
// 如果响应不成功,提取错误消息
|
||||
if (!response.ok) {
|
||||
const errorMsg = result.detail || result.message || '注册请求失败'
|
||||
return { success: false, error: errorMsg }
|
||||
}
|
||||
|
||||
// 检查业务逻辑错误(success 为 false)
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.message || result.detail || '注册失败' }
|
||||
}
|
||||
|
||||
return { success: true, message: result.message || '注册成功' }
|
||||
}
|
||||
|
||||
// 仅使用会话Cookie的最小鉴权头部(不影响现有API Key逻辑)
|
||||
export function get_session_api_headers() {
|
||||
return {
|
||||
|
||||
@ -12,7 +12,8 @@
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
size="large"
|
||||
size="medium"
|
||||
:show-label="false"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<n-form-item path="username">
|
||||
@ -48,14 +49,18 @@
|
||||
block
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
class="brand-button"
|
||||
>
|
||||
{{ t('Login') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<div class="login-footer" v-if="showSignupLink">
|
||||
<n-text depth="3">
|
||||
<router-link to="/signup" class="signup-link">
|
||||
{{ t('Sign up') }}
|
||||
</router-link>
|
||||
</n-text>
|
||||
</div>
|
||||
</div>
|
||||
@ -76,6 +81,7 @@ const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
const showSignupLink = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
@ -123,6 +129,20 @@ onMounted(async () => {
|
||||
// 如果已经登录,直接跳转
|
||||
if (authStore.isLoggedIn) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查服务器配置,判断是否显示注册链接
|
||||
try {
|
||||
const response = await fetch('/jingrow/server-config')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.success && data.jingrow_server_url === 'https://cloud.jingrow.com') {
|
||||
showSignupLink.value = true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get server config:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@ -139,7 +159,7 @@ onMounted(async () => {
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-width: 480px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
@ -148,7 +168,26 @@ onMounted(async () => {
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
:deep(.n-form-item) {
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
:deep(.n-form-item:last-child) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.n-form-item:not(.n-form-item--error) .n-form-item__feedback-wrapper) {
|
||||
min-height: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.n-form-item--error .n-form-item__feedback-wrapper) {
|
||||
margin-top: 4px !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@ -172,4 +211,31 @@ onMounted(async () => {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.signup-link {
|
||||
color: #18a058;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.signup-link:hover {
|
||||
color: #36ad6a;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:deep(.brand-button) {
|
||||
background-color: #18a058 !important;
|
||||
border-color: #18a058 !important;
|
||||
}
|
||||
|
||||
:deep(.brand-button:hover) {
|
||||
background-color: #36ad6a !important;
|
||||
border-color: #36ad6a !important;
|
||||
}
|
||||
|
||||
:deep(.brand-button:focus) {
|
||||
background-color: #18a058 !important;
|
||||
border-color: #18a058 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
323
apps/jingrow/frontend/src/views/auth/Signup.vue
Normal file
323
apps/jingrow/frontend/src/views/auth/Signup.vue
Normal file
@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="signup-container">
|
||||
<div class="signup-card">
|
||||
<div class="signup-header">
|
||||
<div class="logo">
|
||||
<img src="/logo.svg" :alt="appName" width="48" height="48" />
|
||||
</div>
|
||||
<h1 class="title">{{ appName }}</h1>
|
||||
</div>
|
||||
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
size="medium"
|
||||
:show-label="false"
|
||||
@keyup.enter="handleSignup"
|
||||
>
|
||||
<n-form-item path="username">
|
||||
<n-input
|
||||
v-model:value="formData.username"
|
||||
:placeholder="t('Username')"
|
||||
:input-props="{ autocomplete: 'username' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="tabler:user" />
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="password">
|
||||
<n-input
|
||||
v-model:value="formData.password"
|
||||
type="password"
|
||||
:placeholder="t('Password')"
|
||||
:input-props="{ autocomplete: 'new-password' }"
|
||||
show-password-on="click"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="tabler:lock" />
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="confirmPassword">
|
||||
<n-input
|
||||
v-model:value="formData.confirmPassword"
|
||||
type="password"
|
||||
:placeholder="t('Confirm Password')"
|
||||
:input-props="{ autocomplete: 'new-password' }"
|
||||
show-password-on="click"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="tabler:lock" />
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="email">
|
||||
<n-input
|
||||
v-model:value="formData.email"
|
||||
:placeholder="t('Email (Optional)')"
|
||||
:input-props="{ autocomplete: 'email', type: 'email' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="tabler:mail" />
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item path="phoneNumber">
|
||||
<n-input
|
||||
v-model:value="formData.phoneNumber"
|
||||
:placeholder="t('Phone Number (Optional)')"
|
||||
:input-props="{ autocomplete: 'tel' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="tabler:phone" />
|
||||
</template>
|
||||
</n-input>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item>
|
||||
<n-button
|
||||
type="primary"
|
||||
size="medium"
|
||||
block
|
||||
:loading="loading"
|
||||
@click="handleSignup"
|
||||
class="brand-button"
|
||||
>
|
||||
{{ t('Sign up') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<div class="signup-footer">
|
||||
<n-text depth="3">
|
||||
{{ t('Already have an account?') }}
|
||||
<router-link to="/login" class="login-link">
|
||||
{{ t('Login') }}
|
||||
</router-link>
|
||||
</n-text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NForm, NFormItem, NInput, NButton, NText, useMessage } from 'naive-ui'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useAuthStore } from '../../shared/stores/auth'
|
||||
import { t } from '../../shared/i18n'
|
||||
import { signupApi } from '../../shared/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const formRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
email: '',
|
||||
phoneNumber: ''
|
||||
})
|
||||
|
||||
const validatePasswordMatch = (_rule: any, value: string) => {
|
||||
if (value !== formData.password) {
|
||||
return new Error(t('Passwords do not match'))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: t('Please enter username'), trigger: 'blur' },
|
||||
{ min: 3, message: t('Username must be at least 3 characters'), trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: t('Please enter password'), trigger: 'blur' },
|
||||
{ min: 6, message: t('Password must be at least 6 characters'), trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: t('Please confirm password'), trigger: 'blur' },
|
||||
{ validator: validatePasswordMatch, trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{
|
||||
validator: (_rule: any, value: string) => {
|
||||
if (!value) return true
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(value)) {
|
||||
return new Error(t('Please enter a valid email address'))
|
||||
}
|
||||
return true
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
phoneNumber: [
|
||||
{
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: t('Please enter a valid phone number'),
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const handleSignup = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
const result = await signupApi({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email || undefined,
|
||||
phone_number: formData.phoneNumber || undefined
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
message.success(t('Sign up successful'))
|
||||
// 注册成功后自动登录
|
||||
const loginResult = await authStore.login(formData.username, formData.password)
|
||||
if (loginResult.success) {
|
||||
router.push('/')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
} else {
|
||||
message.error(result.error || t('Sign up failed'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Signup error:', error)
|
||||
message.error(error.message || t('Sign up failed, please try again'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 从 localStorage 读取应用名称,默认 Jingrow,与侧边栏保持一致
|
||||
const appName = computed(() => localStorage.getItem('appName') || 'Jingrow')
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化认证状态
|
||||
await authStore.initAuth()
|
||||
|
||||
// 如果已经登录,直接跳转
|
||||
if (authStore.isLoggedIn) {
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signup-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.signup-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
max-height: calc(100vh - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.signup-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.n-form-item) {
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
:deep(.n-form-item:last-child) {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* 只在有错误时才显示反馈区域,减少默认间距 */
|
||||
:deep(.n-form-item:not(.n-form-item--error) .n-form-item__feedback-wrapper) {
|
||||
min-height: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.n-form-item--error .n-form-item__feedback-wrapper) {
|
||||
margin-top: 4px !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.signup-footer {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
color: #18a058;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.login-link:hover {
|
||||
color: #36ad6a;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:deep(.brand-button) {
|
||||
background-color: #18a058 !important;
|
||||
border-color: #18a058 !important;
|
||||
}
|
||||
|
||||
:deep(.brand-button:hover) {
|
||||
background-color: #36ad6a !important;
|
||||
border-color: #36ad6a !important;
|
||||
}
|
||||
|
||||
:deep(.brand-button:focus) {
|
||||
background-color: #18a058 !important;
|
||||
border-color: #18a058 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -4,7 +4,8 @@ from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from jingrow.utils.auth import login, logout, get_user_info, set_context
|
||||
from jingrow.utils.auth import login, logout, get_user_info, set_context, get_jingrow_cloud_url, get_jingrow_cloud_api_headers
|
||||
from jingrow.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -13,6 +14,18 @@ class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
|
||||
class SignupResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
user: Optional[dict] = None
|
||||
session_cookie: Optional[str] = None
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@ -111,6 +124,83 @@ async def logout_route(session_cookie: Optional[str] = Depends(get_session_cooki
|
||||
logger.error(f"登出异常: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"登出异常: {str(e)}")
|
||||
|
||||
@router.post("/jingrow/signup", response_model=SignupResponse)
|
||||
async def signup_route(signup_data: SignupRequest):
|
||||
"""注册路由"""
|
||||
import requests
|
||||
import json
|
||||
|
||||
try:
|
||||
# 调用 jcloud signup_with_username API
|
||||
url = f"{get_jingrow_cloud_url()}/api/action/jcloud.api.account.signup_with_username"
|
||||
data = {"username": signup_data.username, "password": signup_data.password}
|
||||
if signup_data.email:
|
||||
data["email"] = signup_data.email
|
||||
if signup_data.phone_number:
|
||||
data["phone_number"] = signup_data.phone_number
|
||||
|
||||
response = requests.post(url, headers=get_jingrow_cloud_api_headers(), json=data, timeout=30)
|
||||
|
||||
# 提取错误消息的辅助函数
|
||||
def extract_error_msg(resp: requests.Response) -> str:
|
||||
try:
|
||||
error_data = resp.json()
|
||||
if isinstance(error_data, dict) and error_data.get("exc"):
|
||||
exc_str = error_data.get("exc")
|
||||
try:
|
||||
exc_list = json.loads(exc_str)
|
||||
return str(exc_list[0]) if isinstance(exc_list, list) and exc_list else str(exc_str)
|
||||
except:
|
||||
return str(exc_str)
|
||||
except:
|
||||
pass
|
||||
return f"HTTP {resp.status_code}"
|
||||
|
||||
# 如果状态码不是 200,提取错误消息
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail=extract_error_msg(response))
|
||||
|
||||
# 解析响应
|
||||
try:
|
||||
result = response.json()
|
||||
except:
|
||||
raise HTTPException(status_code=400, detail="注册失败:无法解析服务器响应")
|
||||
|
||||
# 检查是否有错误信息(exc 字段)
|
||||
if isinstance(result, dict) and result.get("exc"):
|
||||
raise HTTPException(status_code=400, detail=extract_error_msg(response))
|
||||
|
||||
# 注册成功后自动登录
|
||||
login_result = login(signup_data.username, signup_data.password)
|
||||
if not login_result.get("success"):
|
||||
raise HTTPException(status_code=400, detail="注册成功但自动登录失败")
|
||||
|
||||
session_cookie = login_result.get("session_cookie")
|
||||
user_info = None
|
||||
|
||||
# 如果注册成功且有 session cookie,获取用户信息
|
||||
if session_cookie:
|
||||
user_info_result = get_user_info(session_cookie)
|
||||
user_info = user_info_result.get("user_info") if user_info_result.get("success") else None
|
||||
|
||||
return create_response_with_cookie({
|
||||
"success": True,
|
||||
"message": "注册成功",
|
||||
"user": user_info
|
||||
}, session_cookie)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"注册异常: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"注册异常: {str(e)}")
|
||||
|
||||
@router.get("/jingrow/server-config")
|
||||
async def get_server_config():
|
||||
return {
|
||||
"success": True,
|
||||
"jingrow_server_url": Config.jingrow_server_url
|
||||
}
|
||||
|
||||
@router.get("/jingrow/user-info", response_model=UserInfoResponse)
|
||||
async def get_user_info_route(session_cookie: Optional[str] = Depends(get_session_cookie)):
|
||||
"""获取用户信息路由"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user