feat(ui): Add system settings and OAuth providers management pages

- Add new settings page with application, auth, security, email, SMS configuration
    - Add OAuth identity providers management page with CRUD operations
    - Update login page to support dynamic OAuth providers and verification code login
    - Add navigation menu items for settings and OAuth providers
    - Add i18n translations for settings and OAuth provider management
    - Add routes for /settings and /oauth/providers pages
master
veypi 4 weeks ago
parent 0e8e72b7e7
commit 627439bc4d

@ -9,7 +9,16 @@
"auth.confirm_password": "Confirm Password",
"auth.username_or_email": "Username or Email",
"auth.email_or_phone": "Email or Phone",
"auth.phone": "Phone Number",
"auth.phone_placeholder": "Enter your phone number",
"auth.verification_code": "Verification Code",
"auth.send_code": "Send Code",
"auth.code_sent": "Code sent",
"auth.code_login_not_supported": "Code login is not supported yet",
"auth.target_required": "Email or Phone is required",
"auth.target_and_code_required": "Email/Phone and Code are required",
"auth.email_disabled": "Email login is disabled",
"auth.sms_disabled": "SMS login is disabled",
"auth.username_login": "Username",
"auth.code_login": "Code",
"auth.create_account": "Create Account",
@ -43,11 +52,29 @@
"common.loading": "Loading...",
"common.not_found": "Not Found",
"common.save": "Save",
"common.saving": "Saving...",
"common.reset": "Reset",
"common.status": "Status",
"nav.dashboard": "Dashboard",
"nav.home": "Home",
"nav.oauth": "OAuth Apps",
"nav.roles": "Roles",
"nav.settings": "Settings",
"nav.oauth_providers": "Identity Providers",
"oauth.provider.code": "Code",
"oauth.provider.name": "Name",
"oauth.provider.client_id": "Client ID",
"oauth.provider.client_secret": "Client Secret",
"oauth.provider.redirect_uri": "Redirect URI",
"oauth.provider.auth_url": "Auth URL",
"oauth.provider.token_url": "Token URL",
"oauth.provider.user_info_url": "User Info URL",
"oauth.provider.scope": "Scopes",
"oauth.provider.enabled": "Enabled",
"oauth.provider.create": "Add Provider",
"oauth.provider.edit": "Edit Provider",
"oauth.provider.delete_confirm": "Delete this provider?",
"oauth.provider.templates": "Templates",
"nav.profile": "Profile",
"nav.users": "Users",
"user.email": "Email",
@ -60,7 +87,76 @@
"role.create": "Create Role",
"role.edit": "Edit Role",
"role.delete_confirm": "Are you sure you want to delete this role?",
"role.search_placeholder": "Search roles..."
"role.search_placeholder": "Search roles...",
"settings.category.app": "Application",
"settings.category.auth": "Authentication",
"settings.category.security": "Security",
"settings.category.code": "Verification Code",
"settings.category.email": "Email",
"settings.category.sms": "SMS",
"settings.app.name": "App Name",
"settings.app.name_desc": "Display name of the application",
"settings.app.id": "App ID",
"settings.app.id_desc": "Unique identifier for the application",
"settings.auth.reg_require_email": "Require Email for Registration",
"settings.auth.reg_require_email_desc": "Make email mandatory during registration",
"settings.auth.reg_require_phone": "Require Phone for Registration",
"settings.auth.reg_require_phone_desc": "Make phone number mandatory during registration",
"settings.auth.login_methods": "Login Methods",
"settings.auth.login_methods_desc": "Available login methods (JSON array)",
"settings.auth.password_fields": "Password Login Fields",
"settings.auth.password_fields_desc": "Fields allowed for password login",
"settings.security.captcha_enabled": "Enable Captcha",
"settings.security.captcha_enabled_desc": "Show captcha verification on login",
"settings.security.max_login_attempts": "Max Login Attempts",
"settings.security.max_login_attempts_desc": "Maximum failed login attempts before lockout",
"settings.security.bcrypt_cost": "Bcrypt Cost",
"settings.security.bcrypt_cost_desc": "Password hashing strength (4-31)",
"settings.code.expiry": "Code Expiry",
"settings.code.expiry_desc": "Verification code validity period (minutes)",
"settings.code.length": "Code Length",
"settings.code.length_desc": "Number of digits in verification code",
"settings.code.max_attempt": "Max Attempts",
"settings.code.max_attempt_desc": "Maximum verification code attempts",
"settings.code.send_interval": "Send Interval",
"settings.code.send_interval_desc": "Minimum seconds between code sends",
"settings.code.max_daily_count": "Max Daily Count",
"settings.code.max_daily_count_desc": "Maximum daily send count (0 to disable, -1 for unlimited)",
"settings.email.enabled": "Enable Email",
"settings.email.enabled_desc": "Enable email service for notifications",
"settings.email.provider": "Email Provider",
"settings.email.provider_desc": "Email service provider",
"settings.email.smtp_host": "SMTP Host",
"settings.email.smtp_host_desc": "SMTP server hostname",
"settings.email.smtp_port": "SMTP Port",
"settings.email.smtp_port_desc": "SMTP server port",
"settings.email.smtp_user": "SMTP Username",
"settings.email.smtp_user_desc": "SMTP authentication username",
"settings.email.smtp_pass": "SMTP Password",
"settings.email.smtp_pass_desc": "SMTP authentication password",
"settings.email.from": "From Email",
"settings.email.from_desc": "Sender email address",
"settings.email.from_name": "From Name",
"settings.email.from_name_desc": "Sender display name",
"settings.sms.enabled": "Enable SMS",
"settings.sms.enabled_desc": "Enable SMS service for verification codes",
"settings.sms.provider": "SMS Provider",
"settings.sms.provider_desc": "SMS service provider",
"settings.sms.provider_aliyun": "Alibaba Cloud",
"settings.sms.provider_tencent": "Tencent Cloud",
"settings.sms.access_key": "Access Key",
"settings.sms.access_key_desc": "API access key ID",
"settings.sms.access_secret": "Access Secret",
"settings.sms.access_secret_desc": "API access key secret",
"settings.sms.sign_name": "SMS Sign Name",
"settings.sms.sign_name_desc": "Registered SMS signature",
"settings.sms.template_code": "Template Code",
"settings.sms.template_code_desc": "Verification code template ID",
"settings.load_failed": "Failed to load settings",
"settings.save_success": "Settings saved successfully",
"settings.save_failed": "Failed to save settings",
"settings.reset_done": "Settings reset to original values",
"settings.no_changes": "No changes to save"
},
"zh-CN": {
"auth.email": "邮箱",
@ -72,7 +168,16 @@
"auth.confirm_password": "确认密码",
"auth.username_or_email": "用户名或邮箱",
"auth.email_or_phone": "邮箱或手机号",
"auth.phone": "手机号",
"auth.phone_placeholder": "请输入手机号",
"auth.verification_code": "验证码",
"auth.send_code": "发送验证码",
"auth.code_sent": "验证码已发送",
"auth.code_login_not_supported": "验证码登录暂不支持",
"auth.target_required": "请输入邮箱或手机号",
"auth.target_and_code_required": "请输入邮箱/手机号和验证码",
"auth.email_disabled": "邮箱登录未启用",
"auth.sms_disabled": "手机号登录未启用",
"auth.username_login": "用户名",
"auth.code_login": "验证码",
"auth.create_account": "创建账户",
@ -106,11 +211,29 @@
"common.loading": "加载中...",
"common.not_found": "页面未找到",
"common.save": "保存",
"common.saving": "保存中...",
"common.reset": "重置",
"common.status": "状态",
"nav.dashboard": "仪表盘",
"nav.home": "首页",
"nav.oauth": "OAuth应用",
"nav.roles": "角色管理",
"nav.settings": "系统设置",
"nav.oauth_providers": "身份源管理",
"oauth.provider.code": "代码",
"oauth.provider.name": "名称",
"oauth.provider.client_id": "客户端ID",
"oauth.provider.client_secret": "客户端密钥",
"oauth.provider.redirect_uri": "回调地址",
"oauth.provider.auth_url": "授权地址",
"oauth.provider.token_url": "令牌地址",
"oauth.provider.user_info_url": "用户信息地址",
"oauth.provider.scope": "权限范围",
"oauth.provider.enabled": "启用",
"oauth.provider.create": "添加身份源",
"oauth.provider.edit": "编辑身份源",
"oauth.provider.delete_confirm": "确定删除该身份源吗?",
"oauth.provider.templates": "模板",
"nav.profile": "个人中心",
"nav.users": "用户管理",
"user.email": "邮箱",
@ -123,6 +246,75 @@
"role.create": "创建角色",
"role.edit": "编辑角色",
"role.delete_confirm": "确定要删除该角色吗?",
"role.search_placeholder": "搜索角色..."
"role.search_placeholder": "搜索角色...",
"settings.category.app": "应用配置",
"settings.category.auth": "认证配置",
"settings.category.security": "安全配置",
"settings.category.code": "验证码配置",
"settings.category.email": "邮件配置",
"settings.category.sms": "短信配置",
"settings.app.name": "应用名称",
"settings.app.name_desc": "应用的显示名称",
"settings.app.id": "应用标识",
"settings.app.id_desc": "应用的唯一标识符",
"settings.auth.reg_require_email": "注册需要邮箱",
"settings.auth.reg_require_email_desc": "注册时强制要求填写邮箱",
"settings.auth.reg_require_phone": "注册需要手机号",
"settings.auth.reg_require_phone_desc": "注册时强制要求填写手机号",
"settings.auth.login_methods": "登录方式",
"settings.auth.login_methods_desc": "支持的登录方式JSON数组",
"settings.auth.password_fields": "密码登录字段",
"settings.auth.password_fields_desc": "密码登录支持的字段",
"settings.security.captcha_enabled": "启用验证码",
"settings.security.captcha_enabled_desc": "登录时显示验证码验证",
"settings.security.max_login_attempts": "最大登录尝试次数",
"settings.security.max_login_attempts_desc": "失败登录尝试的最大次数",
"settings.security.bcrypt_cost": "Bcrypt强度",
"settings.security.bcrypt_cost_desc": "密码哈希强度4-31",
"settings.code.expiry": "验证码有效期",
"settings.code.expiry_desc": "验证码有效时间(分钟)",
"settings.code.length": "验证码长度",
"settings.code.length_desc": "验证码的位数",
"settings.code.max_attempt": "最大尝试次数",
"settings.code.max_attempt_desc": "验证码最大尝试次数",
"settings.code.send_interval": "发送间隔",
"settings.code.send_interval_desc": "两次发送之间的最小间隔(秒)",
"settings.code.max_daily_count": "单日最大发送次数",
"settings.code.max_daily_count_desc": "单日发送验证码最大次数0禁用-1不限制",
"settings.email.enabled": "启用邮件",
"settings.email.enabled_desc": "启用邮件服务用于通知",
"settings.email.provider": "邮件服务商",
"settings.email.provider_desc": "邮件服务提供商",
"settings.email.smtp_host": "SMTP服务器",
"settings.email.smtp_host_desc": "SMTP服务器地址",
"settings.email.smtp_port": "SMTP端口",
"settings.email.smtp_port_desc": "SMTP服务器端口",
"settings.email.smtp_user": "SMTP用户名",
"settings.email.smtp_user_desc": "SMTP认证用户名",
"settings.email.smtp_pass": "SMTP密码",
"settings.email.smtp_pass_desc": "SMTP认证密码",
"settings.email.from": "发件人邮箱",
"settings.email.from_desc": "发件人邮箱地址",
"settings.email.from_name": "发件人名称",
"settings.email.from_name_desc": "发件人显示名称",
"settings.sms.enabled": "启用短信",
"settings.sms.enabled_desc": "启用短信服务用于验证码",
"settings.sms.provider": "短信服务商",
"settings.sms.provider_desc": "短信服务提供商",
"settings.sms.provider_aliyun": "阿里云",
"settings.sms.provider_tencent": "腾讯云",
"settings.sms.access_key": "Access Key",
"settings.sms.access_key_desc": "API访问密钥ID",
"settings.sms.access_secret": "Access Secret",
"settings.sms.access_secret_desc": "API访问密钥密文",
"settings.sms.sign_name": "短信签名",
"settings.sms.sign_name_desc": "已注册的短信签名",
"settings.sms.template_code": "模板代码",
"settings.sms.template_code_desc": "验证码模板ID",
"settings.load_failed": "加载设置失败",
"settings.save_success": "设置保存成功",
"settings.save_failed": "保存设置失败",
"settings.reset_done": "设置已重置",
"settings.no_changes": "没有需要保存的更改"
}
}

@ -140,7 +140,9 @@
// Admin only items would be filtered here ideally
{label: () => $t('nav.users'), icon: "<i class='fas fa-users-cog'></i>", path: "/users"},
{label: () => $t('nav.roles'), icon: "<i class='fas fa-user-tag'></i>", path: "/roles"},
{label: () => $t('nav.oauth'), icon: "<i class='fas fa-key'></i>", path: "/oauth/apps"},
{label: () => $t('nav.oauth'), icon: "<i class='fas fa-rocket'></i>", path: "/oauth/apps"},
{label: () => $t('nav.oauth_providers'), icon: "<i class='fas fa-id-card'></i>", path: "/oauth/providers"},
{label: () => $t('nav.settings'), icon: "<i class='fas fa-cog'></i>", path: "/settings"},
];
getRouteName = () => {

@ -413,9 +413,7 @@
<form @submit="handleSignUp">
<h1>{{ $t('auth.create_account') }}</h1>
<div class="social-container">
<a @click="handleOAuth('github')"><i class="fa-brands fa-github"></i></a>
<a @click="handleOAuth('wechat')"><i class="fa-brands fa-weixin"></i></a>
<a @click="handleOAuth('google')"><i class="fa-brands fa-google"></i></a>
<a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name"><i :class="'fa-brands fa-' + p.icon"></i></a>
</div>
<span>{{ $t('auth.use_info_register') }}</span>
@ -423,16 +421,21 @@
<input type="text" :placeholder="$t('auth.username')" v:value="signUpForm.username" required />
</div>
<div class="input-group">
<div class="input-group" v-if="emailEnabled">
<input type="email" :placeholder="$t('auth.email')" v:value="signUpForm.email" />
</div>
<div class="input-group" v-if="smsEnabled">
<input type="text" :placeholder="$t('auth.phone_placeholder')" v:value="signUpForm.phone" />
</div>
<div class="input-group">
<input type="password" :placeholder="$t('auth.password')" v:value="signUpForm.password" required />
</div>
<div class="input-group">
<input type="password" :placeholder="$t('auth.confirm_password')" v:value="signUpForm.confirmPassword" required />
<input type="password" :placeholder="$t('auth.confirm_password')" v:value="signUpForm.confirmPassword"
required />
</div>
<div class="error-message">{{ signUpError }}</div>
@ -453,14 +456,12 @@
<form @submit="handleSignIn">
<h1>{{ $t('auth.login') }}</h1>
<div class="social-container">
<a @click="handleOAuth('github')"><i class="fa-brands fa-github"></i></a>
<a @click="handleOAuth('wechat')"><i class="fa-brands fa-weixin"></i></a>
<a @click="handleOAuth('google')"><i class="fa-brands fa-google"></i></a>
<a v-for="p in providers" @click="handleOAuth(p.code)" :title="p.name"><i :class="'fa-brands fa-' + p.icon"></i></a>
</div>
<span>{{ $t('auth.use_account') }}</span>
<!-- Login Type Tabs -->
<div class="login-tab">
<div class="login-tab" v-if="smsEnabled || emailEnabled">
<div class="tab-item" :class="{ active: loginType === 'username' }" @click="switchLoginType('username')">
{{ $t('auth.username_login') }}
</div>
@ -482,7 +483,7 @@
<!-- Code Login -->
<div v-if="loginType === 'code'" style="width: 100%;">
<div class="input-group">
<input type="text" :placeholder="$t('auth.email_or_phone')" v:value="signInForm.target" required />
<input type="text" :placeholder="codePlaceholder" v:value="signInForm.target" required />
</div>
<div class="input-group">
<input type="text" :placeholder="$t('auth.verification_code')" v:value="signInForm.code" required />
@ -528,6 +529,14 @@
loginType = 'username';
redirect = $router.query.redirect || '/';
smsEnabled = false;
emailEnabled = false;
regRequireEmail = false;
regRequirePhone = false;
codePlaceholder = $t('auth.email_or_phone');
countDown = 0;
timer = null;
signInForm = {
username: '',
password: '',
@ -538,6 +547,7 @@
signUpForm = {
username: '',
email: '',
phone: '',
password: '',
confirmPassword: ''
};
@ -547,6 +557,7 @@
signInLoading = false;
signUpLoading = false;
bubbles = [];
providers = [];
// Create background bubbles
createBubbles = () => {
@ -597,8 +608,94 @@
};
// Handle OAuth login
handleOAuth = (provider) => {
$message.warning($t('auth.oauth_not_ready', { provider: provider }));
handleOAuth = async (provider) => {
try {
const res = await $axios.get('/api/auth/authorize/thirdparty', {
params: {
provider: provider,
redirect: redirect
}
});
if (res && res.auth_url) {
window.location.href = res.auth_url;
}
} catch (e) {
$message.error(e.message || $t('auth.oauth_failed'));
}
};
// Load configuration
loadConfig = async () => {
try {
const res = await $axios.get('/api/info');
if (res) {
if (res.oauth_providers) {
providers = res.oauth_providers;
}
// Update feature flags
smsEnabled = res.sms_enabled;
emailEnabled = res.email_enabled;
regRequireEmail = res.reg_require_email;
regRequirePhone = res.reg_require_phone;
// Update placeholder
if (smsEnabled && emailEnabled) {
codePlaceholder = $t('auth.email_or_phone');
} else if (smsEnabled) {
codePlaceholder = $t('auth.phone_placeholder');
} else if (emailEnabled) {
codePlaceholder = $t('auth.email');
}
}
} catch (e) {
console.error("Failed to load config:", e);
}
};
// Send verification code
sendCode = async () => {
if (countDown > 0) return;
if (!signInForm.target) {
signInError = $t('auth.target_required');
return;
}
// Determine type
const type = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(signInForm.target) ? 'email' : 'sms';
// Check if enabled
if (type === 'email' && !emailEnabled) {
signInError = $t('auth.email_disabled');
return;
}
if (type === 'sms' && !smsEnabled) {
signInError = $t('auth.sms_disabled');
return;
}
try {
await $env.$vbase.request('POST', '/api/verification/send', {
type: type,
target: signInForm.target,
purpose: 'login'
});
signInError = '';
$message.success($t('auth.code_sent'));
countDown = 60;
if (timer) clearInterval(timer);
timer = setInterval(() => {
countDown--;
if (countDown <= 0) {
clearInterval(timer);
timer = null;
}
}, 1000);
} catch (e) {
signInError = e.message || 'Failed to send code';
}
};
// Handle Sign In
@ -610,48 +707,43 @@
signInLoading = true;
try {
let loginData = {};
if (loginType === 'username') {
if (!signInForm.username || !signInForm.password) {
signInError = $t('auth.fill_all_fields');
signInLoading = false;
return;
}
loginData = {
username: signInForm.username,
password: signInForm.password
};
} else {
// Code login
if (loginType === 'code') {
if (!signInForm.target || !signInForm.code) {
signInError = $t('auth.fill_all_fields');
signInLoading = false;
return;
throw new Error($t('auth.target_and_code_required'));
}
const type = signInForm.target.includes('@') ? 'email' : 'phone';
loginData = {
const type = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(signInForm.target) ? 'email' : 'phone';
const data = await $env.$vbase.request('POST', '/api/auth/login/code', {
type: type,
target: signInForm.target,
code: signInForm.code
};
}
});
const response = await $axios.post('/api/auth/login', loginData);
if (data && data.access_token) {
$env.$vbase.token = data.access_token;
if (data.refresh_token) $env.$vbase.refreshToken = data.refresh_token;
if (data.user) $env.$vbase.user = data.user;
if (response && response.access_token) {
// Use vbase to handle login
$env.$vbase.token = response.access_token;
if (response.refresh_token) {
$env.$vbase.refreshToken = response.refresh_token;
$message.success($t('auth.login_success'));
$router.push(redirect);
} else {
throw new Error('Login failed: no token received');
}
if (response.user) {
$env.$vbase.user = response.user;
} else {
// Username/Password login
if (!signInForm.username || !signInForm.password) {
throw new Error($t('auth.fill_all_fields'));
}
const success = await $env.$vbase.login(signInForm.username, signInForm.password);
if (success) {
$message.success($t('auth.login_success'));
$router.push(redirect);
} else {
signInError = $t('auth.invalid_response');
throw new Error($t('auth.login_failed'));
}
}
} catch (error) {
signInError = error.message || $t('auth.login_failed');
@ -691,20 +783,22 @@
signUpLoading = true;
try {
const response = await $axios.post('/api/auth/register', {
// Use vbase.request for consistent API handling
const data = await $env.$vbase.request('POST', '/api/auth/register', {
username: signUpForm.username,
email: signUpForm.email || undefined,
phone: signUpForm.phone || undefined,
password: signUpForm.password
});
if (response && response.access_token) {
// Auto login after register
$env.$vbase.token = response.access_token;
if (response.refresh_token) {
$env.$vbase.refreshToken = response.refresh_token;
if (data && data.access_token) {
// Auto login after register using vbase setters
$env.$vbase.token = data.access_token;
if (data.refresh_token) {
$env.$vbase.refreshToken = data.refresh_token;
}
if (response.user) {
$env.$vbase.user = response.user;
if (data.user) {
$env.$vbase.user = data.user;
}
$message.success($t('auth.register_success'));
@ -722,6 +816,7 @@
// Initialize
createBubbles();
loadConfig();
</script>
</html>

@ -0,0 +1,252 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="Identity Providers">
<title>{{ $t('nav.oauth_providers') }}</title>
<style>
body {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
box-sizing: border-box;
background-color: var(--bg-color);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
}
.page-title {
font-size: var(--font-size-2xl);
font-weight: bold;
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--spacing-lg);
}
.card {
background: var(--bg-color-secondary);
padding: var(--spacing-lg);
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
transition: all 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-weight: bold;
font-size: var(--font-size-lg);
display: flex;
align-items: center;
gap: 8px;
}
.status-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
}
.status-enabled {
background: #e6f4ea;
color: #1e8e3e;
}
.status-disabled {
background: #fce8e6;
color: #c5221f;
}
.info-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 12px;
color: var(--text-color-tertiary);
}
.info-value {
font-family: monospace;
background: var(--bg-color-tertiary);
padding: 4px;
border-radius: 4px;
word-break: break-all;
font-size: 13px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
</style>
</head>
<body>
<div class="page-header">
<div class="page-title">
<i class="fas fa-id-card" style="color: var(--color-primary);"></i>
{{ $t('nav.oauth_providers') }}
</div>
<v-btn color="primary" :click="openCreateModal">
<i class="fas fa-plus"></i>
{{ $t('oauth.provider.create') }}
</v-btn>
</div>
<div class="grid">
<div class="card" v-for="p in providers">
<div class="card-header">
<div class="card-title">
<i :class="'fab fa-' + p.icon" v-if="p.icon"></i>
{{ p.name }}
</div>
<span class="status-badge" :class="p.enabled ? 'status-enabled' : 'status-disabled'">
{{ p.enabled ? $t('oauth.provider.enabled') : 'Disabled' }}
</span>
</div>
<div class="info-row">
<span class="info-label">{{ $t('oauth.provider.code') }}</span>
<div class="info-value">{{ p.code }}</div>
</div>
<div class="info-row">
<span class="info-label">{{ $t('oauth.provider.client_id') }}</span>
<div class="info-value">{{ p.client_id || '-' }}</div>
</div>
<div class="actions">
<v-btn icon size="sm" variant="outline" :click="() => openEditModal(p)">
<i class="fas fa-edit"></i>
</v-btn>
<v-btn icon size="sm" color="danger" variant="outline" :click="() => deleteProvider(p)">
<i class="fas fa-trash"></i>
</v-btn>
</div>
</div>
</div>
<v-dialog v:visible="showModal" :title="isEdit ? $t('oauth.provider.edit') : $t('oauth.provider.create')">
<form @submit.prevent="saveProvider" style="display: grid; gap: 16px;">
<v-input :label="$t('oauth.provider.code')" required v:value="formData.code" :disabled="isEdit"></v-input>
<v-input :label="$t('oauth.provider.name')" required v:value="formData.name"></v-input>
<v-input :label="$t('oauth.provider.client_id')" required v:value="formData.client_id"></v-input>
<v-input :label="$t('oauth.provider.client_secret')" type="password" v:value="formData.client_secret"
:placeholder="isEdit ? 'Leave empty to keep unchanged' : ''"></v-input>
<div style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="enabled" v:checked="formData.enabled"
@change="e => formData.enabled = e.target.checked">
<label for="enabled">{{ $t('oauth.provider.enabled') }}</label>
</div>
</form>
<div vslot="footer">
<v-btn variant="outline" :click="closeModal">{{ $t('common.cancel') }}</v-btn>
<v-btn color="primary" :click="saveProvider">{{ $t('common.save') }}</v-btn>
</div>
</v-dialog>
</body>
<script setup>
providers = [];
showModal = false;
isEdit = false;
formData = {
code: "",
name: "",
client_id: "",
client_secret: "",
enabled: false
};
loadProviders = async () => {
try {
const res = await $axios.get('/api/oauth/providers');
providers = res.items || [];
} catch (e) {
$message.error(e.message);
}
};
openCreateModal = () => {
isEdit = false;
formData = { code: "", name: "", client_id: "", client_secret: "", enabled: true };
showModal = true;
};
openEditModal = async (p) => {
isEdit = true;
try {
const detail = await $axios.get(`/api/oauth/providers/${p.code}`);
formData = { ...detail, client_secret: "" };
showModal = true;
} catch (e) {
$message.error(e.message);
}
};
closeModal = () => showModal = false;
saveProvider = async () => {
try {
if (isEdit) {
await $axios.patch(`/api/oauth/providers/${formData.code}`, formData);
} else {
await $axios.post('/api/oauth/providers', formData);
}
$message.success("Saved");
closeModal();
loadProviders();
} catch (e) {
$message.error(e.message);
}
};
deleteProvider = async (p) => {
if (!confirm(`Delete ${p.name}?`)) return;
try {
await $axios.delete(`/api/oauth/providers/${p.code}`);
$message.success("Deleted");
loadProviders();
} catch (e) {
$message.error(e.message);
}
};
</script>
<script>
$data.loadProviders();
</script>
</html>

@ -0,0 +1,736 @@
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="System Settings Management">
<title>{{ $t('nav.settings') }}</title>
<style>
.settings-container {
padding: 20px;
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: var(--color-text, #1f2937);
margin: 0;
}
.settings-card {
background: var(--bg-color-secondary, #fff);
border-radius: var(--border-radius, 8px);
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,0.05));
margin-bottom: 20px;
overflow: hidden;
}
.card-header {
background: var(--bg-color-tertiary, #f9fafb);
padding: 16px 20px;
border-bottom: 1px solid var(--color-border, #e5e7eb);
font-weight: 600;
font-size: 16px;
color: var(--color-text, #1f2937);
display: flex;
align-items: center;
gap: 8px;
}
.card-header i {
color: var(--color-primary, #4f46e5);
}
.card-body {
padding: 20px;
}
.setting-item {
display: flex;
align-items: flex-start;
padding: 16px 0;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.setting-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.setting-item:first-child {
padding-top: 0;
}
.setting-info {
flex: 1;
margin-right: 20px;
}
.setting-label {
font-weight: 500;
color: var(--color-text, #1f2937);
margin-bottom: 4px;
}
.setting-desc {
font-size: 13px;
color: var(--color-text-light, #6b7280);
}
.setting-control {
min-width: 200px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.setting-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: var(--border-radius, 6px);
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.setting-input:focus {
outline: none;
border-color: var(--color-primary, #4f46e5);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.setting-select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: var(--border-radius, 6px);
font-size: 14px;
background-color: var(--bg-color-secondary, #fff);
cursor: pointer;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 48px;
height: 24px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .3s;
border-radius: 50%;
}
.toggle-switch input:checked + .toggle-slider {
background-color: var(--color-primary, #4f46e5);
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(24px);
}
.actions-bar {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.btn {
padding: 10px 20px;
border-radius: var(--border-radius, 6px);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background-color: var(--color-primary, #4f46e5);
color: white;
}
.btn-primary:hover {
background-color: var(--color-primary-dark, #4338ca);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background-color: var(--bg-color-tertiary, #f3f4f6);
color: var(--color-text, #1f2937);
border: 1px solid var(--color-border, #d1d5db);
}
.btn-secondary:hover {
background-color: var(--bg-color-secondary, #e5e7eb);
}
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-primary, #4f46e5);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.json-editor {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
min-height: 80px;
resize: vertical;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--color-primary-light, #e0e7ff);
color: var(--color-primary, #4f46e5);
border-radius: 4px;
font-size: 13px;
}
.tag-remove {
cursor: pointer;
opacity: 0.6;
}
.tag-remove:hover {
opacity: 1;
}
.tag-input {
border: none;
background: transparent;
padding: 4px 8px;
font-size: 13px;
min-width: 100px;
}
.tag-input:focus {
outline: none;
}
</style>
</head>
<body>
<div class="settings-container">
<div class="page-header">
<h1 class="page-title">{{ $t('nav.settings') }}</h1>
</div>
<div v-if="loading" class="loading-overlay">
<div class="loading-spinner"></div>
</div>
<!-- Application Settings -->
<div class="settings-card">
<div class="card-header">
<i class="fa-solid fa-cube"></i>
{{ $t('settings.category.app') }}
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.app.name') }}</div>
<div class="setting-desc">{{ $t('settings.app.name_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input" v:value="settings['app.name']" />
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.app.id') }}</div>
<div class="setting-desc">{{ $t('settings.app.id_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input" v:value="settings['app.id']" />
</div>
</div>
</div>
</div>
<!-- Authentication Settings -->
<div class="settings-card">
<div class="card-header">
<i class="fa-solid fa-shield-halved"></i>
{{ $t('settings.category.auth') }}
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.auth.reg_require_email') }}</div>
<div class="setting-desc">{{ $t('settings.auth.reg_require_email_desc') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" :checked="settings['auth.reg.require_email'] === 'true'" @change="toggleSetting('auth.reg.require_email')" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.auth.reg_require_phone') }}</div>
<div class="setting-desc">{{ $t('settings.auth.reg_require_phone_desc') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" :checked="settings['auth.reg.require_phone'] === 'true'" @change="toggleSetting('auth.reg.require_phone')" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.auth.login_methods') }}</div>
<div class="setting-desc">{{ $t('settings.auth.login_methods_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input json-editor" v:value="settings['auth.login.methods']" placeholder='["password", "email_code"]' />
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.auth.password_fields') }}</div>
<div class="setting-desc">{{ $t('settings.auth.password_fields_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input json-editor" v:value="settings['auth.login.password_fields']" placeholder='["username", "email"]' />
</div>
</div>
</div>
</div>
<!-- Security Settings -->
<div class="settings-card">
<div class="card-header">
<i class="fa-solid fa-lock"></i>
{{ $t('settings.category.security') }}
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.security.captcha_enabled') }}</div>
<div class="setting-desc">{{ $t('settings.security.captcha_enabled_desc') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" :checked="settings['security.captcha_enabled'] === 'true'" @change="toggleSetting('security.captcha_enabled')" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.security.max_login_attempts') }}</div>
<div class="setting-desc">{{ $t('settings.security.max_login_attempts_desc') }}</div>
</div>
<div class="setting-control">
<input type="number" class="setting-input" v:value="settings['security.max_login_attempts']" />
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.security.bcrypt_cost') }}</div>
<div class="setting-desc">{{ $t('settings.security.bcrypt_cost_desc') }}</div>
</div>
<div class="setting-control">
<input type="number" class="setting-input" v:value="settings['security.bcrypt_cost']" min="4" max="31" />
</div>
</div>
</div>
</div>
<!-- Verification Code Settings -->
<div class="settings-card">
<div class="card-header">
<i class="fa-solid fa-key"></i>
{{ $t('settings.category.code') }}
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.code.expiry') }}</div>
<div class="setting-desc">{{ $t('settings.code.expiry_desc') }}</div>
</div>
<div class="setting-control">
<input type="number" class="setting-input" v:value="settings['code.expiry']" />
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.code.length') }}</div>
<div class="setting-desc">{{ $t('settings.code.length_desc') }}</div>
</div>
<div class="setting-control">
<input type="number" class="setting-input" v:value="settings['code.length']" />
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.code.max_attempt') }}</div>
<div class="setting-desc">{{ $t('settings.code.max_attempt_desc') }}</div>
</div>
<div class="setting-control">
<input type="number" class="setting-input" v:value="settings['code.max_attempt']" />
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.code.send_interval') }}</div>
<div class="setting-desc">{{ $t('settings.code.send_interval_desc') }}</div>
</div>
<div class="setting-control">
<input type="number" class="setting-input" v:value="settings['code.send_interval']" />
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.code.max_daily_count') }}</div>
<div class="setting-desc">{{ $t('settings.code.max_daily_count_desc') }}</div>
</div>
<div class="setting-control">
<input type="number" class="setting-input" v:value="settings['code.max_daily_count']" />
</div>
</div>
</div>
</div>
<!-- Email Settings -->
<div class="settings-card">
<div class="card-header">
<i class="fa-solid fa-envelope"></i>
{{ $t('settings.category.email') }}
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.email.enabled') }}</div>
<div class="setting-desc">{{ $t('settings.email.enabled_desc') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" :checked="settings['email.enabled'] === 'true'" @change="toggleSetting('email.enabled')" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item" v-if="settings['email.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.email.provider') }}</div>
<div class="setting-desc">{{ $t('settings.email.provider_desc') }}</div>
</div>
<div class="setting-control">
<select class="setting-select" v:value="settings['email.provider']">
<option value="smtp">SMTP</option>
<option value="sendgrid">SendGrid</option>
</select>
</div>
</div>
<div class="setting-item" v-if="settings['email.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.email.smtp_host') }}</div>
<div class="setting-desc">{{ $t('settings.email.smtp_host_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input" v:value="settings['email.smtp.host']" />
</div>
</div>
<div class="setting-item" v-if="settings['email.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.email.smtp_port') }}</div>
<div class="setting-desc">{{ $t('settings.email.smtp_port_desc') }}</div>
</div>
<div class="setting-control">
<input type="number" class="setting-input" v:value="settings['email.smtp.port']" />
</div>
</div>
<div class="setting-item" v-if="settings['email.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.email.smtp_user') }}</div>
<div class="setting-desc">{{ $t('settings.email.smtp_user_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input" v:value="settings['email.smtp.user']" />
</div>
</div>
<div class="setting-item" v-if="settings['email.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.email.smtp_pass') }}</div>
<div class="setting-desc">{{ $t('settings.email.smtp_pass_desc') }}</div>
</div>
<div class="setting-control">
<input type="password" class="setting-input" v:value="settings['email.smtp.pass']" placeholder="******" />
</div>
</div>
<div class="setting-item" v-if="settings['email.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.email.from') }}</div>
<div class="setting-desc">{{ $t('settings.email.from_desc') }}</div>
</div>
<div class="setting-control">
<input type="email" class="setting-input" v:value="settings['email.from']" />
</div>
</div>
<div class="setting-item" v-if="settings['email.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.email.from_name') }}</div>
<div class="setting-desc">{{ $t('settings.email.from_name_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input" v:value="settings['email.from_name']" />
</div>
</div>
</div>
</div>
<!-- SMS Settings -->
<div class="settings-card">
<div class="card-header">
<i class="fa-solid fa-comment-sms"></i>
{{ $t('settings.category.sms') }}
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.sms.enabled') }}</div>
<div class="setting-desc">{{ $t('settings.sms.enabled_desc') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" :checked="settings['sms.enabled'] === 'true'" @change="toggleSetting('sms.enabled')" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item" v-if="settings['sms.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.sms.provider') }}</div>
<div class="setting-desc">{{ $t('settings.sms.provider_desc') }}</div>
</div>
<div class="setting-control">
<select class="setting-select" v:value="settings['sms.provider']">
<option value="aliyun">{{ $t('settings.sms.provider_aliyun') }}</option>
<option value="tencent">{{ $t('settings.sms.provider_tencent') }}</option>
</select>
</div>
</div>
<div class="setting-item" v-if="settings['sms.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.sms.access_key') }}</div>
<div class="setting-desc">{{ $t('settings.sms.access_key_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input" v:value="settings['sms.access_key']" />
</div>
</div>
<div class="setting-item" v-if="settings['sms.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.sms.access_secret') }}</div>
<div class="setting-desc">{{ $t('settings.sms.access_secret_desc') }}</div>
</div>
<div class="setting-control">
<input type="password" class="setting-input" v:value="settings['sms.access_secret']" placeholder="******" />
</div>
</div>
<div class="setting-item" v-if="settings['sms.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.sms.sign_name') }}</div>
<div class="setting-desc">{{ $t('settings.sms.sign_name_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input" v:value="settings['sms.sign_name']" />
</div>
</div>
<div class="setting-item" v-if="settings['sms.enabled'] === 'true'">
<div class="setting-info">
<div class="setting-label">{{ $t('settings.sms.template_code') }}</div>
<div class="setting-desc">{{ $t('settings.sms.template_code_desc') }}</div>
</div>
<div class="setting-control">
<input type="text" class="setting-input" v:value="settings['sms.template_code']" />
</div>
</div>
</div>
</div>
<div class="actions-bar">
<button class="btn btn-secondary" @click="resetSettings">{{ $t('common.reset') }}</button>
<button class="btn btn-primary" @click="saveSettings" :disabled="saving">
{{ saving ? $t('common.saving') : $t('common.save') }}
</button>
</div>
</div>
</body>
<script setup>
loading = true;
saving = false;
settings = {};
originalSettings = {};
// Setting keys to manage
settingKeys = [
'app.name',
'app.id',
'auth.reg.require_email',
'auth.reg.require_phone',
'auth.login.methods',
'auth.login.password_fields',
'security.captcha_enabled',
'security.max_login_attempts',
'security.bcrypt_cost',
'code.expiry',
'code.length',
'code.max_attempt',
'code.send_interval',
'code.max_daily_count',
'email.enabled',
'email.provider',
'email.smtp.host',
'email.smtp.port',
'email.smtp.user',
'email.smtp.pass',
'email.from',
'email.from_name',
'sms.enabled',
'sms.provider',
'sms.access_key',
'sms.access_secret',
'sms.sign_name',
'sms.template_code'
];
// Load settings from API
loadSettings = async () => {
loading = true;
try {
const response = await $axios.get('/api/settings');
if (response && response.items) {
const settingsMap = {};
response.items.forEach(item => {
settingsMap[item.key] = item.value;
});
settings = settingsMap;
originalSettings = { ...settingsMap };
}
} catch (error) {
$message.error(error.message || $t('settings.load_failed'));
} finally {
loading = false;
}
};
// Toggle boolean setting
toggleSetting = (key) => {
settings[key] = settings[key] === 'true' ? 'false' : 'true';
};
// Reset to original values
resetSettings = () => {
settings = { ...originalSettings };
$message.info($t('settings.reset_done'));
};
// Save all settings
saveSettings = async () => {
saving = true;
try {
const settingsToUpdate = [];
settingKeys.forEach(key => {
if (settings[key] !== undefined && settings[key] !== originalSettings[key]) {
settingsToUpdate.push({
key: key,
value: String(settings[key])
});
}
});
if (settingsToUpdate.length === 0) {
$message.info($t('settings.no_changes'));
saving = false;
return;
}
await $axios.put('/api/settings', { settings: settingsToUpdate });
originalSettings = { ...settings };
$message.success($t('settings.save_success'));
} catch (error) {
$message.error(error.message || $t('settings.save_failed'));
} finally {
saving = false;
}
};
// Initialize
loadSettings();
</script>
</html>

@ -13,6 +13,9 @@ const routes = [
// Role Management
{ path: '/roles', component: '/page/sys/role/index.html', layout: 'default', meta: { auth: true } },
// Settings Management
{ path: '/settings', component: '/page/sys/settings.html', layout: 'default', meta: { auth: true } },
// User System
{ path: '/profile', component: '/page/user/profile.html', layout: 'default', meta: { auth: true } },
{
@ -24,6 +27,7 @@ const routes = [
// OAuth Management
{ path: '/oauth/apps', component: '/page/sys/oauth/index.html', layout: 'default', meta: { auth: true } },
{ path: '/oauth/providers', component: '/page/sys/oauth/providers.html', layout: 'default', meta: { auth: true } },
// Errors
{ path: '/403', component: '/page/403.html', layout: 'public' },

Loading…
Cancel
Save