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
@@ -0,0 +1,31 @@
from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.engine import Connection
def _table_columns(conn: Connection, table_name: str) -> set[str]:
rows = conn.execute(text(f"PRAGMA table_info({table_name})")).fetchall()
return {str(row[1]) for row in rows}
def apply(conn: Connection) -> None:
columns = _table_columns(conn, "email_notification_settings")
if "require_verified_email_for_approval" not in columns:
conn.execute(
text(
"ALTER TABLE email_notification_settings "
"ADD COLUMN require_verified_email_for_approval BOOLEAN NOT NULL DEFAULT 1"
)
)
conn.commit()
columns = _table_columns(conn, "email_notification_settings")
if "warn_unverified_email_before_approval" in columns:
conn.execute(
text(
"ALTER TABLE email_notification_settings "
"DROP COLUMN warn_unverified_email_before_approval"
)
)
conn.commit()
@@ -18,7 +18,7 @@ def apply(conn: Connection) -> None:
notify_token_expiring BOOLEAN NOT NULL DEFAULT 1,
notify_check_in_success BOOLEAN NOT NULL DEFAULT 1,
require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1,
warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1,
require_verified_email_for_approval BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
)
@@ -0,0 +1,24 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import text
from sqlalchemy.engine import Connection
def apply(conn: Connection) -> None:
verified_at = datetime.now(timezone.utc).isoformat()
conn.execute(
text(
"""
UPDATE users
SET email_verified_at = :verified_at
WHERE email_verified_at IS NULL
AND email IS NOT NULL
AND email != ''
AND is_approved = 1
"""
),
{"verified_at": verified_at},
)
conn.commit()
@@ -19,6 +19,16 @@ def _add_column_if_missing(
return columns
def _drop_column_if_present(
conn: Connection, table_name: str, columns: set[str], column_name: str
) -> set[str]:
if column_name in columns:
conn.execute(text(f"ALTER TABLE {table_name} DROP COLUMN {column_name}"))
conn.commit()
return _table_columns(conn, table_name)
return columns
def apply(conn: Connection) -> None:
user_columns = _table_columns(conn, "users")
user_columns = _add_column_if_missing(
@@ -51,10 +61,16 @@ def apply(conn: Connection) -> None:
"require_admin_approval_for_registration",
"require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1",
)
_add_column_if_missing(
settings_columns = _add_column_if_missing(
conn,
"email_notification_settings",
settings_columns,
"require_verified_email_for_approval",
"require_verified_email_for_approval BOOLEAN NOT NULL DEFAULT 1",
)
_drop_column_if_present(
conn,
"email_notification_settings",
settings_columns,
"warn_unverified_email_before_approval",
"warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1",
)
+16
View File
@@ -12,6 +12,12 @@ from backend.migration_steps.account_lockout import apply as apply_account_locko
from backend.migration_steps.email_notification_settings import (
apply as apply_email_notification_settings,
)
from backend.migration_steps.email_approval_policy import (
apply as apply_email_approval_policy,
)
from backend.migration_steps.legacy_user_email_verification import (
apply as apply_legacy_user_email_verification,
)
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
from backend.migration_steps.user_email_verification import (
apply as apply_user_email_verification,
@@ -102,6 +108,16 @@ MIGRATIONS: tuple[Migration, ...] = (
description="Add user email verification fields and registration approval policy flags.",
apply=apply_user_email_verification,
),
Migration(
id="2026050602_backfill_legacy_verified_emails",
description="Trust existing approved user email addresses after verification rollout.",
apply=apply_legacy_user_email_verification,
),
Migration(
id="2026050603_remove_legacy_email_approval_warning",
description="Replace legacy unverified-email warning setting with approval email policy.",
apply=apply_email_approval_policy,
),
)
+1 -1
View File
@@ -25,7 +25,7 @@ class EmailNotificationSettings(Base):
require_admin_approval_for_registration: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
warn_unverified_email_before_approval: Mapped[bool] = mapped_column(
require_verified_email_for_approval: Mapped[bool] = mapped_column(
Boolean, default=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(
+2 -2
View File
@@ -16,8 +16,8 @@ class EmailNotificationSettingsBase(BaseModel):
require_admin_approval_for_registration: bool = Field(
True, description="新注册是否需要管理员审批"
)
warn_unverified_email_before_approval: bool = Field(
True, description="审批未验证邮箱用户时是否警告"
require_verified_email_for_approval: bool = Field(
True, description="审批前是否要求用户完成邮箱验证"
)
@field_validator("smtp_server", "smtp_sender_email", mode="before")
+1 -1
View File
@@ -20,7 +20,7 @@ class AdminService:
email_changed = next_email is not None and next_email != user.email
has_verified_email = bool(user.email and user.email_verified_at and not email_changed)
should_warn = (
EmailSettingsService.should_warn_unverified_email_before_approval()
EmailSettingsService.is_verified_email_required_for_approval()
and not has_verified_email
)
if should_warn and not allow_unverified_email:
@@ -28,7 +28,7 @@ class EmailSettingsSnapshot:
notify_token_expiring: bool
notify_check_in_success: bool
require_admin_approval_for_registration: bool
warn_unverified_email_before_approval: bool
require_verified_email_for_approval: bool
has_smtp_sender_password: bool
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -49,7 +49,7 @@ class EmailSettingsService:
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,
)
@staticmethod
@@ -65,7 +65,7 @@ class EmailSettingsService:
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,
has_smtp_sender_password=bool(password),
created_at=None,
updated_at=None,
@@ -102,7 +102,7 @@ class EmailSettingsService:
require_admin_approval_for_registration=bool(
row.require_admin_approval_for_registration
),
warn_unverified_email_before_approval=bool(row.warn_unverified_email_before_approval),
require_verified_email_for_approval=bool(row.require_verified_email_for_approval),
has_smtp_sender_password=bool(password),
created_at=row.created_at,
updated_at=row.updated_at,
@@ -119,7 +119,7 @@ class EmailSettingsService:
notify_token_expiring=snapshot.notify_token_expiring,
notify_check_in_success=snapshot.notify_check_in_success,
require_admin_approval_for_registration=snapshot.require_admin_approval_for_registration,
warn_unverified_email_before_approval=snapshot.warn_unverified_email_before_approval,
require_verified_email_for_approval=snapshot.require_verified_email_for_approval,
has_smtp_sender_password=snapshot.has_smtp_sender_password,
created_at=snapshot.created_at,
updated_at=snapshot.updated_at,
@@ -147,7 +147,7 @@ class EmailSettingsService:
row.require_admin_approval_for_registration = (
payload.require_admin_approval_for_registration
)
row.warn_unverified_email_before_approval = payload.warn_unverified_email_before_approval
row.require_verified_email_for_approval = payload.require_verified_email_for_approval
if payload.clear_smtp_sender_password:
row.smtp_sender_password = ""
@@ -223,14 +223,12 @@ class EmailSettingsService:
db.close()
@staticmethod
def should_warn_unverified_email_before_approval() -> bool:
def is_verified_email_required_for_approval() -> bool:
db = SessionLocal()
try:
try:
return EmailSettingsService.get_snapshot(db).warn_unverified_email_before_approval
return EmailSettingsService.get_snapshot(db).require_verified_email_for_approval
except SQLAlchemyError:
return (
EmailSettingsService._default_snapshot().warn_unverified_email_before_approval
)
return EmailSettingsService._default_snapshot().require_verified_email_for_approval
finally:
db.close()
+2 -4
View File
@@ -257,13 +257,11 @@ class UserService:
user.alias = update_data["alias"]
logger.info(f"用户 ID {user_id} 别名更新: {user.alias}")
# 更新邮箱
# 邮箱必须走 /users/me/email 的验证码流程,不能从普通资料保存绕过。
if "email" in update_data:
next_email = str(update_data["email"]) if update_data["email"] is not None else None
if next_email != user.email:
UserService._clear_email_verification(user)
user.email = next_email
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
raise ValueError("邮箱修改需要先完成验证码验证")
# 更新密码
if "new_password" in update_data and update_data["new_password"]:
+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>