mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import vip.mate.auth.model.LoginRequest;
|
import vip.mate.auth.model.LoginRequest;
|
||||||
import vip.mate.auth.model.LoginResponse;
|
import vip.mate.auth.model.LoginResponse;
|
||||||
import vip.mate.auth.model.UserEntity;
|
import vip.mate.auth.model.UserEntity;
|
||||||
import vip.mate.auth.service.AuthService;
|
import vip.mate.auth.service.AuthService;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -48,8 +50,15 @@ public class AuthController {
|
|||||||
public R<Void> changePassword(
|
public R<Void> changePassword(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
@RequestParam String oldPassword,
|
@RequestParam String oldPassword,
|
||||||
@RequestParam String newPassword) {
|
@RequestParam String newPassword,
|
||||||
authService.changePassword(id, oldPassword, 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();
|
return R.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import lombok.Data;
|
|||||||
@Data
|
@Data
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class LoginResponse {
|
public class LoginResponse {
|
||||||
|
private Long id;
|
||||||
private String token;
|
private String token;
|
||||||
private String username;
|
private String username;
|
||||||
private String nickname;
|
private String nickname;
|
||||||
|
|||||||
@ -55,7 +55,7 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String token = generateToken(user);
|
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) {
|
if (count > 0) {
|
||||||
throw new MateClawException("err.auth.username_exists", "用户名已存在: " + user.getUsername());
|
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);
|
user.setEnabled(true);
|
||||||
if (user.getRole() == null) {
|
if (user.getRole() == null) {
|
||||||
user.setRole("user");
|
user.setRole("user");
|
||||||
@ -86,6 +89,22 @@ public class AuthService {
|
|||||||
return user;
|
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) {
|
Authentication auth) {
|
||||||
Long userId = resolveUserId(auth);
|
Long userId = resolveUserId(auth);
|
||||||
workspaceService.requirePermission(id, userId, "admin");
|
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";
|
String role = body.containsKey("role") ? body.get("role").toString() : "member";
|
||||||
return R.ok(workspaceService.addMember(id, targetUserId, role));
|
return R.ok(workspaceService.addMember(id, targetUserId, role));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,6 +68,8 @@ export const authApi = {
|
|||||||
http.post('/auth/login', data),
|
http.post('/auth/login', data),
|
||||||
listUsers: () => http.get('/auth/users'),
|
listUsers: () => http.get('/auth/users'),
|
||||||
createUser: (data: any) => http.post('/auth/users', data),
|
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 ====================
|
// ==================== Agent ====================
|
||||||
@ -415,7 +417,7 @@ export const workspaceTeamApi = {
|
|||||||
update: (id: string | number, data: any) => http.put(`/workspaces/${id}`, data),
|
update: (id: string | number, data: any) => http.put(`/workspaces/${id}`, data),
|
||||||
delete: (id: string | number) => http.delete(`/workspaces/${id}`),
|
delete: (id: string | number) => http.delete(`/workspaces/${id}`),
|
||||||
listMembers: (id: string | number) => http.get(`/workspaces/${id}/members`),
|
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),
|
http.post(`/workspaces/${id}/members`, data),
|
||||||
updateMemberRole: (id: string | number, memberId: string | number, role: string) =>
|
updateMemberRole: (id: string | number, memberId: string | number, role: string) =>
|
||||||
http.put(`/workspaces/${id}/members/${memberId}`, { role }),
|
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',
|
enable: 'Enable',
|
||||||
disable: 'Disable',
|
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: {
|
chat: {
|
||||||
status: {
|
status: {
|
||||||
idle: 'Ready',
|
idle: 'Ready',
|
||||||
@ -301,6 +311,9 @@ export default {
|
|||||||
providerDeleted: 'Provider deleted',
|
providerDeleted: 'Provider deleted',
|
||||||
modelAdded: 'Model added',
|
modelAdded: 'Model added',
|
||||||
modelRemoved: 'Model removed',
|
modelRemoved: 'Model removed',
|
||||||
|
modelAddFailed: 'Failed to add model',
|
||||||
|
modelRemoveFailed: 'Failed to remove model',
|
||||||
|
activeChangeFailed: 'Failed to change active model',
|
||||||
deleteConfirm: 'Delete provider "{name}"?',
|
deleteConfirm: 'Delete provider "{name}"?',
|
||||||
removeConfirm: 'Remove model "{name}"?',
|
removeConfirm: 'Remove model "{name}"?',
|
||||||
generateConfigInvalidJson: 'Generate kwargs is not valid JSON',
|
generateConfigInvalidJson: 'Generate kwargs is not valid JSON',
|
||||||
@ -715,6 +728,16 @@ export default {
|
|||||||
title: 'Add Member',
|
title: 'Add Member',
|
||||||
userId: 'User ID',
|
userId: 'User ID',
|
||||||
userIdPlaceholder: 'Enter 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',
|
role: 'Role',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@ -33,6 +33,16 @@ export default {
|
|||||||
enable: '启用',
|
enable: '启用',
|
||||||
disable: '停用',
|
disable: '停用',
|
||||||
},
|
},
|
||||||
|
auth: {
|
||||||
|
changePassword: '修改密码',
|
||||||
|
oldPassword: '当前密码',
|
||||||
|
newPassword: '新密码',
|
||||||
|
confirmPassword: '确认新密码',
|
||||||
|
fieldsRequired: '请填写当前密码和新密码',
|
||||||
|
passwordMismatch: '两次输入的新密码不一致',
|
||||||
|
passwordChanged: '密码修改成功',
|
||||||
|
passwordChangeFailed: '密码修改失败',
|
||||||
|
},
|
||||||
chat: {
|
chat: {
|
||||||
status: {
|
status: {
|
||||||
idle: '就绪',
|
idle: '就绪',
|
||||||
@ -291,6 +301,9 @@ export default {
|
|||||||
providerDeleted: 'Provider 已删除',
|
providerDeleted: 'Provider 已删除',
|
||||||
modelAdded: '模型已添加',
|
modelAdded: '模型已添加',
|
||||||
modelRemoved: '模型已移除',
|
modelRemoved: '模型已移除',
|
||||||
|
modelAddFailed: '模型添加失败',
|
||||||
|
modelRemoveFailed: '模型删除失败',
|
||||||
|
activeChangeFailed: '激活模型切换失败',
|
||||||
deleteConfirm: '确认删除 Provider “{name}”?',
|
deleteConfirm: '确认删除 Provider “{name}”?',
|
||||||
removeConfirm: '确认移除模型 “{name}”?',
|
removeConfirm: '确认移除模型 “{name}”?',
|
||||||
generateConfigInvalidJson: 'Generate Kwargs 不是合法 JSON',
|
generateConfigInvalidJson: 'Generate Kwargs 不是合法 JSON',
|
||||||
@ -715,6 +728,16 @@ export default {
|
|||||||
title: '添加成员',
|
title: '添加成员',
|
||||||
userId: '用户 ID',
|
userId: '用户 ID',
|
||||||
userIdPlaceholder: '输入用户 ID',
|
userIdPlaceholder: '输入用户 ID',
|
||||||
|
user: '选择用户',
|
||||||
|
userPlaceholder: '搜索用户名…',
|
||||||
|
username: '用户名',
|
||||||
|
usernamePlaceholder: '输入用户名',
|
||||||
|
usernameHint: '如果用户不存在,将自动创建账号',
|
||||||
|
password: '密码',
|
||||||
|
passwordPlaceholder: '新用户需填写密码',
|
||||||
|
passwordHint: '已有用户无需填写,新用户必填',
|
||||||
|
nickname: '昵称',
|
||||||
|
nicknamePlaceholder: '可选,用户显示名称',
|
||||||
role: '角色',
|
role: '角色',
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@ -221,7 +221,10 @@ router.beforeEach((to, _from, next) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const token = localStorage.getItem('token')
|
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' })
|
next({ name: 'Login' })
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
|
|||||||
@ -77,6 +77,7 @@ async function handleLogin() {
|
|||||||
const res: any = await authApi.login(form)
|
const res: any = await authApi.login(form)
|
||||||
const data = res.data || res
|
const data = res.data || res
|
||||||
localStorage.setItem('token', data.token)
|
localStorage.setItem('token', data.token)
|
||||||
|
localStorage.setItem('userId', String(data.id || '1'))
|
||||||
localStorage.setItem('username', data.username || form.username)
|
localStorage.setItem('username', data.username || form.username)
|
||||||
localStorage.setItem('role', data.role || 'user')
|
localStorage.setItem('role', data.role || 'user')
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
|||||||
@ -83,12 +83,22 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-grid" style="grid-template-columns: 1fr;">
|
<div class="form-grid" style="grid-template-columns: 1fr;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('security.members.addDialog.userId') }}</label>
|
<label>{{ t('security.members.addDialog.username') }} <span class="required">*</span></label>
|
||||||
<input v-model.number="newMemberUserId" type="number" class="form-input" :placeholder="t('security.members.addDialog.userIdPlaceholder')" />
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{ t('security.members.addDialog.role') }}</label>
|
<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="admin">{{ t('security.members.roles.admin') }}</option>
|
||||||
<option value="member">{{ t('security.members.roles.member') }}</option>
|
<option value="member">{{ t('security.members.roles.member') }}</option>
|
||||||
<option value="viewer">{{ t('security.members.roles.viewer') }}</option>
|
<option value="viewer">{{ t('security.members.roles.viewer') }}</option>
|
||||||
@ -98,7 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn-secondary" @click="showAddDialog = false">{{ t('security.members.actions.cancel') }}</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -107,7 +117,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { workspaceTeamApi } from '@/api/index'
|
import { workspaceTeamApi } from '@/api/index'
|
||||||
@ -129,8 +139,9 @@ const store = useWorkspaceStore()
|
|||||||
const members = ref<Member[]>([])
|
const members = ref<Member[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showAddDialog = 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(() => {
|
onMounted(() => {
|
||||||
fetchMembers()
|
fetchMembers()
|
||||||
@ -152,16 +163,20 @@ async function fetchMembers() {
|
|||||||
|
|
||||||
async function addMember() {
|
async function addMember() {
|
||||||
const wsId = store.currentWorkspaceId
|
const wsId = store.currentWorkspaceId
|
||||||
if (!wsId || !newMemberUserId.value) return
|
if (!wsId || !newMemberForm.username) return
|
||||||
try {
|
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'))
|
ElMessage.success(t('security.members.messages.addSuccess'))
|
||||||
showAddDialog.value = false
|
showAddDialog.value = false
|
||||||
newMemberUserId.value = null
|
Object.assign(newMemberForm, defaultForm())
|
||||||
newMemberRole.value = 'member'
|
|
||||||
fetchMembers()
|
fetchMembers()
|
||||||
} catch (e: any) {
|
} 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;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--mc-danger, #e74c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -200,18 +200,30 @@ async function onDeleteProvider(provider: ProviderInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onAddProviderModel() {
|
async function onAddProviderModel() {
|
||||||
await addProviderModel()
|
try {
|
||||||
showSavedTip(t('settings.model.modelAdded'))
|
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) {
|
async function onRemoveProviderModel(model: ProviderModelInfo) {
|
||||||
await removeProviderModel(model)
|
try {
|
||||||
showSavedTip(t('settings.model.modelRemoved'))
|
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) {
|
async function onSetActiveModel(model: ProviderModelInfo) {
|
||||||
await setActiveModel(model)
|
try {
|
||||||
showSavedTip(t('settings.model.activeChanged'))
|
await setActiveModel(model)
|
||||||
|
showSavedTip(t('settings.model.activeChanged'))
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : t('settings.model.activeChangeFailed'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onApplyModels() {
|
async function onApplyModels() {
|
||||||
|
|||||||
@ -105,6 +105,9 @@
|
|||||||
<div class="user-name">{{ username }}</div>
|
<div class="user-name">{{ username }}</div>
|
||||||
<div class="user-role">{{ roleLabel }}</div>
|
<div class="user-role">{{ roleLabel }}</div>
|
||||||
</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')">
|
<button class="logout-btn" @click="logout" :title="t('nav.logout')">
|
||||||
<el-icon :size="16"><SwitchButton /></el-icon>
|
<el-icon :size="16"><SwitchButton /></el-icon>
|
||||||
</button>
|
</button>
|
||||||
@ -142,6 +145,8 @@
|
|||||||
|
|
||||||
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
|
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
|
||||||
<DoctorDrawer :visible="showDoctor" @close="showDoctor = false" @status="onHealthStatus" />
|
<DoctorDrawer :visible="showDoctor" @close="showDoctor = false" @status="onHealthStatus" />
|
||||||
|
|
||||||
|
<ChangePasswordDialog v-model:visible="showChangePassword" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -158,7 +163,8 @@ import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
|
|||||||
import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue'
|
import WorkspaceSwitcher from '@/components/workspace/WorkspaceSwitcher.vue'
|
||||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||||
import { applyLocale, currentLocale, type AppLocale } from '@/i18n'
|
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 router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -365,6 +371,8 @@ function isNavItemActive(item: { path: string; label: string }) {
|
|||||||
return route.path === item.path
|
return route.path === item.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showChangePassword = ref(false)
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('username')
|
localStorage.removeItem('username')
|
||||||
@ -819,6 +827,7 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
|||||||
|
|
||||||
.user-role { font-size: 10px; color: var(--mc-text-tertiary); }
|
.user-role { font-size: 10px; color: var(--mc-text-tertiary); }
|
||||||
|
|
||||||
|
.change-password-btn,
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
@ -834,6 +843,11 @@ watch(() => workspaceStore.currentWorkspaceId, () => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.change-password-btn:hover {
|
||||||
|
background: var(--mc-primary-bg);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.logout-btn:hover {
|
.logout-btn:hover {
|
||||||
background: var(--mc-danger-bg);
|
background: var(--mc-danger-bg);
|
||||||
color: var(--mc-danger);
|
color: var(--mc-danger);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user