update
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'; // 导入 ref
|
||||
import { reactive, ref, onMounted, computed } from 'vue'; // Import onMounted, computed
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha'; // <-- Import hCaptcha component
|
||||
import { useReCaptcha } from 'vue-recaptcha-v3'; // <-- Import reCAPTCHA v3 hook
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
// 获取 loginRequires2FA 状态
|
||||
const { isLoading, error, loginRequires2FA } = storeToRefs(authStore);
|
||||
const { isLoading, error, loginRequires2FA, publicCaptchaConfig } = storeToRefs(authStore); // Get publicCaptchaConfig
|
||||
|
||||
// 表单数据
|
||||
const credentials = reactive({
|
||||
@@ -16,19 +18,102 @@ const credentials = reactive({
|
||||
});
|
||||
const twoFactorToken = ref(''); // 用于存储 2FA 验证码
|
||||
const rememberMe = ref(false); // 新增:记住我状态,默认为 false
|
||||
const captchaToken = ref<string | null>(null); // NEW: Store CAPTCHA token
|
||||
const captchaError = ref<string | null>(null); // NEW: Store CAPTCHA specific error
|
||||
const hcaptchaWidget = ref<InstanceType<typeof VueHcaptcha> | null>(null); // NEW: Ref for hCaptcha component instance
|
||||
|
||||
// --- reCAPTCHA v3 Initialization ---
|
||||
const recaptchaInstance = useReCaptcha(); // Get the instance, might be undefined
|
||||
|
||||
// --- CAPTCHA Event Handlers ---
|
||||
// TODO: Implement functions to handle successful CAPTCHA completion and token retrieval
|
||||
const handleCaptchaVerified = (token: string) => {
|
||||
console.log('CAPTCHA verified, token:', token);
|
||||
captchaToken.value = token;
|
||||
captchaError.value = null; // Clear error on successful verification
|
||||
};
|
||||
const handleCaptchaExpired = () => {
|
||||
console.log('CAPTCHA expired');
|
||||
captchaToken.value = null;
|
||||
};
|
||||
const handleCaptchaError = (errorDetails: any) => {
|
||||
console.error('CAPTCHA error:', errorDetails);
|
||||
captchaToken.value = null;
|
||||
captchaError.value = t('login.error.captchaLoadFailed'); // Need translation
|
||||
};
|
||||
const resetCaptchaWidget = () => {
|
||||
console.log('Resetting CAPTCHA widget...');
|
||||
captchaToken.value = null;
|
||||
// Reset hCaptcha if it exists
|
||||
hcaptchaWidget.value?.reset();
|
||||
// reCAPTCHA v3 doesn't typically need explicit reset in the same way
|
||||
};
|
||||
// --- End CAPTCHA Event Handlers ---
|
||||
|
||||
|
||||
// 处理登录或 2FA 验证提交
|
||||
const handleSubmit = async () => {
|
||||
if (loginRequires2FA.value) {
|
||||
// 如果需要 2FA,则调用 2FA 验证 action
|
||||
await authStore.verifyLogin2FA(twoFactorToken.value);
|
||||
} else {
|
||||
// 否则,调用常规登录 action,并传递 rememberMe 状态
|
||||
await authStore.login({ ...credentials, rememberMe: rememberMe.value });
|
||||
captchaError.value = null; // Clear previous CAPTCHA error
|
||||
|
||||
// --- CAPTCHA Execution & Check ---
|
||||
if (publicCaptchaConfig.value?.enabled && !loginRequires2FA.value) {
|
||||
// If reCAPTCHA v3, execute it now to get the token
|
||||
if (publicCaptchaConfig.value.provider === 'recaptcha') {
|
||||
// Check if instance and methods are available
|
||||
if (recaptchaInstance?.recaptchaLoaded && recaptchaInstance?.executeRecaptcha) {
|
||||
try {
|
||||
await recaptchaInstance.recaptchaLoaded(); // Ensure library is loaded
|
||||
const token = await recaptchaInstance.executeRecaptcha('login'); // Execute with action 'login'
|
||||
console.log('reCAPTCHA v3 token obtained:', token);
|
||||
captchaToken.value = token; // Store the obtained token
|
||||
} catch (reError: any) {
|
||||
console.error('reCAPTCHA v3 execution failed:', reError);
|
||||
captchaError.value = t('login.error.captchaLoadFailed');
|
||||
return; // Stop submission if reCAPTCHA execution fails
|
||||
}
|
||||
} else {
|
||||
// Handle case where reCAPTCHA is not ready/initialized
|
||||
console.error('reCAPTCHA v3 not initialized or ready.');
|
||||
captchaError.value = t('login.error.captchaLoadFailed'); // Or a more specific error
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if token exists (for both hCaptcha and reCAPTCHA)
|
||||
if (!captchaToken.value) {
|
||||
captchaError.value = t('login.error.captchaRequired'); // Need translation
|
||||
return; // Stop submission if CAPTCHA is required but not completed/obtained
|
||||
}
|
||||
}
|
||||
// 成功后的重定向由 store action 处理
|
||||
// 失败会更新 error 状态并在模板中显示
|
||||
// --- End CAPTCHA Check ---
|
||||
|
||||
try {
|
||||
if (loginRequires2FA.value) {
|
||||
// 如果需要 2FA,则调用 2FA 验证 action
|
||||
await authStore.verifyLogin2FA(twoFactorToken.value);
|
||||
} else {
|
||||
// 否则,调用常规登录 action,并传递 rememberMe 和 captchaToken 状态
|
||||
await authStore.login({
|
||||
...credentials,
|
||||
rememberMe: rememberMe.value,
|
||||
captchaToken: captchaToken.value ?? undefined // Pass token or undefined if null
|
||||
});
|
||||
}
|
||||
// 成功后的重定向由 store action 处理
|
||||
// 失败会更新 error 状态并在模板中显示
|
||||
} finally {
|
||||
// Reset CAPTCHA after attempt (success or failure handled by store redirect/error display)
|
||||
if (publicCaptchaConfig.value?.enabled) {
|
||||
resetCaptchaWidget(); // Reset the widget for potential retry
|
||||
}
|
||||
} // <-- Correctly closing the try block here
|
||||
// --- Remove the extraneous else block that was causing the syntax error ---
|
||||
};
|
||||
|
||||
// Fetch CAPTCHA config on component mount
|
||||
onMounted(() => {
|
||||
authStore.fetchCaptchaConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -79,11 +164,39 @@ const handleSubmit = async () => {
|
||||
<label for="twoFactorToken" class="block text-sm font-medium text-text-secondary mb-1">{{ t('login.twoFactorPrompt') }}</label>
|
||||
<input type="text" id="twoFactorToken" v-model="twoFactorToken" required :disabled="isLoading" pattern="\d{6}" title="请输入 6 位数字验证码"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-error text-center text-sm -mt-2 mb-2"> <!-- Adjusted margin -->
|
||||
{{ error }}
|
||||
</div>
|
||||
<!-- CAPTCHA Area -->
|
||||
<div v-if="publicCaptchaConfig?.enabled && !loginRequires2FA" class="space-y-2">
|
||||
<label class="block text-sm font-medium text-text-secondary">{{ t('login.captchaPrompt') }}</label>
|
||||
<!-- hCaptcha Component -->
|
||||
<div v-if="publicCaptchaConfig.provider === 'hcaptcha' && publicCaptchaConfig.hcaptchaSiteKey">
|
||||
<VueHcaptcha
|
||||
ref="hcaptchaWidget"
|
||||
:sitekey="publicCaptchaConfig.hcaptchaSiteKey"
|
||||
@verify="handleCaptchaVerified"
|
||||
@expired="handleCaptchaExpired"
|
||||
@error="handleCaptchaError"
|
||||
theme="auto"
|
||||
></VueHcaptcha>
|
||||
</div>
|
||||
<!-- reCAPTCHA v3 Info (usually invisible) -->
|
||||
<div v-else-if="publicCaptchaConfig.provider === 'recaptcha'">
|
||||
<p class="text-xs text-text-secondary italic">
|
||||
{{ t('login.recaptchaV3Notice', 'This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.') }}
|
||||
</p>
|
||||
<!-- v3 is typically invisible, token obtained programmatically on submit -->
|
||||
</div>
|
||||
<!-- CAPTCHA Error Message -->
|
||||
<div v-if="captchaError" class="text-error text-sm">
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Login Error -->
|
||||
<div v-if="error" class="text-error text-center text-sm -mt-2 mb-2"> <!-- Adjusted margin -->
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="isLoading"
|
||||
class="w-full py-3 px-4 bg-primary text-white border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70">
|
||||
|
||||
@@ -141,6 +141,76 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<hr class="border-border/50"> <!-- Separator -->
|
||||
<!-- CAPTCHA Settings -->
|
||||
<div class="settings-section-content">
|
||||
<h3 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.captcha.title') }}</h3>
|
||||
<p class="text-sm text-text-secondary mb-4">{{ $t('settings.captcha.description') }}</p>
|
||||
<div v-if="!captchaSettings" class="p-4 text-center text-text-secondary italic">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
<form v-else @submit.prevent="handleUpdateCaptchaSettings" class="space-y-4">
|
||||
<!-- Enable Switch -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="captchaEnabled" v-model="captchaForm.enabled"
|
||||
class="h-4 w-4 rounded border-border text-primary focus:ring-primary mr-2 cursor-pointer">
|
||||
<label for="captchaEnabled" class="text-sm text-foreground cursor-pointer select-none">{{ $t('settings.captcha.enableLabel') }}</label>
|
||||
</div>
|
||||
|
||||
<!-- Provider Select (Only show if enabled) -->
|
||||
<div v-if="captchaForm.enabled">
|
||||
<label for="captchaProvider" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.providerLabel') }}</label>
|
||||
<select id="captchaProvider" v-model="captchaForm.provider"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
|
||||
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
|
||||
<option value="none">{{ $t('settings.captcha.providerNone') }}</option>
|
||||
<option value="hcaptcha">hCaptcha</option>
|
||||
<option value="recaptcha">Google reCAPTCHA v2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- hCaptcha Settings (Only show if enabled and provider is hcaptcha) -->
|
||||
<div v-if="captchaForm.enabled && captchaForm.provider === 'hcaptcha'" class="space-y-4 pl-4 border-l-2 border-border/50 ml-1 pt-2">
|
||||
<p class="text-xs text-text-secondary">{{ $t('settings.captcha.hcaptchaHint') }} <a href="https://www.hcaptcha.com/" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">hCaptcha.com</a></p>
|
||||
<div>
|
||||
<label for="hcaptchaSiteKey" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.siteKeyLabel') }}</label>
|
||||
<input type="text" id="hcaptchaSiteKey" v-model="captchaForm.hcaptchaSiteKey"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
|
||||
</div>
|
||||
<div>
|
||||
<label for="hcaptchaSecretKey" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.secretKeyLabel') }}</label>
|
||||
<input type="password" id="hcaptchaSecretKey" v-model="captchaForm.hcaptchaSecretKey" placeholder="••••••••••••" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
|
||||
<small class="block mt-1 text-xs text-text-secondary">{{ $t('settings.captcha.secretKeyHint') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- reCAPTCHA Settings (Only show if enabled and provider is recaptcha) -->
|
||||
<div v-if="captchaForm.enabled && captchaForm.provider === 'recaptcha'" class="space-y-4 pl-4 border-l-2 border-border/50 ml-1 pt-2">
|
||||
<p class="text-xs text-text-secondary">{{ $t('settings.captcha.recaptchaHint') }} <a href="https://www.google.com/recaptcha/" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">Google reCAPTCHA</a></p>
|
||||
<div>
|
||||
<label for="recaptchaSiteKey" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.siteKeyLabel') }}</label>
|
||||
<input type="text" id="recaptchaSiteKey" v-model="captchaForm.recaptchaSiteKey"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
|
||||
</div>
|
||||
<div>
|
||||
<label for="recaptchaSecretKey" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.secretKeyLabel') }}</label>
|
||||
<input type="password" id="recaptchaSecretKey" v-model="captchaForm.recaptchaSecretKey" placeholder="••••••••••••" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
|
||||
<small class="block mt-1 text-xs text-text-secondary">{{ $t('settings.captcha.secretKeyHint') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button & Message -->
|
||||
<div class="flex items-center justify-between pt-2">
|
||||
<button type="submit" :disabled="captchaLoading"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out text-sm font-medium">
|
||||
{{ captchaLoading ? $t('common.saving') : $t('settings.captcha.saveButton') }}
|
||||
</button>
|
||||
<p v-if="captchaMessage" :class="['text-sm', captchaSuccess ? 'text-success' : 'text-error']">{{ captchaMessage }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -395,6 +465,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Define necessary types locally if not shared
|
||||
type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'none';
|
||||
interface UpdateCaptchaSettingsDto {
|
||||
enabled?: boolean;
|
||||
provider?: CaptchaProvider;
|
||||
hcaptchaSiteKey?: string;
|
||||
hcaptchaSecretKey?: string;
|
||||
recaptchaSiteKey?: string;
|
||||
recaptchaSecretKey?: string;
|
||||
}
|
||||
import { ref, onMounted, computed, reactive, watch } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
@@ -413,7 +493,19 @@ const { t } = useI18n();
|
||||
|
||||
// --- Reactive state from store ---
|
||||
// 使用 storeToRefs 获取响应式 getter,包括 language
|
||||
const { settings, isLoading: settingsLoading, error: settingsError, showPopupFileEditorBoolean, shareFileEditorTabsBoolean, autoCopyOnSelectBoolean, dockerDefaultExpandBoolean, statusMonitorIntervalSecondsNumber, language: storeLanguage, workspaceSidebarPersistentBoolean } = storeToRefs(settingsStore); // +++ 添加 workspaceSidebarPersistentBoolean getter +++
|
||||
const {
|
||||
settings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
showPopupFileEditorBoolean,
|
||||
shareFileEditorTabsBoolean,
|
||||
autoCopyOnSelectBoolean,
|
||||
dockerDefaultExpandBoolean,
|
||||
statusMonitorIntervalSecondsNumber,
|
||||
language: storeLanguage,
|
||||
workspaceSidebarPersistentBoolean,
|
||||
captchaSettings, // <-- Import CAPTCHA settings state
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
// --- Local state for forms ---
|
||||
const ipWhitelistInput = ref('');
|
||||
@@ -460,6 +552,19 @@ const workspaceSidebarPersistentLoading = ref(false); // 新增
|
||||
const workspaceSidebarPersistentMessage = ref(''); // 新增
|
||||
const workspaceSidebarPersistentSuccess = ref(false); // 新增
|
||||
|
||||
// CAPTCHA Form State
|
||||
const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the form object
|
||||
enabled: false,
|
||||
provider: 'none',
|
||||
hcaptchaSiteKey: '',
|
||||
hcaptchaSecretKey: '',
|
||||
recaptchaSiteKey: '',
|
||||
recaptchaSecretKey: '',
|
||||
});
|
||||
const captchaLoading = ref(false);
|
||||
const captchaMessage = ref('');
|
||||
const captchaSuccess = ref(false);
|
||||
|
||||
|
||||
// --- Watcher to sync local form state with store state ---
|
||||
watch(settings, (newSettings, oldSettings) => {
|
||||
@@ -482,6 +587,27 @@ watch(settings, (newSettings, oldSettings) => {
|
||||
|
||||
}, { deep: true, immediate: true }); // immediate: true to run on initial load
|
||||
|
||||
// Watcher for CAPTCHA settings
|
||||
watch(captchaSettings, (newCaptchaSettings) => {
|
||||
if (newCaptchaSettings) {
|
||||
captchaForm.enabled = newCaptchaSettings.enabled;
|
||||
captchaForm.provider = newCaptchaSettings.provider;
|
||||
captchaForm.hcaptchaSiteKey = newCaptchaSettings.hcaptchaSiteKey || '';
|
||||
captchaForm.hcaptchaSecretKey = newCaptchaSettings.hcaptchaSecretKey || ''; // Keep secret keys local
|
||||
captchaForm.recaptchaSiteKey = newCaptchaSettings.recaptchaSiteKey || '';
|
||||
captchaForm.recaptchaSecretKey = newCaptchaSettings.recaptchaSecretKey || ''; // Keep secret keys local
|
||||
} else {
|
||||
// Reset form if settings are null (e.g., on error)
|
||||
captchaForm.enabled = false;
|
||||
captchaForm.provider = 'none';
|
||||
captchaForm.hcaptchaSiteKey = '';
|
||||
captchaForm.hcaptchaSecretKey = '';
|
||||
captchaForm.recaptchaSiteKey = '';
|
||||
captchaForm.recaptchaSecretKey = '';
|
||||
}
|
||||
}, { immediate: true }); // immediate: true to run on initial load
|
||||
|
||||
|
||||
// --- Popup Editor setting method ---
|
||||
const handleUpdatePopupEditorSetting = async () => {
|
||||
popupEditorLoading.value = true;
|
||||
@@ -849,10 +975,42 @@ const handleUpdateBlacklistSettings = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// --- CAPTCHA Settings Method ---
|
||||
const handleUpdateCaptchaSettings = async () => {
|
||||
captchaLoading.value = true;
|
||||
captchaMessage.value = '';
|
||||
captchaSuccess.value = false;
|
||||
try {
|
||||
// Prepare DTO, ensuring keys are present even if empty
|
||||
const dto: UpdateCaptchaSettingsDto = {
|
||||
enabled: captchaForm.enabled,
|
||||
provider: captchaForm.provider,
|
||||
hcaptchaSiteKey: captchaForm.hcaptchaSiteKey || '',
|
||||
hcaptchaSecretKey: captchaForm.hcaptchaSecretKey || '', // Send secret key
|
||||
recaptchaSiteKey: captchaForm.recaptchaSiteKey || '',
|
||||
recaptchaSecretKey: captchaForm.recaptchaSecretKey || '', // Send secret key
|
||||
};
|
||||
await settingsStore.updateCaptchaSettings(dto);
|
||||
captchaMessage.value = t('settings.captcha.success.saved'); // Need translation
|
||||
captchaSuccess.value = true;
|
||||
// Clear secret key fields in the form after successful save for security
|
||||
captchaForm.hcaptchaSecretKey = '';
|
||||
captchaForm.recaptchaSecretKey = '';
|
||||
} catch (error: any) {
|
||||
console.error('更新 CAPTCHA 设置失败:', error);
|
||||
captchaMessage.value = error.message || t('settings.captcha.error.saveFailed'); // Need translation
|
||||
captchaSuccess.value = false;
|
||||
} finally {
|
||||
captchaLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
onMounted(async () => {
|
||||
await checkTwoFactorStatus(); // Check 2FA status
|
||||
await fetchIpBlacklist(); // Fetch current blacklist entries
|
||||
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
|
||||
// Initial settings (including language, whitelist, blacklist config) are loaded in main.ts via settingsStore.loadInitialSettings()
|
||||
});
|
||||
|
||||
@@ -861,4 +1019,4 @@ onMounted(async () => {
|
||||
<style scoped>
|
||||
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
|
||||
</style>
|
||||
]]>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user