feat(auth): require verified email for approval

This commit is contained in:
2026-05-06 20:57:54 +08:00
parent f2554c7e56
commit 6afc5817a7
26 changed files with 944 additions and 28 deletions
+11 -4
View File
@@ -1,5 +1,6 @@
import { apiClient } from './client'
import type {
AdminApprovalResponse,
AdminStats,
CheckInRecord,
CheckInRecordStatus,
@@ -45,6 +46,8 @@ export const userApi = {
me: () => apiClient.get<User>('/api/users/me'),
status: () => apiClient.get<UserStatus>('/api/users/me/status'),
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
@@ -56,8 +59,12 @@ export const userApi = {
apiClient.post<User>('/api/users', payload),
update: (
userId: number,
payload: Partial<User> & { password?: string; reset_password?: boolean },
) => apiClient.put<User>(`/api/users/${userId}`, payload),
payload: Partial<User> & {
password?: string
reset_password?: boolean
allow_unverified_email?: boolean
},
) => apiClient.put<User | AdminApprovalResponse>(`/api/users/${userId}`, payload),
delete: (userId: number) => apiClient.delete<void>(`/api/users/${userId}`),
}
@@ -104,8 +111,8 @@ export const templateApi = {
export const adminApi = {
pendingUsers: () => apiClient.get<User[]>('/api/admin/users/pending'),
approveUser: (userId: number) =>
apiClient.post<{ success: boolean; message: string }>(`/api/admin/users/${userId}/approve`),
approveUser: (userId: number, payload: { allow_unverified_email?: boolean } = {}) =>
apiClient.post<AdminApprovalResponse>(`/api/admin/users/${userId}/approve`, payload),
rejectUser: (userId: number) =>
apiClient.delete<{ success: boolean; message: string }>(`/api/admin/users/${userId}/reject`),
stats: () => apiClient.get<AdminStats>('/api/admin/stats'),
+16
View File
@@ -7,6 +7,8 @@ export interface User {
is_approved: boolean
jwt_exp: string
email?: string | null
email_verified?: boolean
email_verified_at?: string | null
has_password?: boolean
created_at: string
updated_at?: string | null
@@ -35,6 +37,8 @@ export interface AuthUserPayload {
is_approved?: boolean
jwt_exp?: string
email?: string | null
email_verified?: boolean
email_verified_at?: string | null
has_password?: boolean
created_at?: string
updated_at?: string | null
@@ -210,6 +214,8 @@ export interface EmailNotificationSettings {
smtp_use_ssl: boolean
notify_token_expiring: boolean
notify_check_in_success: boolean
require_admin_approval_for_registration: boolean
warn_unverified_email_before_approval: boolean
has_smtp_sender_password: boolean
created_at?: string | null
updated_at?: string | null
@@ -222,10 +228,20 @@ export interface EmailNotificationSettingsUpdate {
smtp_use_ssl: boolean
notify_token_expiring: boolean
notify_check_in_success: boolean
require_admin_approval_for_registration: boolean
warn_unverified_email_before_approval: boolean
smtp_sender_password?: string
clear_smtp_sender_password?: boolean
}
export interface AdminApprovalResponse {
success: boolean
message: string
user_id?: number
requires_override?: boolean
warning_code?: string
}
export interface CronValidation {
valid: boolean
message: string
+2
View File
@@ -33,6 +33,8 @@ function userFromLogin(payload: LoginResponse): User | null {
is_approved: raw?.is_approved ?? payload.is_approved ?? false,
jwt_exp: raw?.jwt_exp ?? '',
email: raw?.email ?? null,
email_verified: raw?.email_verified ?? false,
email_verified_at: raw?.email_verified_at ?? null,
has_password: raw?.has_password,
created_at: raw?.created_at ?? new Date().toISOString(),
updated_at: raw?.updated_at ?? null,
+108 -3
View File
@@ -1,17 +1,26 @@
<script setup lang="ts">
import { RefreshCw } from 'lucide-vue-next'
import { ref } from 'vue'
import { MailCheck, RefreshCw, Send } from 'lucide-vue-next'
import { computed, reactive, ref } from 'vue'
import { userApi } from '@/api'
import { useAuth } from '@/app/auth'
import { useRouter } from '@/app/router'
import { alertClass, cardClass, toneClass } from '@/components/ui'
import { alertClass, cardClass, inputClass, labelClass, toneClass } from '@/components/ui'
import { Button } from '@/components/ui/button'
import { extractErrorMessage, formatFullDateTime } from '@/utils/format'
const auth = useAuth()
const router = useRouter()
const loading = ref(false)
const sendingCode = ref(false)
const verifying = ref(false)
const error = ref('')
const emailMessage = ref('')
const emailForm = reactive({
email: auth.state.user?.email ?? '',
code: '',
})
const emailVerified = computed(() => Boolean(auth.state.user?.email_verified))
async function refresh() {
loading.value = true
@@ -28,6 +37,37 @@ async function refresh() {
loading.value = false
}
}
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
emailMessage.value = '验证码已发送,请检查邮箱'
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
sendingCode.value = false
}
}
async function verifyEmail() {
verifying.value = true
error.value = ''
emailMessage.value = ''
try {
const user = await userApi.verifyEmail(emailForm.code)
auth.state.user = user
emailMessage.value = '邮箱已验证,账号已进入正常审批流程'
} catch (err) {
error.value = extractErrorMessage(err)
} finally {
verifying.value = false
}
}
</script>
<template>
@@ -39,6 +79,9 @@ async function refresh() {
当前账号
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录但还需要管理员审批后才能访问工作台
</p>
<p class="mt-3 text-sm font-medium text-[var(--tone-warning-fg)]">
请填写并验证邮箱只有邮箱完成验证后账号才会进入正常审批流程未验证的待审批账号可能会在清理窗口后自动吊销
</p>
</div>
<div class="p-6">
<dl class="mt-5 grid gap-3 sm:grid-cols-2">
@@ -52,7 +95,69 @@ async function refresh() {
<dt class="text-xs text-muted-foreground">审批状态</dt>
<dd class="mt-1 text-sm font-medium">待审批</dd>
</div>
<div class="rounded-md border border-border bg-background p-3">
<dt class="text-xs text-muted-foreground">邮箱状态</dt>
<dd class="mt-1 text-sm font-medium">
{{ emailVerified ? '已验证' : '未验证' }}
</dd>
</div>
</dl>
<form class="mt-5 grid gap-4 rounded-md border border-border bg-background p-4">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="font-semibold">邮箱验证</h3>
</div>
<span :class="toneClass(emailVerified ? 'success' : 'warning')">
{{ emailVerified ? '已验证' : '待验证' }}
</span>
</div>
<label class="grid gap-2">
<span :class="labelClass">邮箱</span>
<input
v-model.trim="emailForm.email"
:class="inputClass"
type="email"
placeholder="用于审批通知"
:disabled="emailVerified"
/>
</label>
<div class="grid gap-3 sm: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="请输入邮箱验证码"
:disabled="emailVerified"
/>
</label>
<div class="flex items-end gap-2">
<Button
variant="outline"
type="button"
:disabled="sendingCode || emailVerified || !emailForm.email"
@click="requestEmailCode"
>
<Send class="size-4" :class="{ 'animate-spin': sendingCode }" />
发送验证码
</Button>
<Button
type="button"
:disabled="verifying || emailVerified || !emailForm.code"
@click="verifyEmail"
>
<MailCheck class="size-4" :class="{ 'animate-spin': verifying }" />
验证
</Button>
</div>
</div>
<div v-if="emailMessage" :class="alertClass.success">
{{ emailMessage }}
</div>
</form>
<div v-if="error" :class="[alertClass.danger, 'mt-4']">
{{ error }}
</div>
@@ -28,6 +28,8 @@ const form = reactive({
smtp_use_ssl: true,
notify_token_expiring: true,
notify_check_in_success: true,
require_admin_approval_for_registration: true,
warn_unverified_email_before_approval: true,
})
const passwordState = computed(() => {
@@ -47,6 +49,8 @@ function hydrate(next: EmailNotificationSettings) {
form.smtp_use_ssl = next.smtp_use_ssl
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
}
async function load() {
@@ -63,6 +67,12 @@ async function load() {
}
async function save() {
if (!form.require_admin_approval_for_registration) {
const ok = window.confirm(
'关闭管理员审批后,新注册用户可能绕过人工审核直接进入系统。确认关闭管理员审批?',
)
if (!ok) return
}
saving.value = true
error.value = ''
savedMessage.value = ''
@@ -74,6 +84,8 @@ async function save() {
smtp_use_ssl: form.smtp_use_ssl,
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,
smtp_sender_password: form.smtp_sender_password || undefined,
clear_smtp_sender_password: form.clear_smtp_sender_password,
})
@@ -205,6 +217,34 @@ onMounted(load)
<span class="text-sm text-muted-foreground">只影响过期前提醒不影响已过期通知</span>
</label>
<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.require_admin_approval_for_registration"
type="checkbox"
class="size-4 accent-primary"
/>
</span>
<span class="text-sm text-muted-foreground"
>默认开启关闭管理员审批会让新注册用户绕过人工审核</span
>
</label>
<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"
type="checkbox"
class="size-4 accent-primary"
/>
</span>
<span class="text-sm text-muted-foreground"
>开启后管理员审批邮箱未验证用户时需要确认覆盖</span
>
</label>
<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">
打卡成功通知
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { Check, Save, Search, Trash2, UserPlus } from 'lucide-vue-next'
import { onMounted, reactive, ref } from 'vue'
import { adminApi, userApi, type User } from '@/api'
import { adminApi, userApi, type AdminApprovalResponse, type User } from '@/api'
import StateBlock from '@/components/StateBlock.vue'
import {
alertClass,
@@ -27,6 +27,12 @@ const form = reactive({
is_approved: true,
})
function requiresUnverifiedEmailOverride(
result: User | AdminApprovalResponse,
): result is AdminApprovalResponse {
return 'requires_override' in result && result.warning_code === 'UNVERIFIED_EMAIL'
}
async function load() {
loading.value = true
error.value = ''
@@ -40,7 +46,12 @@ async function load() {
}
async function approve(userId: number) {
await adminApi.approveUser(userId)
const result = await adminApi.approveUser(userId)
if (requiresUnverifiedEmailOverride(result)) {
const ok = window.confirm('邮箱未验证,审批后不会发送审批通知。确认无视邮箱条件继续审批?')
if (!ok) return
await adminApi.approveUser(userId, { allow_unverified_email: true })
}
await load()
}
@@ -81,7 +92,12 @@ async function save() {
if (editingId.value === 'new') {
await userApi.create(payload)
} else if (typeof editingId.value === 'number') {
await userApi.update(editingId.value, payload)
const result = await userApi.update(editingId.value, payload)
if (requiresUnverifiedEmailOverride(result)) {
const ok = window.confirm('邮箱未验证,审批后不会发送审批通知。确认无视邮箱条件继续审批?')
if (!ok) return
await userApi.update(editingId.value, { ...payload, allow_unverified_email: true })
}
}
editingId.value = null
await load()
@@ -145,6 +161,7 @@ onMounted(load)
</div>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-muted-foreground">
<span>{{ user.email || '未设置邮箱' }}</span>
<span>{{ user.email_verified ? '邮箱已验证' : '邮箱未验证' }}</span>
<span>{{ formatDateTime(user.created_at) }}</span>
</div>
</div>