Implement registration whitelist flow

This commit is contained in:
Tim
2025-07-14 22:03:45 +08:00
parent c9c96edcb0
commit e4e83197d2
28 changed files with 353 additions and 47 deletions

View File

@@ -5,6 +5,7 @@ import AboutPageView from '../views/AboutPageView.vue'
import PostPageView from '../views/PostPageView.vue'
import LoginPageView from '../views/LoginPageView.vue'
import SignupPageView from '../views/SignupPageView.vue'
import SignupReasonPageView from '../views/SignupReasonPageView.vue'
import NewPostPageView from '../views/NewPostPageView.vue'
import SettingsPageView from '../views/SettingsPageView.vue'
import ProfileView from '../views/ProfileView.vue'
@@ -46,6 +47,11 @@ const routes = [
name: 'signup',
component: SignupPageView
},
{
path: '/signup-reason',
name: 'signup-reason',
component: SignupReasonPageView
},
{
path: '/settings',
name: 'settings',

View File

@@ -1,7 +1,7 @@
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
export function googleSignIn(redirect) {
export function googleSignIn(redirect, reason) {
if (!window.google || !GOOGLE_CLIENT_ID) {
toast.error('Google 登录不可用')
return
@@ -13,7 +13,7 @@ export function googleSignIn(redirect) {
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken: credential })
body: JSON.stringify({ idToken: credential, reason })
})
const data = await res.json()
if (res.ok && data.token) {

View File

@@ -181,6 +181,15 @@
已提交审核
</NotificationContainer>
</template>
<template v-else-if="item.type === 'REGISTER_REQUEST'">
<NotificationContainer :item="item" :markRead="markRead">
{{ item.fromUser.username }} 希望注册为会员理由是{{ item.content }}
<template #actions v-if="authState.role === 'ADMIN'">
<button class="mark-read-button-item" @click="approve(item.fromUser.id, item.id)">同意</button>
<button class="mark-read-button-item" @click="reject(item.fromUser.id, item.id)">拒绝</button>
</template>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved">
<NotificationContainer :item="item" :markRead="markRead">
您发布的帖子
@@ -226,7 +235,7 @@ import { API_BASE_URL } from '../main'
import BaseTimeline from '../components/BaseTimeline.vue'
import BasePlaceholder from '../components/BasePlaceholder.vue'
import NotificationContainer from '../components/NotificationContainer.vue'
import { getToken } from '../utils/auth'
import { getToken, authState } from '../utils/auth'
import { markNotificationsRead } from '../utils/notification'
import { toast } from '../main'
import { stripMarkdown } from '../utils/markdown'
@@ -276,7 +285,8 @@ export default {
USER_FOLLOWED: 'fas fa-user-plus',
USER_UNFOLLOWED: 'fas fa-user-minus',
POST_SUBSCRIBED: 'fas fa-bookmark',
POST_UNSUBSCRIBED: 'fas fa-bookmark'
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock'
}
const reactionEmojiMap = {
@@ -393,7 +403,7 @@ export default {
}
}
})
} else if (n.type === 'POST_REVIEW_REQUEST') {
} else if (n.type === 'POST_REVIEW_REQUEST') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
@@ -405,6 +415,12 @@ export default {
}
}
})
} else if (n.type === 'REGISTER_REQUEST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {}
})
} else {
notifications.value.push({
...n,
@@ -417,6 +433,36 @@ export default {
}
}
const approve = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/approve`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
markRead(nid)
toast.success('已同意')
} else {
toast.error('操作失败')
}
}
const reject = async (id, nid) => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/users/${id}/reject`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
})
if (res.ok) {
markRead(nid)
toast.success('已拒绝')
} else {
toast.error('操作失败')
}
}
const formatType = t => {
switch (t) {
case 'POST_VIEWED':
@@ -456,10 +502,13 @@ export default {
sanitizeDescription,
isLoadingMessage,
markRead,
approve,
reject,
TimeManager,
selectedTab,
filteredNotifications,
markAllRead
markAllRead,
authState
}
}
}

View File

@@ -40,6 +40,10 @@
<div class="setting-title">AI 优化次数</div>
<Dropdown v-model="aiFormatLimit" :fetch-options="fetchAiLimits" />
</div>
<div class="form-row dropdown-row">
<div class="setting-title">注册模式</div>
<Dropdown v-model="registerMode" :fetch-options="fetchRegisterModes" />
</div>
</div>
<div class="buttons">
<div v-if="isSaving" class="save-button disabled">保存中...</div>
@@ -70,6 +74,7 @@ export default {
publishMode: 'DIRECT',
passwordStrength: 'LOW',
aiFormatLimit: 3,
registerMode: 'DIRECT',
isLoadingPage: false,
isSaving: false
}
@@ -122,6 +127,12 @@ export default {
{ id: -1, name: '无限' }
])
},
fetchRegisterModes() {
return Promise.resolve([
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' }
])
},
async loadAdminConfig() {
try {
const token = getToken()
@@ -133,6 +144,7 @@ export default {
this.publishMode = data.publishMode
this.passwordStrength = data.passwordStrength
this.aiFormatLimit = data.aiFormatLimit
this.registerMode = data.registerMode
}
} catch (e) {
// ignore
@@ -183,7 +195,7 @@ export default {
await fetch(`${API_BASE_URL}/api/admin/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ publishMode: this.publishMode, passwordStrength: this.passwordStrength, aiFormatLimit: this.aiFormatLimit })
body: JSON.stringify({ publishMode: this.publishMode, passwordStrength: this.passwordStrength, aiFormatLimit: this.aiFormatLimit, registerMode: this.registerMode })
})
}
toast.success('保存成功')

View File

@@ -94,6 +94,7 @@ export default {
email: '',
username: '',
password: '',
registerMode: 'DIRECT',
emailError: '',
usernameError: '',
passwordError: '',
@@ -103,6 +104,19 @@ export default {
isWaitingForEmailVerified: false
}
},
async mounted() {
try {
const res = await fetch(`${API_BASE_URL}/api/config`)
if (res.ok) {
const data = await res.json()
this.registerMode = data.registerMode
}
} catch {}
if (this.$route.query.verify) {
this.emailStep = 1
this.username = sessionStorage.getItem('signup_username') || ''
}
},
methods: {
clearErrors() {
this.emailError = ''
@@ -126,6 +140,13 @@ export default {
if (this.emailError || this.passwordError || this.usernameError) {
return
}
if (this.registerMode === 'WHITELIST') {
sessionStorage.setItem('signup_username', this.username)
sessionStorage.setItem('signup_email', this.email)
sessionStorage.setItem('signup_password', this.password)
this.$router.push('/signup-reason')
return
}
try {
console.log('base url: ', API_BASE_URL)
this.isWaitingForEmailSent = true
@@ -135,7 +156,7 @@ export default {
body: JSON.stringify({
username: this.username,
email: this.email,
password: this.password,
password: this.password
})
})
this.isWaitingForEmailSent = false
@@ -175,9 +196,13 @@ export default {
}
},
signupWithGoogle() {
googleSignIn(() => {
this.$router.push('/')
})
if (this.registerMode === 'WHITELIST') {
this.$router.push('/signup-reason?google=1')
} else {
googleSignIn(() => {
this.$router.push('/')
})
}
}
}
}

View File

@@ -0,0 +1,76 @@
<template>
<div class="reason-page">
<div class="reason-content">
<BaseInput textarea rows="4" v-model="reason" placeholder="请填写注册理由" />
<div v-if="error" class="error-message">{{ error }}</div>
<div class="signup-page-button-primary" @click="submit" >提交</div>
</div>
</div>
</template>
<script>
import BaseInput from '../components/BaseInput.vue'
import { API_BASE_URL, toast } from '../main'
import { googleSignIn } from '../utils/google'
export default {
name: 'SignupReasonPageView',
components: { BaseInput },
data() {
return {
reason: '',
error: '',
isGoogle: false
}
},
mounted() {
this.isGoogle = this.$route.query.google === '1'
if (!this.isGoogle) {
if (!sessionStorage.getItem('signup_username')) {
this.$router.push('/signup')
}
}
},
methods: {
async submit() {
if (!this.reason || this.reason.length < 20) {
this.error = '请至少输入20个字'
return
}
if (this.isGoogle) {
googleSignIn(() => { this.$router.push('/') }, this.reason)
return
}
try {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: sessionStorage.getItem('signup_username'),
email: sessionStorage.getItem('signup_email'),
password: sessionStorage.getItem('signup_password'),
reason: this.reason
})
})
const data = await res.json()
if (res.ok) {
toast.success('验证码已发送,请查收邮箱')
this.$router.push('/signup?verify=1')
} else {
toast.error(data.error || '发送失败')
}
} catch (e) {
toast.error('发送失败')
}
}
}
}
</script>
<style scoped>
.reason-page { display: flex; justify-content: center; align-items: center; height: calc(100vh - var(--header-height)); }
.reason-content { display: flex; flex-direction: column; gap: 20px; width: 400px; }
.error-message { color: red; font-size: 14px; }
.signup-page-button-primary { background-color: var(--primary-color); color: white; padding: 10px 20px; border-radius: 10px; text-align: center; cursor: pointer; }
.signup-page-button-primary:hover { background-color: var(--primary-color-hover); }
</style>