mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
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:
@@ -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_token_expiring BOOLEAN NOT NULL DEFAULT 1,
|
||||||
notify_check_in_success BOOLEAN NOT NULL DEFAULT 1,
|
notify_check_in_success BOOLEAN NOT NULL DEFAULT 1,
|
||||||
require_admin_approval_for_registration 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,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME
|
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
|
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:
|
def apply(conn: Connection) -> None:
|
||||||
user_columns = _table_columns(conn, "users")
|
user_columns = _table_columns(conn, "users")
|
||||||
user_columns = _add_column_if_missing(
|
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",
|
||||||
"require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1",
|
"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,
|
conn,
|
||||||
"email_notification_settings",
|
"email_notification_settings",
|
||||||
settings_columns,
|
settings_columns,
|
||||||
"warn_unverified_email_before_approval",
|
"warn_unverified_email_before_approval",
|
||||||
"warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1",
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ from backend.migration_steps.account_lockout import apply as apply_account_locko
|
|||||||
from backend.migration_steps.email_notification_settings import (
|
from backend.migration_steps.email_notification_settings import (
|
||||||
apply as apply_email_notification_settings,
|
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.task_thread_id import apply as apply_task_thread_id
|
||||||
from backend.migration_steps.user_email_verification import (
|
from backend.migration_steps.user_email_verification import (
|
||||||
apply as apply_user_email_verification,
|
apply as apply_user_email_verification,
|
||||||
@@ -102,6 +108,16 @@ MIGRATIONS: tuple[Migration, ...] = (
|
|||||||
description="Add user email verification fields and registration approval policy flags.",
|
description="Add user email verification fields and registration approval policy flags.",
|
||||||
apply=apply_user_email_verification,
|
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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class EmailNotificationSettings(Base):
|
|||||||
require_admin_approval_for_registration: Mapped[bool] = mapped_column(
|
require_admin_approval_for_registration: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=True, nullable=False
|
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
|
Boolean, default=True, nullable=False
|
||||||
)
|
)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ class EmailNotificationSettingsBase(BaseModel):
|
|||||||
require_admin_approval_for_registration: bool = Field(
|
require_admin_approval_for_registration: bool = Field(
|
||||||
True, description="新注册是否需要管理员审批"
|
True, description="新注册是否需要管理员审批"
|
||||||
)
|
)
|
||||||
warn_unverified_email_before_approval: bool = Field(
|
require_verified_email_for_approval: bool = Field(
|
||||||
True, description="审批未验证邮箱用户时是否警告"
|
True, description="审批前是否要求用户完成邮箱验证"
|
||||||
)
|
)
|
||||||
|
|
||||||
@field_validator("smtp_server", "smtp_sender_email", mode="before")
|
@field_validator("smtp_server", "smtp_sender_email", mode="before")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class AdminService:
|
|||||||
email_changed = next_email is not None and next_email != user.email
|
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)
|
has_verified_email = bool(user.email and user.email_verified_at and not email_changed)
|
||||||
should_warn = (
|
should_warn = (
|
||||||
EmailSettingsService.should_warn_unverified_email_before_approval()
|
EmailSettingsService.is_verified_email_required_for_approval()
|
||||||
and not has_verified_email
|
and not has_verified_email
|
||||||
)
|
)
|
||||||
if should_warn and not allow_unverified_email:
|
if should_warn and not allow_unverified_email:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class EmailSettingsSnapshot:
|
|||||||
notify_token_expiring: bool
|
notify_token_expiring: bool
|
||||||
notify_check_in_success: bool
|
notify_check_in_success: bool
|
||||||
require_admin_approval_for_registration: bool
|
require_admin_approval_for_registration: bool
|
||||||
warn_unverified_email_before_approval: bool
|
require_verified_email_for_approval: bool
|
||||||
has_smtp_sender_password: bool
|
has_smtp_sender_password: bool
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
@@ -49,7 +49,7 @@ class EmailSettingsService:
|
|||||||
notify_token_expiring=True,
|
notify_token_expiring=True,
|
||||||
notify_check_in_success=True,
|
notify_check_in_success=True,
|
||||||
require_admin_approval_for_registration=True,
|
require_admin_approval_for_registration=True,
|
||||||
warn_unverified_email_before_approval=True,
|
require_verified_email_for_approval=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -65,7 +65,7 @@ class EmailSettingsService:
|
|||||||
notify_token_expiring=True,
|
notify_token_expiring=True,
|
||||||
notify_check_in_success=True,
|
notify_check_in_success=True,
|
||||||
require_admin_approval_for_registration=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),
|
has_smtp_sender_password=bool(password),
|
||||||
created_at=None,
|
created_at=None,
|
||||||
updated_at=None,
|
updated_at=None,
|
||||||
@@ -102,7 +102,7 @@ class EmailSettingsService:
|
|||||||
require_admin_approval_for_registration=bool(
|
require_admin_approval_for_registration=bool(
|
||||||
row.require_admin_approval_for_registration
|
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),
|
has_smtp_sender_password=bool(password),
|
||||||
created_at=row.created_at,
|
created_at=row.created_at,
|
||||||
updated_at=row.updated_at,
|
updated_at=row.updated_at,
|
||||||
@@ -119,7 +119,7 @@ class EmailSettingsService:
|
|||||||
notify_token_expiring=snapshot.notify_token_expiring,
|
notify_token_expiring=snapshot.notify_token_expiring,
|
||||||
notify_check_in_success=snapshot.notify_check_in_success,
|
notify_check_in_success=snapshot.notify_check_in_success,
|
||||||
require_admin_approval_for_registration=snapshot.require_admin_approval_for_registration,
|
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,
|
has_smtp_sender_password=snapshot.has_smtp_sender_password,
|
||||||
created_at=snapshot.created_at,
|
created_at=snapshot.created_at,
|
||||||
updated_at=snapshot.updated_at,
|
updated_at=snapshot.updated_at,
|
||||||
@@ -147,7 +147,7 @@ class EmailSettingsService:
|
|||||||
row.require_admin_approval_for_registration = (
|
row.require_admin_approval_for_registration = (
|
||||||
payload.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:
|
if payload.clear_smtp_sender_password:
|
||||||
row.smtp_sender_password = ""
|
row.smtp_sender_password = ""
|
||||||
@@ -223,14 +223,12 @@ class EmailSettingsService:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def should_warn_unverified_email_before_approval() -> bool:
|
def is_verified_email_required_for_approval() -> bool:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
return EmailSettingsService.get_snapshot(db).warn_unverified_email_before_approval
|
return EmailSettingsService.get_snapshot(db).require_verified_email_for_approval
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
return (
|
return EmailSettingsService._default_snapshot().require_verified_email_for_approval
|
||||||
EmailSettingsService._default_snapshot().warn_unverified_email_before_approval
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -257,13 +257,11 @@ class UserService:
|
|||||||
user.alias = update_data["alias"]
|
user.alias = update_data["alias"]
|
||||||
logger.info(f"用户 ID {user_id} 别名更新: {user.alias}")
|
logger.info(f"用户 ID {user_id} 别名更新: {user.alias}")
|
||||||
|
|
||||||
# 更新邮箱
|
# 邮箱必须走 /users/me/email 的验证码流程,不能从普通资料保存绕过。
|
||||||
if "email" in update_data:
|
if "email" in update_data:
|
||||||
next_email = str(update_data["email"]) if update_data["email"] is not None else None
|
next_email = str(update_data["email"]) if update_data["email"] is not None else None
|
||||||
if next_email != user.email:
|
if next_email != user.email:
|
||||||
UserService._clear_email_verification(user)
|
raise ValueError("邮箱修改需要先完成验证码验证")
|
||||||
user.email = next_email
|
|
||||||
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
|
|
||||||
|
|
||||||
# 更新密码
|
# 更新密码
|
||||||
if "new_password" in update_data and update_data["new_password"]:
|
if "new_password" in update_data and update_data["new_password"]:
|
||||||
|
|||||||
@@ -48,12 +48,8 @@ export const userApi = {
|
|||||||
tokenStatus: () => apiClient.get<TokenStatus>('/api/users/me/token_status'),
|
tokenStatus: () => apiClient.get<TokenStatus>('/api/users/me/token_status'),
|
||||||
setEmail: (email: string) => apiClient.put<User>('/api/users/me/email', { email }),
|
setEmail: (email: string) => apiClient.put<User>('/api/users/me/email', { email }),
|
||||||
verifyEmail: (code: string) => apiClient.post<User>('/api/users/me/email/verify', { code }),
|
verifyEmail: (code: string) => apiClient.post<User>('/api/users/me/email/verify', { code }),
|
||||||
updateProfile: (payload: {
|
updateProfile: (payload: { alias?: string; current_password?: string; new_password?: string }) =>
|
||||||
alias?: string
|
apiClient.put<User>('/api/users/me/profile', payload),
|
||||||
email?: 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),
|
list: (params: Record<string, unknown> = {}) => apiClient.get<User[]>('/api/users', params),
|
||||||
create: (payload: Partial<User> & { password?: string }) =>
|
create: (payload: Partial<User> & { password?: string }) =>
|
||||||
apiClient.post<User>('/api/users', payload),
|
apiClient.post<User>('/api/users', payload),
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export interface EmailNotificationSettings {
|
|||||||
notify_token_expiring: boolean
|
notify_token_expiring: boolean
|
||||||
notify_check_in_success: boolean
|
notify_check_in_success: boolean
|
||||||
require_admin_approval_for_registration: boolean
|
require_admin_approval_for_registration: boolean
|
||||||
warn_unverified_email_before_approval: boolean
|
require_verified_email_for_approval: boolean
|
||||||
has_smtp_sender_password: boolean
|
has_smtp_sender_password: boolean
|
||||||
created_at?: string | null
|
created_at?: string | null
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
@@ -229,7 +229,7 @@ export interface EmailNotificationSettingsUpdate {
|
|||||||
notify_token_expiring: boolean
|
notify_token_expiring: boolean
|
||||||
notify_check_in_success: boolean
|
notify_check_in_success: boolean
|
||||||
require_admin_approval_for_registration: boolean
|
require_admin_approval_for_registration: boolean
|
||||||
warn_unverified_email_before_approval: boolean
|
require_verified_email_for_approval: boolean
|
||||||
smtp_sender_password?: string
|
smtp_sender_password?: string
|
||||||
clear_smtp_sender_password?: boolean
|
clear_smtp_sender_password?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Save } from 'lucide-vue-next'
|
import { MailCheck, Save, Send } from 'lucide-vue-next'
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { userApi, type TokenStatus } from '@/api'
|
import { userApi, type TokenStatus } from '@/api'
|
||||||
import { useAuth } from '@/app/auth'
|
import { useAuth } from '@/app/auth'
|
||||||
import StateBlock from '@/components/StateBlock.vue'
|
import StateBlock from '@/components/StateBlock.vue'
|
||||||
@@ -18,15 +18,22 @@ import { extractErrorMessage } from '@/utils/format'
|
|||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const sendingCode = ref(false)
|
||||||
|
const verifyingEmail = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
|
const emailMessage = ref('')
|
||||||
const token = ref<TokenStatus | null>(null)
|
const token = ref<TokenStatus | null>(null)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
alias: '',
|
alias: '',
|
||||||
email: '',
|
|
||||||
current_password: '',
|
current_password: '',
|
||||||
new_password: '',
|
new_password: '',
|
||||||
})
|
})
|
||||||
|
const emailForm = reactive({
|
||||||
|
email: '',
|
||||||
|
code: '',
|
||||||
|
})
|
||||||
|
const emailVerified = computed(() => Boolean(auth.state.user?.email_verified))
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -39,7 +46,7 @@ async function load() {
|
|||||||
auth.state.user = user
|
auth.state.user = user
|
||||||
token.value = tokenStatus
|
token.value = tokenStatus
|
||||||
form.alias = user.alias
|
form.alias = user.alias
|
||||||
form.email = user.email ?? ''
|
emailForm.email = user.email ?? ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = extractErrorMessage(err)
|
error.value = extractErrorMessage(err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -54,7 +61,6 @@ async function save() {
|
|||||||
try {
|
try {
|
||||||
const user = await userApi.updateProfile({
|
const user = await userApi.updateProfile({
|
||||||
alias: form.alias,
|
alias: form.alias,
|
||||||
email: form.email || undefined,
|
|
||||||
current_password: form.current_password || undefined,
|
current_password: form.current_password || undefined,
|
||||||
new_password: form.new_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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -92,10 +132,6 @@ onMounted(load)
|
|||||||
<span :class="labelClass">别名</span>
|
<span :class="labelClass">别名</span>
|
||||||
<input v-model="form.alias" :class="inputClass" required />
|
<input v-model="form.alias" :class="inputClass" required />
|
||||||
</label>
|
</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">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<label class="grid gap-2">
|
<label class="grid gap-2">
|
||||||
<span :class="labelClass">当前密码</span>
|
<span :class="labelClass">当前密码</span>
|
||||||
@@ -129,6 +165,61 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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']">
|
<aside :class="[cardClass, 'h-fit overflow-hidden']">
|
||||||
<div
|
<div
|
||||||
class="grid gap-2 border-b px-4 py-3"
|
class="grid gap-2 border-b px-4 py-3"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const form = reactive({
|
|||||||
notify_token_expiring: true,
|
notify_token_expiring: true,
|
||||||
notify_check_in_success: true,
|
notify_check_in_success: true,
|
||||||
require_admin_approval_for_registration: true,
|
require_admin_approval_for_registration: true,
|
||||||
warn_unverified_email_before_approval: true,
|
require_verified_email_for_approval: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const passwordState = computed(() => {
|
const passwordState = computed(() => {
|
||||||
@@ -50,7 +50,7 @@ function hydrate(next: EmailNotificationSettings) {
|
|||||||
form.notify_token_expiring = next.notify_token_expiring
|
form.notify_token_expiring = next.notify_token_expiring
|
||||||
form.notify_check_in_success = next.notify_check_in_success
|
form.notify_check_in_success = next.notify_check_in_success
|
||||||
form.require_admin_approval_for_registration = next.require_admin_approval_for_registration
|
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() {
|
async function load() {
|
||||||
@@ -85,7 +85,7 @@ async function save() {
|
|||||||
notify_token_expiring: form.notify_token_expiring,
|
notify_token_expiring: form.notify_token_expiring,
|
||||||
notify_check_in_success: form.notify_check_in_success,
|
notify_check_in_success: form.notify_check_in_success,
|
||||||
require_admin_approval_for_registration: form.require_admin_approval_for_registration,
|
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,
|
smtp_sender_password: form.smtp_sender_password || undefined,
|
||||||
clear_smtp_sender_password: form.clear_smtp_sender_password,
|
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">
|
<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">
|
<span class="flex items-center justify-between gap-3 text-sm font-medium">
|
||||||
未验证邮箱审批警告
|
审批前要求验证邮箱
|
||||||
<input
|
<input
|
||||||
v-model="form.warn_unverified_email_before_approval"
|
v-model="form.require_verified_email_for_approval"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="size-4 accent-primary"
|
class="size-4 accent-primary"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-muted-foreground"
|
<span class="text-sm text-muted-foreground"
|
||||||
>开启后,管理员审批邮箱未验证用户时需要确认覆盖。</span
|
>开启后,管理员审批未验证邮箱用户时每次都需要确认覆盖。</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ from backend.migration_steps.account_lockout import apply as apply_account_locko
|
|||||||
from backend.migration_steps.email_notification_settings import (
|
from backend.migration_steps.email_notification_settings import (
|
||||||
apply as apply_email_notification_settings,
|
apply as apply_email_notification_settings,
|
||||||
)
|
)
|
||||||
|
from backend.migration_steps.email_approval_policy import apply as apply_email_approval_policy
|
||||||
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
|
||||||
from backend.migration_steps.user_email_verification import (
|
from backend.migration_steps.user_email_verification import (
|
||||||
apply as apply_user_email_verification,
|
apply as apply_user_email_verification,
|
||||||
)
|
)
|
||||||
|
from backend.migration_steps.legacy_user_email_verification import (
|
||||||
|
apply as apply_legacy_user_email_verification,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_pending_migration_is_recorded_and_skipped_on_next_run() -> None:
|
def test_pending_migration_is_recorded_and_skipped_on_next_run() -> None:
|
||||||
@@ -83,12 +87,16 @@ def test_existing_migrations_are_registered_in_order() -> None:
|
|||||||
"2026050402_add_task_thread_id",
|
"2026050402_add_task_thread_id",
|
||||||
"2026050501_add_email_notification_settings",
|
"2026050501_add_email_notification_settings",
|
||||||
"2026050601_add_user_email_verification",
|
"2026050601_add_user_email_verification",
|
||||||
|
"2026050602_backfill_legacy_verified_emails",
|
||||||
|
"2026050603_remove_legacy_email_approval_warning",
|
||||||
]
|
]
|
||||||
assert [migration.apply.__module__ for migration in MIGRATIONS] == [
|
assert [migration.apply.__module__ for migration in MIGRATIONS] == [
|
||||||
"backend.migration_steps.account_lockout",
|
"backend.migration_steps.account_lockout",
|
||||||
"backend.migration_steps.task_thread_id",
|
"backend.migration_steps.task_thread_id",
|
||||||
"backend.migration_steps.email_notification_settings",
|
"backend.migration_steps.email_notification_settings",
|
||||||
"backend.migration_steps.user_email_verification",
|
"backend.migration_steps.user_email_verification",
|
||||||
|
"backend.migration_steps.legacy_user_email_verification",
|
||||||
|
"backend.migration_steps.email_approval_policy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -111,8 +119,9 @@ def test_email_notification_settings_migration_creates_settings_table() -> None:
|
|||||||
"notify_token_expiring",
|
"notify_token_expiring",
|
||||||
"notify_check_in_success",
|
"notify_check_in_success",
|
||||||
"require_admin_approval_for_registration",
|
"require_admin_approval_for_registration",
|
||||||
"warn_unverified_email_before_approval",
|
"require_verified_email_for_approval",
|
||||||
} <= columns
|
} <= columns
|
||||||
|
assert "warn_unverified_email_before_approval" not in columns
|
||||||
|
|
||||||
|
|
||||||
def test_user_email_verification_migration_adds_user_fields_and_policy_flags() -> None:
|
def test_user_email_verification_migration_adds_user_fields_and_policy_flags() -> None:
|
||||||
@@ -145,10 +154,74 @@ def test_user_email_verification_migration_adds_user_fields_and_policy_flags() -
|
|||||||
} <= user_columns
|
} <= user_columns
|
||||||
assert {
|
assert {
|
||||||
"require_admin_approval_for_registration",
|
"require_admin_approval_for_registration",
|
||||||
"warn_unverified_email_before_approval",
|
"require_verified_email_for_approval",
|
||||||
} <= settings_columns
|
} <= settings_columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_notification_settings_migration_removes_legacy_warning_column() -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE email_notification_settings ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
apply_email_approval_policy(conn)
|
||||||
|
|
||||||
|
columns = {
|
||||||
|
row[1] for row in conn.execute(text("PRAGMA table_info(email_notification_settings)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert "warn_unverified_email_before_approval" not in columns
|
||||||
|
assert "require_verified_email_for_approval" in columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_email_verification_migration_trusts_approved_existing_emails() -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE users ("
|
||||||
|
"id INTEGER PRIMARY KEY, "
|
||||||
|
"email VARCHAR(100), "
|
||||||
|
"is_approved BOOLEAN NOT NULL DEFAULT 0, "
|
||||||
|
"email_verified_at DATETIME"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO users (id, email, is_approved, email_verified_at) VALUES "
|
||||||
|
"(1, 'old@example.com', 1, NULL), "
|
||||||
|
"(2, 'pending@example.com', 0, NULL), "
|
||||||
|
"(3, '', 1, NULL), "
|
||||||
|
"(4, 'verified@example.com', 1, '2026-01-01T00:00:00+00:00')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
apply_legacy_user_email_verification(conn)
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
row.id: row.email_verified_at
|
||||||
|
for row in conn.execute(
|
||||||
|
text("SELECT id, email_verified_at FROM users ORDER BY id")
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
assert rows[1] is not None
|
||||||
|
assert rows[2] is None
|
||||||
|
assert rows[3] is None
|
||||||
|
assert rows[4] == "2026-01-01T00:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
def test_account_lockout_migration_adds_missing_user_fields() -> None:
|
def test_account_lockout_migration_adds_missing_user_fields() -> None:
|
||||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
|
|||||||
assert updated.notify_token_expiring is False
|
assert updated.notify_token_expiring is False
|
||||||
assert updated.notify_check_in_success is False
|
assert updated.notify_check_in_success is False
|
||||||
assert updated.require_admin_approval_for_registration is True
|
assert updated.require_admin_approval_for_registration is True
|
||||||
assert updated.warn_unverified_email_before_approval is True
|
assert updated.require_verified_email_for_approval is True
|
||||||
|
|
||||||
EmailSettingsService.update_settings(
|
EmailSettingsService.update_settings(
|
||||||
session,
|
session,
|
||||||
@@ -87,7 +87,7 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
|
|||||||
notify_token_expiring=False,
|
notify_token_expiring=False,
|
||||||
notify_check_in_success=False,
|
notify_check_in_success=False,
|
||||||
require_admin_approval_for_registration=False,
|
require_admin_approval_for_registration=False,
|
||||||
warn_unverified_email_before_approval=False,
|
require_verified_email_for_approval=False,
|
||||||
clear_smtp_sender_password=True,
|
clear_smtp_sender_password=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -96,7 +96,7 @@ def test_email_settings_update_preserves_and_clears_password(monkeypatch) -> Non
|
|||||||
assert cleared.smtp_sender_password in ("", None)
|
assert cleared.smtp_sender_password in ("", None)
|
||||||
assert cleared.has_smtp_sender_password is False
|
assert cleared.has_smtp_sender_password is False
|
||||||
assert cleared.require_admin_approval_for_registration is False
|
assert cleared.require_admin_approval_for_registration is False
|
||||||
assert cleared.warn_unverified_email_before_approval is False
|
assert cleared.require_verified_email_for_approval is False
|
||||||
session.close()
|
session.close()
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
@@ -209,6 +209,6 @@ def test_registration_approval_policy_defaults_enabled() -> None:
|
|||||||
snapshot = EmailSettingsService.get_snapshot(session)
|
snapshot = EmailSettingsService.get_snapshot(session)
|
||||||
|
|
||||||
assert snapshot.require_admin_approval_for_registration is True
|
assert snapshot.require_admin_approval_for_registration is True
|
||||||
assert snapshot.warn_unverified_email_before_approval is True
|
assert snapshot.require_verified_email_for_approval is True
|
||||||
session.close()
|
session.close()
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|||||||
@@ -84,7 +84,10 @@ def test_frontend_admin_approval_policy_warnings_are_visible() -> None:
|
|||||||
assert "allow_unverified_email" in admin_users
|
assert "allow_unverified_email" in admin_users
|
||||||
assert "邮箱未验证" in admin_users
|
assert "邮箱未验证" in admin_users
|
||||||
assert "require_admin_approval_for_registration" in email_settings
|
assert "require_admin_approval_for_registration" in email_settings
|
||||||
|
assert "require_verified_email_for_approval" in email_settings
|
||||||
|
assert "审批前要求验证邮箱" in email_settings
|
||||||
assert "关闭管理员审批" in email_settings
|
assert "关闭管理员审批" in email_settings
|
||||||
|
assert "未验证邮箱审批警告" not in email_settings
|
||||||
|
|
||||||
|
|
||||||
def test_frontend_replaces_starter_component() -> None:
|
def test_frontend_replaces_starter_component() -> None:
|
||||||
@@ -110,3 +113,13 @@ def test_dashboard_qr_refresh_uses_dialog() -> None:
|
|||||||
assert "qrRefreshDialogOpen" in dashboard
|
assert "qrRefreshDialogOpen" in dashboard
|
||||||
assert "<Dialog" in dashboard
|
assert "<Dialog" in dashboard
|
||||||
assert "<DialogContent" in dashboard
|
assert "<DialogContent" in dashboard
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_email_changes_use_verification_flow() -> None:
|
||||||
|
settings = (SRC_ROOT / "views" / "SettingsView.vue").read_text(encoding="utf-8")
|
||||||
|
api = (SRC_ROOT / "api" / "index.ts").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "userApi.setEmail" in settings
|
||||||
|
assert "userApi.verifyEmail" in settings
|
||||||
|
assert "验证码" in settings
|
||||||
|
assert "email?: string" not in api
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from backend.api import users as users_api
|
|||||||
from backend.dependencies import get_current_user, get_db
|
from backend.dependencies import get_current_user, get_db
|
||||||
from backend.models import Base, User
|
from backend.models import Base, User
|
||||||
from backend.schemas.email_settings import EmailNotificationSettingsUpdate
|
from backend.schemas.email_settings import EmailNotificationSettingsUpdate
|
||||||
from backend.schemas.user import UserUpdate
|
from backend.schemas.user import UserUpdate, UserUpdateProfile
|
||||||
from backend.services import email_settings_service
|
from backend.services import email_settings_service
|
||||||
from backend.services.admin_service import AdminService
|
from backend.services.admin_service import AdminService
|
||||||
from backend.services.email_service import EmailService
|
from backend.services.email_service import EmailService
|
||||||
@@ -116,6 +116,24 @@ def test_changing_email_clears_verification_state(monkeypatch) -> None:
|
|||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_update_rejects_email_changes() -> None:
|
||||||
|
engine, _, session = make_session()
|
||||||
|
user = add_user(session, email="old@example.com")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="邮箱"):
|
||||||
|
UserService.update_user_profile(
|
||||||
|
user.id,
|
||||||
|
UserUpdateProfile(email="new@example.com"),
|
||||||
|
session,
|
||||||
|
)
|
||||||
|
|
||||||
|
refreshed = session.get(User, user.id)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.email == "old@example.com"
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def test_approval_requires_warning_then_allows_override(monkeypatch) -> None:
|
def test_approval_requires_warning_then_allows_override(monkeypatch) -> None:
|
||||||
engine, session_factory, session = make_session()
|
engine, session_factory, session = make_session()
|
||||||
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
||||||
@@ -134,7 +152,7 @@ def test_approval_requires_warning_then_allows_override(monkeypatch) -> None:
|
|||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def test_approval_warning_can_be_disabled(monkeypatch) -> None:
|
def test_verified_email_requirement_can_be_disabled(monkeypatch) -> None:
|
||||||
engine, session_factory, session = make_session()
|
engine, session_factory, session = make_session()
|
||||||
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
monkeypatch.setattr(email_settings_service, "SessionLocal", session_factory)
|
||||||
EmailSettingsService.update_settings(
|
EmailSettingsService.update_settings(
|
||||||
@@ -147,7 +165,7 @@ def test_approval_warning_can_be_disabled(monkeypatch) -> None:
|
|||||||
notify_token_expiring=True,
|
notify_token_expiring=True,
|
||||||
notify_check_in_success=True,
|
notify_check_in_success=True,
|
||||||
require_admin_approval_for_registration=True,
|
require_admin_approval_for_registration=True,
|
||||||
warn_unverified_email_before_approval=False,
|
require_verified_email_for_approval=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
user = add_user(session)
|
user = add_user(session)
|
||||||
@@ -192,7 +210,6 @@ def test_registration_approval_policy_controls_new_user_approval(monkeypatch) ->
|
|||||||
notify_token_expiring=True,
|
notify_token_expiring=True,
|
||||||
notify_check_in_success=True,
|
notify_check_in_success=True,
|
||||||
require_admin_approval_for_registration=False,
|
require_admin_approval_for_registration=False,
|
||||||
warn_unverified_email_before_approval=True,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user