feat(auth): password change dialog + workspace member provisioning improvements

This commit is contained in:
matevip 2026-04-17 00:11:10 +08:00
parent c4883e108c
commit 8fbe30d7ac
13 changed files with 492 additions and 26 deletions

View File

@ -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();
}
}

View File

@ -11,6 +11,7 @@ import lombok.Data;
@Data
@AllArgsConstructor
public class LoginResponse {
private Long id;
private String token;
private String username;
private String nickname;

View File

@ -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);
}
/**
* 修改密码
*/

View File

@ -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));
}

View File

@ -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 }),

View 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">&times;</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>

View File

@ -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: {

View File

@ -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: {

View File

@ -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()

View File

@ -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('/')

View File

@ -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>

View File

@ -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() {

View File

@ -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);