mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
feat(auth): require verified email for approval
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user