feat(email): require verified approval email

Backfill approved legacy users with verified emails and replace the old unverified-email warning setting with a single approval email policy.
This commit is contained in:
2026-05-06 22:12:23 +08:00
parent a17a913618
commit ce55cfc6b3
18 changed files with 328 additions and 55 deletions
+2 -6
View File
@@ -48,12 +48,8 @@ export const userApi = {
tokenStatus: () => apiClient.get<TokenStatus>('/api/users/me/token_status'),
setEmail: (email: string) => apiClient.put<User>('/api/users/me/email', { email }),
verifyEmail: (code: string) => apiClient.post<User>('/api/users/me/email/verify', { code }),
updateProfile: (payload: {
alias?: string
email?: string
current_password?: string
new_password?: string
}) => apiClient.put<User>('/api/users/me/profile', payload),
updateProfile: (payload: { alias?: string; current_password?: string; new_password?: string }) =>
apiClient.put<User>('/api/users/me/profile', payload),
list: (params: Record<string, unknown> = {}) => apiClient.get<User[]>('/api/users', params),
create: (payload: Partial<User> & { password?: string }) =>
apiClient.post<User>('/api/users', payload),
+2 -2
View File
@@ -215,7 +215,7 @@ export interface EmailNotificationSettings {
notify_token_expiring: boolean
notify_check_in_success: boolean
require_admin_approval_for_registration: boolean
warn_unverified_email_before_approval: boolean
require_verified_email_for_approval: boolean
has_smtp_sender_password: boolean
created_at?: string | null
updated_at?: string | null
@@ -229,7 +229,7 @@ export interface EmailNotificationSettingsUpdate {
notify_token_expiring: boolean
notify_check_in_success: boolean
require_admin_approval_for_registration: boolean
warn_unverified_email_before_approval: boolean
require_verified_email_for_approval: boolean
smtp_sender_password?: string
clear_smtp_sender_password?: boolean
}
+100 -9
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Save } from 'lucide-vue-next'
import { onMounted, reactive, ref } from 'vue'
import { MailCheck, Save, Send } from 'lucide-vue-next'
import { computed, onMounted, reactive, ref } from 'vue'
import { userApi, type TokenStatus } from '@/api'
import { useAuth } from '@/app/auth'
import StateBlock from '@/components/StateBlock.vue'
@@ -18,15 +18,22 @@ import { extractErrorMessage } from '@/utils/format'
const auth = useAuth()
const loading = ref(true)
const saving = ref(false)
const sendingCode = ref(false)
const verifyingEmail = ref(false)
const error = ref('')
const message = ref('')
const emailMessage = ref('')
const token = ref<TokenStatus | null>(null)
const form = reactive({
alias: '',
email: '',
current_password: '',
new_password: '',
})
const emailForm = reactive({
email: '',
code: '',
})
const emailVerified = computed(() => Boolean(auth.state.user?.email_verified))
async function load() {
loading.value = true
@@ -39,7 +46,7 @@ async function load() {
auth.state.user = user
token.value = tokenStatus
form.alias = user.alias
form.email = user.email ?? ''
emailForm.email = user.email ?? ''
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
@@ -54,7 +61,6 @@ async function save() {
try {
const user = await userApi.updateProfile({
alias: form.alias,
email: form.email || undefined,
current_password: form.current_password || undefined,
new_password: form.new_password || undefined,
})
@@ -69,6 +75,40 @@ async function save() {
}
}
async function requestEmailCode() {
sendingCode.value = true
error.value = ''
emailMessage.value = ''
try {
const user = await userApi.setEmail(emailForm.email)
auth.state.user = user
emailForm.email = user.email ?? emailForm.email
emailForm.code = ''
emailMessage.value = '验证码已发送,请检查邮箱'
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
sendingCode.value = false
}
}
async function verifyEmail() {
verifyingEmail.value = true
error.value = ''
emailMessage.value = ''
try {
const user = await userApi.verifyEmail(emailForm.code)
auth.state.user = user
emailForm.email = user.email ?? emailForm.email
emailForm.code = ''
emailMessage.value = '邮箱已验证'
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
verifyingEmail.value = false
}
}
onMounted(load)
</script>
@@ -92,10 +132,6 @@ onMounted(load)
<span :class="labelClass">别名</span>
<input v-model="form.alias" :class="inputClass" required />
</label>
<label class="grid gap-2">
<span :class="labelClass">邮箱</span>
<input v-model="form.email" :class="inputClass" type="email" placeholder="用于打卡通知" />
</label>
<div class="grid gap-4 md:grid-cols-2">
<label class="grid gap-2">
<span :class="labelClass">当前密码</span>
@@ -129,6 +165,61 @@ onMounted(load)
</div>
</form>
<section :class="[cardClass, 'overflow-hidden lg:col-span-2']">
<div :class="sectionHeaderClass">
<div class="flex items-center gap-2">
<h2 class="font-semibold">邮箱验证</h2>
<span :class="toneClass(emailVerified ? 'success' : 'warning')">
{{ emailVerified ? '已验证' : '待验证' }}
</span>
</div>
</div>
<div class="grid gap-4 p-4">
<label class="grid gap-2">
<span :class="labelClass">邮箱</span>
<input
v-model.trim="emailForm.email"
:class="inputClass"
type="email"
placeholder="用于审批与打卡通知"
/>
</label>
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<label class="grid gap-2">
<span :class="labelClass">验证码</span>
<input
v-model.trim="emailForm.code"
:class="inputClass"
inputmode="numeric"
placeholder="请输入邮箱验证码"
/>
</label>
<div class="flex items-end gap-2">
<Button
variant="outline"
type="button"
:disabled="sendingCode || !emailForm.email"
@click="requestEmailCode"
>
<Send class="size-4" :class="{ 'animate-spin': sendingCode }" />
发送验证码
</Button>
<Button
type="button"
:disabled="verifyingEmail || !emailForm.code"
@click="verifyEmail"
>
<MailCheck class="size-4" :class="{ 'animate-spin': verifyingEmail }" />
验证
</Button>
</div>
</div>
<div v-if="emailMessage" :class="alertClass.success">
{{ emailMessage }}
</div>
</div>
</section>
<aside :class="[cardClass, 'h-fit overflow-hidden']">
<div
class="grid gap-2 border-b px-4 py-3"
@@ -29,7 +29,7 @@ const form = reactive({
notify_token_expiring: true,
notify_check_in_success: true,
require_admin_approval_for_registration: true,
warn_unverified_email_before_approval: true,
require_verified_email_for_approval: true,
})
const passwordState = computed(() => {
@@ -50,7 +50,7 @@ function hydrate(next: EmailNotificationSettings) {
form.notify_token_expiring = next.notify_token_expiring
form.notify_check_in_success = next.notify_check_in_success
form.require_admin_approval_for_registration = next.require_admin_approval_for_registration
form.warn_unverified_email_before_approval = next.warn_unverified_email_before_approval
form.require_verified_email_for_approval = next.require_verified_email_for_approval
}
async function load() {
@@ -85,7 +85,7 @@ async function save() {
notify_token_expiring: form.notify_token_expiring,
notify_check_in_success: form.notify_check_in_success,
require_admin_approval_for_registration: form.require_admin_approval_for_registration,
warn_unverified_email_before_approval: form.warn_unverified_email_before_approval,
require_verified_email_for_approval: form.require_verified_email_for_approval,
smtp_sender_password: form.smtp_sender_password || undefined,
clear_smtp_sender_password: form.clear_smtp_sender_password,
})
@@ -233,15 +233,15 @@ onMounted(load)
<label class="grid gap-2 rounded-lg border border-border bg-background p-3">
<span class="flex items-center justify-between gap-3 text-sm font-medium">
未验证邮箱审批警告
审批前要求验证邮箱
<input
v-model="form.warn_unverified_email_before_approval"
v-model="form.require_verified_email_for_approval"
type="checkbox"
class="size-4 accent-primary"
/>
</span>
<span class="text-sm text-muted-foreground"
>开启后管理员审批邮箱未验证用户时需要确认覆盖</span
>开启后管理员审批未验证邮箱用户时每次都需要确认覆盖</span
>
</label>