mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(auth): password change dialog + workspace member provisioning improvements
This commit is contained in:
parent
c4883e108c
commit
8fbe30d7ac
@ -3,12 +3,14 @@ package vip.mate.auth.controller;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.auth.model.LoginRequest;
|
||||
import vip.mate.auth.model.LoginResponse;
|
||||
import vip.mate.auth.model.UserEntity;
|
||||
import vip.mate.auth.service.AuthService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -48,8 +50,15 @@ public class AuthController {
|
||||
public R<Void> changePassword(
|
||||
@PathVariable Long id,
|
||||
@RequestParam String oldPassword,
|
||||
@RequestParam String newPassword) {
|
||||
authService.changePassword(id, oldPassword, newPassword);
|
||||
@RequestParam String newPassword,
|
||||
Authentication auth) {
|
||||
// Resolve user from the JWT principal — the {id} path segment is
|
||||
// informational. A user may only change their own password.
|
||||
UserEntity me = authService.findByUsername(auth.getName());
|
||||
if (me == null) {
|
||||
throw new MateClawException("err.auth.user_not_found", "用户不存在");
|
||||
}
|
||||
authService.changePassword(me.getId(), oldPassword, newPassword);
|
||||
return R.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import lombok.Data;
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class LoginResponse {
|
||||
private Long id;
|
||||
private String token;
|
||||
private String username;
|
||||
private String nickname;
|
||||
|
||||
@ -55,7 +55,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
String token = generateToken(user);
|
||||
return new LoginResponse(token, user.getUsername(), user.getNickname(), user.getRole());
|
||||
return new LoginResponse(user.getId(), token, user.getUsername(), user.getNickname(), user.getRole());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -76,7 +76,10 @@ public class AuthService {
|
||||
if (count > 0) {
|
||||
throw new MateClawException("err.auth.username_exists", "用户名已存在: " + user.getUsername());
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(user.getPassword()));
|
||||
if (user.getPassword() == null || user.getPassword().isBlank()) {
|
||||
throw new MateClawException("err.auth.password_required", "Password is required");
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(user.getPassword().trim()));
|
||||
user.setEnabled(true);
|
||||
if (user.getRole() == null) {
|
||||
user.setRole("user");
|
||||
@ -86,6 +89,22 @@ public class AuthService {
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset password (admin operation — no old password required).
|
||||
* Used when an admin wants to set/reset a member's password.
|
||||
*/
|
||||
public void resetPassword(Long userId, String newPassword) {
|
||||
if (newPassword == null || newPassword.isBlank()) {
|
||||
throw new MateClawException("err.auth.password_required", "Password is required");
|
||||
}
|
||||
UserEntity user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new MateClawException("err.auth.user_not_found", "用户不存在");
|
||||
}
|
||||
user.setPassword(passwordEncoder.encode(newPassword.trim()));
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*/
|
||||
|
||||
@ -94,7 +94,35 @@ public class WorkspaceController {
|
||||
Authentication auth) {
|
||||
Long userId = resolveUserId(auth);
|
||||
workspaceService.requirePermission(id, userId, "admin");
|
||||
Long targetUserId = Long.valueOf(body.get("userId").toString());
|
||||
|
||||
Long targetUserId;
|
||||
if (body.containsKey("username")) {
|
||||
String username = body.get("username").toString().trim();
|
||||
String password = body.containsKey("password") && body.get("password") != null
|
||||
? body.get("password").toString().trim() : null;
|
||||
UserEntity target = authService.findByUsername(username);
|
||||
if (target == null) {
|
||||
// User does not exist — create account (password required)
|
||||
if (password == null || password.isBlank()) {
|
||||
throw new MateClawException("err.workspace.user_not_found",
|
||||
"User not found: " + username + ". Provide a password to create the account.");
|
||||
}
|
||||
UserEntity newUser = new UserEntity();
|
||||
newUser.setUsername(username);
|
||||
newUser.setPassword(password);
|
||||
newUser.setNickname(body.containsKey("nickname")
|
||||
? body.get("nickname").toString() : username);
|
||||
target = authService.createUser(newUser);
|
||||
} else if (password != null && !password.isBlank()) {
|
||||
// User exists AND admin provided a password — reset it.
|
||||
// This fixes the case where an admin removes a member, re-adds
|
||||
// them with a new password, but the stale password blocks login.
|
||||
authService.resetPassword(target.getId(), password);
|
||||
}
|
||||
targetUserId = target.getId();
|
||||
} else {
|
||||
targetUserId = Long.valueOf(body.get("userId").toString());
|
||||
}
|
||||
String role = body.containsKey("role") ? body.get("role").toString() : "member";
|
||||
return R.ok(workspaceService.addMember(id, targetUserId, role));
|
||||
}
|
||||
|
||||
@ -68,6 +68,8 @@ export const authApi = {
|
||||
http.post('/auth/login', data),
|
||||
listUsers: () => http.get('/auth/users'),
|
||||
createUser: (data: any) => http.post('/auth/users', data),
|
||||
changePassword: (id: number, oldPassword: string, newPassword: string) =>
|
||||
http.put(`/auth/users/${id}/password`, null, { params: { oldPassword, newPassword } }),
|
||||
}
|
||||
|
||||
// ==================== Agent ====================
|
||||
@ -415,7 +417,7 @@ export const workspaceTeamApi = {
|
||||
update: (id: string | number, data: any) => http.put(`/workspaces/${id}`, data),
|
||||
delete: (id: string | number) => http.delete(`/workspaces/${id}`),
|
||||
listMembers: (id: string | number) => http.get(`/workspaces/${id}/members`),
|
||||
addMember: (id: string | number, data: { userId: number; role?: string }) =>
|
||||
addMember: (id: string | number, data: { username: string; password?: string; nickname?: string; role?: string }) =>
|
||||
http.post(`/workspaces/${id}/members`, data),
|
||||
updateMemberRole: (id: string | number, memberId: string | number, role: string) =>
|
||||
http.put(`/workspaces/${id}/members/${memberId}`, { role }),
|
||||
|
||||
305
mateclaw-ui/src/components/ChangePasswordDialog.vue
Normal file
305
mateclaw-ui/src/components/ChangePasswordDialog.vue
Normal file
@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="modal-overlay" @click.self="close">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ t('auth.changePassword') }}</h3>
|
||||
<button class="modal-close" @click="close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t('auth.oldPassword') }}</label>
|
||||
<div class="password-wrapper">
|
||||
<input
|
||||
v-model="form.oldPassword"
|
||||
:type="showOld ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button type="button" class="toggle-eye" @click="showOld = !showOld">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path v-if="showOld" d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
|
||||
<line v-if="showOld" x1="1" y1="1" x2="23" y2="23"/>
|
||||
<path v-else d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle v-if="!showOld" cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('auth.newPassword') }}</label>
|
||||
<div class="password-wrapper">
|
||||
<input
|
||||
v-model="form.newPassword"
|
||||
:type="showNew ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button type="button" class="toggle-eye" @click="showNew = !showNew">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path v-if="showNew" d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
|
||||
<line v-if="showNew" x1="1" y1="1" x2="23" y2="23"/>
|
||||
<path v-else d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle v-if="!showNew" cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('auth.confirmPassword') }}</label>
|
||||
<input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="close">{{ t('common.cancel') }}</button>
|
||||
<button class="btn-primary" :disabled="submitting" @click="handleSubmit">
|
||||
{{ submitting ? t('common.loading') : t('common.confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { authApi } from '@/api/index'
|
||||
|
||||
const props = defineProps<{ visible: boolean }>()
|
||||
const emit = defineEmits<{ 'update:visible': [value: boolean] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const submitting = ref(false)
|
||||
const showOld = ref(false)
|
||||
const showNew = ref(false)
|
||||
const form = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||
|
||||
watch(() => props.visible, (open) => {
|
||||
if (open) {
|
||||
form.oldPassword = ''
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
showOld.value = false
|
||||
showNew.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.oldPassword || !form.newPassword) {
|
||||
ElMessage.warning(t('auth.fieldsRequired'))
|
||||
return
|
||||
}
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
ElMessage.warning(t('auth.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const userId = Number(localStorage.getItem('userId') || '1')
|
||||
await authApi.changePassword(userId, form.oldPassword, form.newPassword)
|
||||
ElMessage.success(t('auth.passwordChanged'))
|
||||
close()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.msg || error?.message || t('auth.passwordChangeFailed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(124, 63, 30, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
background: var(--mc-bg-elevated);
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 16px 48px rgba(25, 14, 8, 0.18);
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--mc-border-light);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--mc-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--mc-text-tertiary);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--mc-bg-muted);
|
||||
color: var(--mc-text-primary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--mc-text-secondary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--mc-border);
|
||||
border-radius: 8px;
|
||||
background: var(--mc-bg-sunken);
|
||||
color: var(--mc-text-primary);
|
||||
font-size: 14px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--mc-primary);
|
||||
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.12);
|
||||
}
|
||||
|
||||
.password-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.password-wrapper .form-input {
|
||||
padding-right: 36px;
|
||||
}
|
||||
|
||||
.toggle-eye {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--mc-text-tertiary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle-eye:hover {
|
||||
color: var(--mc-text-secondary);
|
||||
background: var(--mc-bg-muted);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--mc-border-light);
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--mc-primary);
|
||||
color: white;
|
||||
border-color: var(--mc-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--mc-primary-hover);
|
||||
border-color: var(--mc-primary-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--mc-bg);
|
||||
color: var(--mc-text-primary);
|
||||
border-color: var(--mc-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--mc-bg-muted);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@ -33,6 +33,16 @@ export default {
|
||||
enable: 'Enable',
|
||||
disable: 'Disable',
|
||||
},
|
||||
auth: {
|
||||
changePassword: 'Change Password',
|
||||
oldPassword: 'Current Password',
|
||||
newPassword: 'New Password',
|
||||
confirmPassword: 'Confirm New Password',
|
||||
fieldsRequired: 'Please enter current and new password',
|
||||
passwordMismatch: 'New passwords do not match',
|
||||
passwordChanged: 'Password changed successfully',
|
||||
passwordChangeFailed: 'Failed to change password',
|
||||
},
|
||||
chat: {
|
||||
status: {
|
||||
idle: 'Ready',
|
||||
@ -301,6 +311,9 @@ export default {
|
||||
providerDeleted: 'Provider deleted',
|
||||
modelAdded: 'Model added',
|
||||
modelRemoved: 'Model removed',
|
||||
modelAddFailed: 'Failed to add model',
|
||||
modelRemoveFailed: 'Failed to remove model',
|
||||
activeChangeFailed: 'Failed to change active model',
|
||||
deleteConfirm: 'Delete provider "{name}"?',
|
||||
removeConfirm: 'Remove model "{name}"?',
|
||||
generateConfigInvalidJson: 'Generate kwargs is not valid JSON',
|
||||
@ -715,6 +728,16 @@ export default {
|
||||
title: 'Add Member',
|
||||
userId: 'User ID',
|
||||
userIdPlaceholder: 'Enter user ID',
|
||||
user: 'Select User',
|
||||
userPlaceholder: 'Search username…',
|
||||
username: 'Username',
|
||||
usernamePlaceholder: 'Enter username',
|
||||
usernameHint: 'Account will be created automatically if user does not exist',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Required for new users',
|
||||
passwordHint: 'Leave empty for existing users, required for new users',
|
||||
nickname: 'Nickname',
|
||||
nicknamePlaceholder: 'Optional display name',
|
||||
role: 'Role',
|
||||
},
|
||||
actions: {
|
||||
|
||||
@ -33,6 +33,16 @@ export default {
|
||||
enable: '启用',
|
||||
disable: '停用',
|
||||
},
|
||||
auth: {
|
||||
changePassword: '修改密码',
|
||||
oldPassword: '当前密码',
|
||||
newPassword: '新密码',
|
||||
confirmPassword: '确认新密码',
|
||||
fieldsRequired: '请填写当前密码和新密码',
|
||||
passwordMismatch: '两次输入的新密码不一致',
|
||||
passwordChanged: '密码修改成功',
|
||||
passwordChangeFailed: '密码修改失败',
|
||||
},
|
||||
chat: {
|
||||
status: {
|
||||
idle: '就绪',
|
||||
@ -291,6 +301,9 @@ export default {
|
||||
providerDeleted: 'Provider 已删除',
|
||||
modelAdded: '模型已添加',
|
||||
modelRemoved: '模型已移除',
|
||||
modelAddFailed: '模型添加失败',
|
||||
modelRemoveFailed: '模型删除失败',
|
||||
activeChangeFailed: '激活模型切换失败',
|
||||
deleteConfirm: '确认删除 Provider “{name}”?',
|
||||
removeConfirm: '确认移除模型 “{name}”?',
|
||||
generateConfigInvalidJson: 'Generate Kwargs 不是合法 JSON',
|
||||
@ -715,6 +728,16 @@ export default {
|
||||
title: '添加成员',
|
||||
userId: '用户 ID',
|
||||
userIdPlaceholder: '输入用户 ID',
|
||||
user: '选择用户',
|
||||
userPlaceholder: '搜索用户名…',
|
||||
username: '用户名',
|
||||
usernamePlaceholder: '输入用户名',
|
||||
usernameHint: '如果用户不存在,将自动创建账号',
|
||||
password: '密码',
|
||||
passwordPlaceholder: '新用户需填写密码',
|
||||
passwordHint: '已有用户无需填写,新用户必填',
|
||||
nickname: '昵称',
|
||||
nicknamePlaceholder: '可选,用户显示名称',
|
||||
role: '角色',
|
||||
},
|
||||
actions: {
|
||||
|
||||
@ -221,7 +221,10 @@ router.beforeEach((to, _from, next) => {
|
||||
return
|
||||
}
|
||||
const token = localStorage.getItem('token')
|
||||
if (to.name !== 'Login' && !token) {
|
||||
if (to.name === 'Login' && token) {
|
||||
// Already logged in — skip login page
|
||||
next({ path: '/' })
|
||||
} else if (to.name !== 'Login' && !token) {
|
||||
next({ name: 'Login' })
|
||||
} else {
|
||||
next()
|
||||
|
||||
@ -77,6 +77,7 @@ async function handleLogin() {
|
||||
const res: any = await authApi.login(form)
|
||||
const data = res.data || res
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('userId', String(data.id || '1'))
|
||||
localStorage.setItem('username', data.username || form.username)
|
||||
localStorage.setItem('role', data.role || 'user')
|
||||
router.push('/')
|
||||
|
||||
@ -83,12 +83,22 @@
|
||||
<div class="modal-body">
|
||||
<div class="form-grid" style="grid-template-columns: 1fr;">
|
||||
<div class="form-group">
|
||||
<label>{{ t('security.members.addDialog.userId') }}</label>
|
||||
<input v-model.number="newMemberUserId" type="number" class="form-input" :placeholder="t('security.members.addDialog.userIdPlaceholder')" />
|
||||
<label>{{ t('security.members.addDialog.username') }} <span class="required">*</span></label>
|
||||
<input v-model.trim="newMemberForm.username" type="text" class="form-input" :placeholder="t('security.members.addDialog.usernamePlaceholder')" />
|
||||
<span class="form-hint">{{ t('security.members.addDialog.usernameHint') }}</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('security.members.addDialog.password') }}</label>
|
||||
<input v-model="newMemberForm.password" type="password" class="form-input" :placeholder="t('security.members.addDialog.passwordPlaceholder')" />
|
||||
<span class="form-hint">{{ t('security.members.addDialog.passwordHint') }}</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('security.members.addDialog.nickname') }}</label>
|
||||
<input v-model.trim="newMemberForm.nickname" type="text" class="form-input" :placeholder="t('security.members.addDialog.nicknamePlaceholder')" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('security.members.addDialog.role') }}</label>
|
||||
<select v-model="newMemberRole" class="form-input">
|
||||
<select v-model="newMemberForm.role" class="form-input">
|
||||
<option value="admin">{{ t('security.members.roles.admin') }}</option>
|
||||
<option value="member">{{ t('security.members.roles.member') }}</option>
|
||||
<option value="viewer">{{ t('security.members.roles.viewer') }}</option>
|
||||
@ -98,7 +108,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="showAddDialog = false">{{ t('security.members.actions.cancel') }}</button>
|
||||
<button class="btn-primary" @click="addMember" :disabled="!newMemberUserId">{{ t('security.members.actions.confirm') }}</button>
|
||||
<button class="btn-primary" @click="addMember" :disabled="!newMemberForm.username">{{ t('security.members.actions.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -107,7 +117,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { workspaceTeamApi } from '@/api/index'
|
||||
@ -129,8 +139,9 @@ const store = useWorkspaceStore()
|
||||
const members = ref<Member[]>([])
|
||||
const loading = ref(false)
|
||||
const showAddDialog = ref(false)
|
||||
const newMemberUserId = ref<number | null>(null)
|
||||
const newMemberRole = ref('member')
|
||||
|
||||
const defaultForm = () => ({ username: '', password: '', nickname: '', role: 'member' })
|
||||
const newMemberForm = reactive(defaultForm())
|
||||
|
||||
onMounted(() => {
|
||||
fetchMembers()
|
||||
@ -152,16 +163,20 @@ async function fetchMembers() {
|
||||
|
||||
async function addMember() {
|
||||
const wsId = store.currentWorkspaceId
|
||||
if (!wsId || !newMemberUserId.value) return
|
||||
if (!wsId || !newMemberForm.username) return
|
||||
try {
|
||||
await workspaceTeamApi.addMember(wsId, { userId: newMemberUserId.value, role: newMemberRole.value })
|
||||
await workspaceTeamApi.addMember(wsId, {
|
||||
username: newMemberForm.username,
|
||||
password: newMemberForm.password || undefined,
|
||||
nickname: newMemberForm.nickname || undefined,
|
||||
role: newMemberForm.role,
|
||||
})
|
||||
ElMessage.success(t('security.members.messages.addSuccess'))
|
||||
showAddDialog.value = false
|
||||
newMemberUserId.value = null
|
||||
newMemberRole.value = 'member'
|
||||
Object.assign(newMemberForm, defaultForm())
|
||||
fetchMembers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(t('security.members.messages.addFailed'))
|
||||
ElMessage.error(e?.msg || e?.message || t('security.members.messages.addFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@ -248,4 +263,15 @@ function formatDate(dateStr: string) {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--mc-danger, #e74c3c);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--mc-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -200,18 +200,30 @@ async function onDeleteProvider(provider: ProviderInfo) {
|
||||
}
|
||||
|
||||
async function onAddProviderModel() {
|
||||
await addProviderModel()
|
||||
showSavedTip(t('settings.model.modelAdded'))
|
||||
try {
|
||||
await addProviderModel()
|
||||
showSavedTip(t('settings.model.modelAdded'))
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : t('settings.model.modelAddFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemoveProviderModel(model: ProviderModelInfo) {
|
||||
await removeProviderModel(model)
|
||||
showSavedTip(t('settings.model.modelRemoved'))
|
||||
try {
|
||||
await removeProviderModel(model)
|
||||
showSavedTip(t('settings.model.modelRemoved'))
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : t('settings.model.modelRemoveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function onSetActiveModel(model: ProviderModelInfo) {
|
||||
await setActiveModel(model)
|
||||
showSavedTip(t('settings.model.activeChanged'))
|
||||
try {
|
||||
await setActiveModel(model)
|
||||
showSavedTip(t('settings.model.activeChanged'))
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : t('settings.model.activeChangeFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function onApplyModels() {
|
||||
|
||||
@ -105,6 +105,9 @@
|
||||
<div class="user-name">{{ username }}</div>
|
||||
<div class="user-role">{{ roleLabel }}</div>
|
||||
</div>
|
||||
<button class="change-password-btn" @click="showChangePassword = true" :title="t('auth.changePassword')">
|
||||
<el-icon :size="16"><Lock /></el-icon>
|
||||
</button>
|
||||
<button class="logout-btn" @click="logout" :title="t('nav.logout')">
|
||||
<el-icon :size="16"><SwitchButton /></el-icon>
|
||||
</button>
|
||||
@ -142,6 +145,8 @@
|
||||
|
||||
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
|
||||
<DoctorDrawer :visible="showDoctor" @close="showDoctor = false" @status="onHealthStatus" />
|
||||
|
||||
<ChangePasswordDialog v-model:visible="showChangePassword" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -158,7 +163,8 @@ import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
|
||||
import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
import { applyLocale, currentLocale, type AppLocale } from '@/i18n'
|
||||
import { SwitchButton } from '@element-plus/icons-vue'
|
||||
import { SwitchButton, Lock } from '@element-plus/icons-vue'
|
||||
import ChangePasswordDialog from '@/components/ChangePasswordDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@ -365,6 +371,8 @@ function isNavItemActive(item: { path: string; label: string }) {
|
||||
return route.path === item.path
|
||||
}
|
||||
|
||||
const showChangePassword = ref(false)
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
@ -819,6 +827,7 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
|
||||
.user-role { font-size: 10px; color: var(--mc-text-tertiary); }
|
||||
|
||||
.change-password-btn,
|
||||
.logout-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
@ -834,6 +843,11 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.change-password-btn:hover {
|
||||
background: var(--mc-primary-bg);
|
||||
color: var(--mc-primary);
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: var(--mc-danger-bg);
|
||||
color: var(--mc-danger);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user