release: v1.2.7 — 登录人机验证(滑块 / Turnstile / Cap)
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
This commit is contained in:
1 parent
8408d01197
commit
430c4de0f4
42 files changed
+1103
-72
No files matched your search
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import logoLight from '~/assets/image/logo-light.png'
|
||||
import logoDark from '~/assets/image/logo-dark.png'
|
||||
import type { LoginVerificationPayload } from '~/types/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { login, fetchStatus, authStatus } = useAuth()
|
||||
@@ -11,45 +12,53 @@ const username = ref('')
|
||||
const password = ref('')
|
||||
const secret = ref('')
|
||||
const loading = ref(false)
|
||||
const captchaRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||
const captchaPayload = ref<{ captchaId: string, captchaPosition: number } | null>(null)
|
||||
const verificationRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||
const verificationPayload = ref<LoginVerificationPayload | null>(null)
|
||||
const verificationMountKey = ref(0)
|
||||
|
||||
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
|
||||
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
|
||||
const loginVerification = computed(() =>
|
||||
authStatus.value?.loginVerification ?? { method: 'slider' as const }
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(captchaPayload.value)
|
||||
Boolean(verificationPayload.value)
|
||||
&& (legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value))
|
||||
)
|
||||
|
||||
function onCaptchaVerified(payload: { id: string, positionPercent: number }) {
|
||||
captchaPayload.value = {
|
||||
captchaId: payload.id,
|
||||
captchaPosition: payload.positionPercent
|
||||
}
|
||||
function onVerificationVerified(payload: LoginVerificationPayload) {
|
||||
verificationPayload.value = payload
|
||||
}
|
||||
|
||||
async function resetCaptcha() {
|
||||
captchaPayload.value = null
|
||||
await captchaRef.value?.reset()
|
||||
function onVerificationCleared() {
|
||||
verificationPayload.value = null
|
||||
}
|
||||
|
||||
async function resetVerification() {
|
||||
verificationPayload.value = null
|
||||
await verificationRef.value?.reset()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
verificationPayload.value = null
|
||||
verificationMountKey.value += 1
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
await router.replace('/setup')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || loading.value || !captchaPayload.value) return
|
||||
if (!canSubmit.value || loading.value || !verificationPayload.value) return
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const captcha = captchaPayload.value
|
||||
const verification = verificationPayload.value
|
||||
const result = legacyMode.value
|
||||
? await login({ secret: secret.value }, captcha)
|
||||
: await login({ username: username.value.trim(), password: password.value }, captcha)
|
||||
? await login({ secret: secret.value }, verification)
|
||||
: await login({ username: username.value.trim(), password: password.value }, verification)
|
||||
|
||||
if (result.ok) {
|
||||
if (result.needsMigration) {
|
||||
@@ -59,10 +68,10 @@ async function submit() {
|
||||
username.value = ''
|
||||
password.value = ''
|
||||
secret.value = ''
|
||||
await resetCaptcha()
|
||||
await resetVerification()
|
||||
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
|
||||
} else {
|
||||
await resetCaptcha()
|
||||
await resetVerification()
|
||||
toast.add({
|
||||
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
|
||||
color: 'error'
|
||||
@@ -170,9 +179,14 @@ async function submit() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SliderCaptcha
|
||||
ref="captchaRef"
|
||||
@verified="onCaptchaVerified"
|
||||
<LoginVerification
|
||||
:key="verificationMountKey"
|
||||
ref="verificationRef"
|
||||
:method="loginVerification.method"
|
||||
:turnstile-site-key="loginVerification.turnstileSiteKey"
|
||||
:cap-api-endpoint="loginVerification.capApiEndpoint"
|
||||
@verified="onVerificationVerified"
|
||||
@cleared="onVerificationCleared"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import 'cap-widget'
|
||||
|
||||
const props = defineProps<{
|
||||
apiEndpoint: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verified: [payload: { capToken: string }]
|
||||
cleared: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const widgetRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function onSolve(event: Event) {
|
||||
const token = (event as CustomEvent<{ token: string }>).detail?.token
|
||||
if (token) {
|
||||
emit('verified', { capToken: token })
|
||||
}
|
||||
}
|
||||
|
||||
function onError() {
|
||||
emit('cleared')
|
||||
}
|
||||
|
||||
function reset() {
|
||||
const widget = widgetRef.value as (HTMLElement & { reset?: () => void }) | null
|
||||
widget?.reset?.()
|
||||
emit('cleared')
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<cap-widget
|
||||
ref="widgetRef"
|
||||
:data-cap-api-endpoint="props.apiEndpoint"
|
||||
:data-cap-i18n-initial-state="t('auth.capInitialState')"
|
||||
:data-cap-i18n-verifying-label="t('auth.capVerifying')"
|
||||
:data-cap-i18n-solved-label="t('auth.capSolved')"
|
||||
:data-cap-i18n-error-label="t('auth.capError')"
|
||||
@solve="onSolve"
|
||||
@error="onError"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
@@ -10,12 +10,14 @@ const props = withDefaults(defineProps<{
|
||||
showStorage?: boolean
|
||||
compact?: boolean
|
||||
gallery?: boolean
|
||||
allowDelete?: boolean
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true,
|
||||
showStorage: false,
|
||||
compact: false,
|
||||
gallery: false
|
||||
gallery: false,
|
||||
allowDelete: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -251,6 +253,7 @@ async function copyGalleryUrl() {
|
||||
@click="emit('preview')"
|
||||
/>
|
||||
<UButton
|
||||
v-if="allowDelete"
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
|
||||
@@ -12,13 +12,15 @@ const props = withDefaults(defineProps<{
|
||||
compact?: boolean
|
||||
/** 图库:5 列 × 2 行,缩略图略扁 */
|
||||
gallery?: boolean
|
||||
allowDelete?: boolean
|
||||
}>(), {
|
||||
selectable: true,
|
||||
showKey: true,
|
||||
showStorage: false,
|
||||
dense: false,
|
||||
compact: false,
|
||||
gallery: false
|
||||
gallery: false,
|
||||
allowDelete: true
|
||||
})
|
||||
|
||||
const gridClass = computed(() => {
|
||||
@@ -65,6 +67,7 @@ function updateSelection(key: string, selected: boolean) {
|
||||
:show-storage="showStorage"
|
||||
:compact="compact"
|
||||
:gallery="gallery"
|
||||
:allow-delete="allowDelete"
|
||||
@update:selected="updateSelection(image.key, $event)"
|
||||
@preview="emit('preview', image)"
|
||||
@delete="emit('delete', image)"
|
||||
|
||||
@@ -10,8 +10,10 @@ const props = withDefaults(defineProps<{
|
||||
image: ImageItem | null
|
||||
showStorage?: boolean
|
||||
deleting?: boolean
|
||||
allowDelete?: boolean
|
||||
}>(), {
|
||||
showStorage: false
|
||||
showStorage: false,
|
||||
allowDelete: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -396,6 +398,7 @@ function onImageLoad(event: Event) {
|
||||
@click="copyLink"
|
||||
/>
|
||||
<UButton
|
||||
v-if="allowDelete"
|
||||
:label="t('common.delete')"
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
LoginVerificationMethod,
|
||||
LoginVerificationPayload
|
||||
} from '~/types/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
method: LoginVerificationMethod
|
||||
turnstileSiteKey?: string
|
||||
capApiEndpoint?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verified: [payload: LoginVerificationPayload]
|
||||
cleared: []
|
||||
}>()
|
||||
|
||||
const sliderRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||
const turnstileRef = ref<{ reset: () => void } | null>(null)
|
||||
const capRef = ref<{ reset: () => void } | null>(null)
|
||||
|
||||
function onSliderVerified(payload: { id: string, positionPercent: number }) {
|
||||
emit('verified', {
|
||||
method: 'slider',
|
||||
captchaId: payload.id,
|
||||
captchaPosition: payload.positionPercent
|
||||
})
|
||||
}
|
||||
|
||||
function onTurnstileVerified(payload: { turnstileToken: string }) {
|
||||
emit('verified', {
|
||||
method: 'turnstile',
|
||||
turnstileToken: payload.turnstileToken
|
||||
})
|
||||
}
|
||||
|
||||
function onCapVerified(payload: { capToken: string }) {
|
||||
emit('verified', {
|
||||
method: 'cap',
|
||||
capToken: payload.capToken
|
||||
})
|
||||
}
|
||||
|
||||
function onCleared() {
|
||||
emit('cleared')
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
emit('cleared')
|
||||
if (props.method === 'slider') {
|
||||
await sliderRef.value?.reset()
|
||||
return
|
||||
}
|
||||
if (props.method === 'turnstile') {
|
||||
turnstileRef.value?.reset()
|
||||
return
|
||||
}
|
||||
capRef.value?.reset()
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SliderCaptcha
|
||||
v-if="method === 'slider'"
|
||||
ref="sliderRef"
|
||||
@verified="onSliderVerified"
|
||||
/>
|
||||
<TurnstileCaptcha
|
||||
v-else-if="method === 'turnstile' && turnstileSiteKey"
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verified="onTurnstileVerified"
|
||||
@cleared="onCleared"
|
||||
/>
|
||||
<CapCaptcha
|
||||
v-else-if="method === 'cap' && capApiEndpoint"
|
||||
ref="capRef"
|
||||
:api-endpoint="capApiEndpoint"
|
||||
@verified="onCapVerified"
|
||||
@cleared="onCleared"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="rounded-lg border border-warning/25 bg-warning/5 px-3 py-2 text-xs text-warning"
|
||||
>
|
||||
{{ $t('auth.verificationMisconfigured') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
siteKey: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verified: [payload: { turnstileToken: string }]
|
||||
cleared: []
|
||||
}>()
|
||||
|
||||
interface TurnstileRenderOptions {
|
||||
'sitekey': string
|
||||
'action'?: string
|
||||
'callback'?: (token: string) => void
|
||||
'expired-callback'?: () => void
|
||||
'error-callback'?: () => void
|
||||
}
|
||||
|
||||
interface TurnstileApi {
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileApi
|
||||
onTurnstileLoad?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const widgetId = ref<string | null>(null)
|
||||
const scriptLoaded = ref(false)
|
||||
|
||||
function onVerified(token: string) {
|
||||
if (token) {
|
||||
emit('verified', { turnstileToken: token })
|
||||
}
|
||||
}
|
||||
|
||||
function onCleared() {
|
||||
emit('cleared')
|
||||
}
|
||||
|
||||
function renderWidget() {
|
||||
if (!containerRef.value || !window.turnstile || !props.siteKey) return
|
||||
if (widgetId.value) {
|
||||
window.turnstile.remove(widgetId.value)
|
||||
widgetId.value = null
|
||||
}
|
||||
widgetId.value = window.turnstile.render(containerRef.value, {
|
||||
'sitekey': props.siteKey,
|
||||
'action': 'login',
|
||||
'callback': onVerified,
|
||||
'expired-callback': onCleared,
|
||||
'error-callback': onCleared
|
||||
})
|
||||
}
|
||||
|
||||
function tryRenderWidget() {
|
||||
if (scriptLoaded.value) {
|
||||
renderWidget()
|
||||
}
|
||||
}
|
||||
|
||||
function loadScript() {
|
||||
if (window.turnstile) {
|
||||
scriptLoaded.value = true
|
||||
tryRenderWidget()
|
||||
return
|
||||
}
|
||||
window.onTurnstileLoad = () => {
|
||||
scriptLoaded.value = true
|
||||
tryRenderWidget()
|
||||
}
|
||||
const existing = document.querySelector('script[data-turnstile]')
|
||||
if (existing) {
|
||||
if (window.turnstile) {
|
||||
scriptLoaded.value = true
|
||||
tryRenderWidget()
|
||||
}
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad'
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.dataset.turnstile = 'true'
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (window.turnstile && widgetId.value) {
|
||||
window.turnstile.reset(widgetId.value)
|
||||
}
|
||||
onCleared()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadScript()
|
||||
await nextTick()
|
||||
tryRenderWidget()
|
||||
})
|
||||
|
||||
watch([containerRef, () => props.siteKey, scriptLoaded], () => {
|
||||
tryRenderWidget()
|
||||
}, { flush: 'post' })
|
||||
|
||||
defineExpose({ reset })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="flex min-h-16 items-center justify-center"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
@@ -8,9 +8,11 @@ const props = withDefaults(defineProps<{
|
||||
viewMode: 'grid' | 'list'
|
||||
selectedCount?: number
|
||||
loading?: boolean
|
||||
showBatchDelete?: boolean
|
||||
}>(), {
|
||||
selectedCount: 0,
|
||||
loading: false
|
||||
loading: false,
|
||||
showBatchDelete: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -99,6 +101,7 @@ function viewButtonClass(active: boolean) {
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-if="showBatchDelete"
|
||||
type="button"
|
||||
icon="i-lucide-trash-2"
|
||||
size="sm"
|
||||
|
||||
@@ -7,9 +7,11 @@ const props = withDefaults(defineProps<{
|
||||
selectable?: boolean
|
||||
showStorage?: boolean
|
||||
emptyText?: string
|
||||
showDelete?: boolean
|
||||
}>(), {
|
||||
selectable: false,
|
||||
showStorage: false
|
||||
showStorage: false,
|
||||
showDelete: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -198,6 +200,7 @@ function typeLabel(image: ImageItem) {
|
||||
@click="emit('preview', image)"
|
||||
/>
|
||||
<UButton
|
||||
v-if="showDelete"
|
||||
icon="i-lucide-trash-2"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
|
||||
+37
-10
@@ -1,3 +1,5 @@
|
||||
import type { LoginVerificationPayload } from '~/types/auth'
|
||||
|
||||
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
||||
|
||||
export type UserRole = 'admin' | 'user'
|
||||
@@ -8,11 +10,18 @@ export interface AuthUser {
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
export interface LoginVerificationPublicConfig {
|
||||
method: 'slider' | 'turnstile' | 'cap'
|
||||
turnstileSiteKey?: string
|
||||
capApiEndpoint?: string
|
||||
}
|
||||
|
||||
export interface AuthStatusResponse {
|
||||
initialized: boolean
|
||||
allowRegistration: boolean
|
||||
legacyMode: boolean
|
||||
needsMigration: boolean
|
||||
loginVerification: LoginVerificationPublicConfig
|
||||
user: AuthUser | null
|
||||
}
|
||||
|
||||
@@ -66,9 +75,19 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string {
|
||||
|
||||
export type AuthActionResult = { ok: true } | { ok: false, error: string }
|
||||
|
||||
export interface LoginCaptchaPayload {
|
||||
captchaId: string
|
||||
captchaPosition: number
|
||||
export type { LoginVerificationPayload }
|
||||
|
||||
function buildVerificationBody(verification: LoginVerificationPayload): Record<string, unknown> {
|
||||
if (verification.method === 'slider') {
|
||||
return {
|
||||
captchaId: verification.captchaId,
|
||||
captchaPosition: verification.captchaPosition
|
||||
}
|
||||
}
|
||||
if (verification.method === 'turnstile') {
|
||||
return { turnstileToken: verification.turnstileToken }
|
||||
}
|
||||
return { capToken: verification.capToken }
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
@@ -123,7 +142,7 @@ export function useAuth() {
|
||||
async function login(
|
||||
credentials: { username: string, password: string }
|
||||
| { secret: string },
|
||||
captcha: LoginCaptchaPayload
|
||||
verification: LoginVerificationPayload
|
||||
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
|
||||
try {
|
||||
const result = await $fetch<{
|
||||
@@ -135,8 +154,7 @@ export function useAuth() {
|
||||
credentials: 'include',
|
||||
body: {
|
||||
...credentials,
|
||||
captchaId: captcha.captchaId,
|
||||
captchaPosition: captcha.captchaPosition
|
||||
...buildVerificationBody(verification)
|
||||
}
|
||||
})
|
||||
if (result.needsMigration) {
|
||||
@@ -159,7 +177,8 @@ export function useAuth() {
|
||||
|
||||
async function register(
|
||||
username: string,
|
||||
password: string
|
||||
password: string,
|
||||
verification: LoginVerificationPayload
|
||||
): Promise<AuthActionResult> {
|
||||
const validationError = validateAuthInput(username, password)
|
||||
if (validationError) {
|
||||
@@ -172,7 +191,11 @@ export function useAuth() {
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: { username: username.trim(), password }
|
||||
body: {
|
||||
username: username.trim(),
|
||||
password,
|
||||
...buildVerificationBody(verification)
|
||||
}
|
||||
}
|
||||
)
|
||||
user.value = result.user
|
||||
@@ -291,15 +314,19 @@ export function useAuth() {
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||
await $fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
} catch {
|
||||
// 忽略登出失败
|
||||
} finally {
|
||||
status.value = 'unauthenticated'
|
||||
user.value = null
|
||||
authStatus.value = null
|
||||
hasValidatedOnce.value = true
|
||||
}
|
||||
try {
|
||||
await fetchStatus()
|
||||
} catch {
|
||||
// 保留既有公开配置(loginVerification 等)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthError(error: unknown) {
|
||||
|
||||
+3
-1
@@ -35,7 +35,9 @@ const baseUrl = computed(() => {
|
||||
|
||||
const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '')
|
||||
const hasToken = computed(() => tokenDisplay.value.length > 0)
|
||||
const canRegenerate = computed(() => !settings.value?.envTokenOverride && !loading.value)
|
||||
const canRegenerate = computed(() =>
|
||||
!settings.value?.envTokenOverride && !loading.value
|
||||
)
|
||||
|
||||
const authHeader = computed(() =>
|
||||
hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN'
|
||||
|
||||
@@ -401,6 +401,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
:view-mode="viewMode"
|
||||
:selected-count="selectedCount"
|
||||
:loading="galleryLoading"
|
||||
:show-batch-delete="true"
|
||||
@update:storage-backend="handleStorageBackendChange"
|
||||
@update:upload-source="handleUploadSourceChange"
|
||||
@update:view-mode="viewMode = $event"
|
||||
@@ -430,7 +431,8 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
show-storage
|
||||
:items="items"
|
||||
:selected-keys="selectedKeys"
|
||||
selectable
|
||||
:selectable="true"
|
||||
:allow-delete="true"
|
||||
@update:selected-keys="updateSelectedKeys"
|
||||
@preview="openPreview"
|
||||
@delete="(image) => requestDelete(image.key)"
|
||||
@@ -442,6 +444,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
:items="items"
|
||||
:selected-keys="selectedKeys"
|
||||
:selectable="true"
|
||||
:show-delete="true"
|
||||
@update:selected-keys="updateSelectedKeys"
|
||||
@preview="openPreview"
|
||||
@delete="(image) => requestDelete(image.key)"
|
||||
@@ -452,6 +455,7 @@ watch(isAuthenticated, async (authed, prev) => {
|
||||
:image="previewImage"
|
||||
show-storage
|
||||
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
||||
:allow-delete="true"
|
||||
@delete="requestPreviewDelete"
|
||||
/>
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ function requestPreviewDelete() {
|
||||
v-model:open="previewOpen"
|
||||
:image="previewImage"
|
||||
:deleting="previewImage ? deletingKeys.has(previewImage.key) : false"
|
||||
:allow-delete="true"
|
||||
@delete="requestPreviewDelete"
|
||||
/>
|
||||
</AppShell>
|
||||
|
||||
+39
-4
@@ -2,9 +2,10 @@
|
||||
import logoLight from '~/assets/image/logo-light.png'
|
||||
import logoDark from '~/assets/image/logo-dark.png'
|
||||
import { isPasswordValid } from '~/utils/password-strength'
|
||||
import type { LoginVerificationPayload } from '~/types/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { register, fetchStatus } = useAuth()
|
||||
const { register, fetchStatus, authStatus } = useAuth()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -12,14 +13,34 @@ const username = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const verificationRef = ref<{ reset: () => Promise<void> } | null>(null)
|
||||
const verificationPayload = ref<LoginVerificationPayload | null>(null)
|
||||
|
||||
const loginVerification = computed(() =>
|
||||
authStatus.value?.loginVerification ?? { method: 'slider' as const }
|
||||
)
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
Boolean(username.value && password.value && confirmPassword.value)
|
||||
Boolean(verificationPayload.value)
|
||||
&& Boolean(username.value && password.value && confirmPassword.value)
|
||||
&& password.value === confirmPassword.value
|
||||
&& isPasswordValid(password.value)
|
||||
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
|
||||
)
|
||||
|
||||
function onVerificationVerified(payload: LoginVerificationPayload) {
|
||||
verificationPayload.value = payload
|
||||
}
|
||||
|
||||
function onVerificationCleared() {
|
||||
verificationPayload.value = null
|
||||
}
|
||||
|
||||
async function resetVerification() {
|
||||
verificationPayload.value = null
|
||||
await verificationRef.value?.reset()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const status = await fetchStatus()
|
||||
if (!status.initialized && !status.legacyMode) {
|
||||
@@ -32,7 +53,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value || loading.value) return
|
||||
if (!canSubmit.value || loading.value || !verificationPayload.value) return
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
|
||||
@@ -41,11 +62,16 @@ async function submit() {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await register(username.value.trim(), password.value)
|
||||
const result = await register(
|
||||
username.value.trim(),
|
||||
password.value,
|
||||
verificationPayload.value
|
||||
)
|
||||
if (result.ok) {
|
||||
toast.add({ title: t('auth.registerSuccess'), color: 'success' })
|
||||
await router.replace('/')
|
||||
} else {
|
||||
await resetVerification()
|
||||
toast.add({ title: result.error, color: 'error' })
|
||||
}
|
||||
} finally {
|
||||
@@ -142,6 +168,15 @@ async function submit() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LoginVerification
|
||||
ref="verificationRef"
|
||||
:method="loginVerification.method"
|
||||
:turnstile-site-key="loginVerification.turnstileSiteKey"
|
||||
:cap-api-endpoint="loginVerification.capApiEndpoint"
|
||||
@verified="onVerificationVerified"
|
||||
@cleared="onVerificationCleared"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
:label="t('auth.register')"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings'
|
||||
|
||||
type SettingSource = 'env' | 'db' | 'none'
|
||||
type WebpQualitySource = 'env' | 'db' | 'default'
|
||||
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
||||
|
||||
interface SettingsResponse {
|
||||
apiUploadToken: string
|
||||
@@ -33,6 +34,11 @@ interface SettingsResponse {
|
||||
storageUseDatePath: boolean
|
||||
storageUseDatePathSource: SettingSource
|
||||
allowRegistration: boolean
|
||||
loginVerificationMethod: LoginVerificationMethod
|
||||
turnstileSiteKey: string
|
||||
turnstileSecretKey: string
|
||||
capApiEndpoint: string
|
||||
capSecret: string
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
@@ -98,6 +104,11 @@ const imageBaseUrlDraft = ref('')
|
||||
const hideFolderInUrlDraft = ref(false)
|
||||
const storageUseDatePathDraft = ref(true)
|
||||
const allowRegistrationDraft = ref(false)
|
||||
const loginVerificationMethodDraft = ref<LoginVerificationMethod>('slider')
|
||||
const turnstileSiteKeyDraft = ref('')
|
||||
const turnstileSecretKeyDraft = ref('')
|
||||
const capApiEndpointDraft = ref('')
|
||||
const capSecretDraft = ref('')
|
||||
const domainSeparationDraft = ref(false)
|
||||
const disableDomainSeparationOpen = ref(false)
|
||||
const pendingDomainSeparationDisable = ref(false)
|
||||
@@ -120,6 +131,11 @@ const hasServerChanges = computed(() => {
|
||||
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|
||||
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|
||||
|| allowRegistrationDraft.value !== settings.value.allowRegistration
|
||||
|| loginVerificationMethodDraft.value !== settings.value.loginVerificationMethod
|
||||
|| turnstileSiteKeyDraft.value !== settings.value.turnstileSiteKey
|
||||
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|
||||
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint
|
||||
|| capSecretDraft.value !== settings.value.capSecret
|
||||
})
|
||||
|
||||
function sourceBadge(source: SettingSource | WebpQualitySource) {
|
||||
@@ -150,6 +166,11 @@ function applySettings(data: SettingsResponse) {
|
||||
hideFolderInUrlDraft.value = data.hideFolderInUrl
|
||||
storageUseDatePathDraft.value = data.storageUseDatePath
|
||||
allowRegistrationDraft.value = data.allowRegistration
|
||||
loginVerificationMethodDraft.value = data.loginVerificationMethod
|
||||
turnstileSiteKeyDraft.value = data.turnstileSiteKey
|
||||
turnstileSecretKeyDraft.value = data.turnstileSecretKey
|
||||
capApiEndpointDraft.value = data.capApiEndpoint
|
||||
capSecretDraft.value = data.capSecret
|
||||
domainSeparationDraft.value = data.domainSeparation
|
||||
}
|
||||
|
||||
@@ -221,7 +242,12 @@ async function saveServerSettings() {
|
||||
imageBaseUrl: imageBaseUrlDraft.value,
|
||||
hideFolderInUrl: hideFolderInUrlDraft.value,
|
||||
storageUseDatePath: storageUseDatePathDraft.value,
|
||||
allowRegistration: allowRegistrationDraft.value
|
||||
allowRegistration: allowRegistrationDraft.value,
|
||||
loginVerificationMethod: loginVerificationMethodDraft.value,
|
||||
turnstileSiteKey: turnstileSiteKeyDraft.value,
|
||||
turnstileSecretKey: turnstileSecretKeyDraft.value,
|
||||
capApiEndpoint: capApiEndpointDraft.value,
|
||||
capSecret: capSecretDraft.value
|
||||
})
|
||||
toast.add({ title: t('settings.saved'), color: 'success' })
|
||||
} catch (error: unknown) {
|
||||
@@ -279,9 +305,27 @@ async function checkLatestRelease(options: { notify?: boolean, refresh?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const loginVerificationOptions = computed(() => [
|
||||
{ value: 'slider' as const, label: t('settings.loginVerificationSlider') },
|
||||
{ value: 'turnstile' as const, label: t('settings.loginVerificationTurnstile') },
|
||||
{ value: 'cap' as const, label: t('settings.loginVerificationCap') }
|
||||
])
|
||||
|
||||
const loginVerificationHint = computed(() => {
|
||||
switch (loginVerificationMethodDraft.value) {
|
||||
case 'turnstile':
|
||||
return t('settings.loginVerificationTurnstileHint')
|
||||
case 'cap':
|
||||
return t('settings.loginVerificationCapHint')
|
||||
default:
|
||||
return t('settings.loginVerificationSliderHint')
|
||||
}
|
||||
})
|
||||
|
||||
async function loadPage() {
|
||||
if (isAdmin.value) {
|
||||
await Promise.all([loadSettings(), checkLatestRelease()])
|
||||
await loadSettings()
|
||||
await checkLatestRelease()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +426,10 @@ watch(isAdmin, () => {
|
||||
:title="t('settings.systemInfo')"
|
||||
:hint="t('settings.systemInfoHint')"
|
||||
>
|
||||
<template #action>
|
||||
<template
|
||||
v-if="isAdmin"
|
||||
#action
|
||||
>
|
||||
<UButton
|
||||
:label="t('settings.checkUpdate')"
|
||||
icon="i-lucide-refresh-cw"
|
||||
@@ -712,6 +759,75 @@ watch(isAdmin, () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
:title="t('settings.loginVerification')"
|
||||
:hint="t('settings.loginVerificationHint')"
|
||||
>
|
||||
<SettingsGroup>
|
||||
<div class="space-y-4 px-4 py-4 sm:px-5">
|
||||
<URadioGroup
|
||||
v-model="loginVerificationMethodDraft"
|
||||
:items="loginVerificationOptions"
|
||||
/>
|
||||
<p class="text-xs leading-relaxed text-muted">
|
||||
{{ loginVerificationHint }}
|
||||
</p>
|
||||
<div
|
||||
v-if="loginVerificationMethodDraft === 'turnstile'"
|
||||
class="space-y-3 rounded-xl border border-default bg-muted/20 p-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.turnstileSiteKey') }}
|
||||
</label>
|
||||
<UInput
|
||||
v-model="turnstileSiteKeyDraft"
|
||||
:placeholder="t('settings.turnstileSiteKeyPlaceholder')"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.turnstileSecretKey') }}
|
||||
</label>
|
||||
<UInput
|
||||
v-model="turnstileSecretKeyDraft"
|
||||
type="password"
|
||||
:placeholder="t('settings.turnstileSecretKeyPlaceholder')"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loginVerificationMethodDraft === 'cap'"
|
||||
class="space-y-3 rounded-xl border border-default bg-muted/20 p-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.capApiEndpoint') }}
|
||||
</label>
|
||||
<UInput
|
||||
v-model="capApiEndpointDraft"
|
||||
:placeholder="t('settings.capApiEndpointPlaceholder')"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-highlighted">
|
||||
{{ t('settings.capSecretKey') }}
|
||||
</label>
|
||||
<UInput
|
||||
v-model="capSecretDraft"
|
||||
type="password"
|
||||
:placeholder="t('settings.capSecretKeyPlaceholder')"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
</SettingsSection>
|
||||
</SettingsPanel>
|
||||
<div
|
||||
v-else
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
|
||||
|
||||
export interface LoginVerificationPublicConfig {
|
||||
method: LoginVerificationMethod
|
||||
turnstileSiteKey?: string
|
||||
capApiEndpoint?: string
|
||||
}
|
||||
|
||||
export type LoginVerificationPayload = {
|
||||
method: 'slider'
|
||||
captchaId: string
|
||||
captchaPosition: number
|
||||
} | {
|
||||
method: 'turnstile'
|
||||
turnstileToken: string
|
||||
} | {
|
||||
method: 'cap'
|
||||
capToken: string
|
||||
}
|
||||
Reference in new issue
Block a user