88cf6458d0
Application web d'inventaire réseau manuel avec FastAPI, Vue 3 et Docker. Inclut l'authentification JWT, la découverte ICMP, et la topologie en cards CSS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
280 lines
7.6 KiB
Vue
280 lines
7.6 KiB
Vue
<template>
|
|
<div class="modal-overlay" @click.self="forced ? null : $emit('close')">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h2>{{ t('accountSettings') }}</h2>
|
|
<button v-if="!forced" class="close-btn" @click="$emit('close')">✕</button>
|
|
</div>
|
|
|
|
<div class="modal-body">
|
|
<div v-if="forced" class="forced-warning">
|
|
{{ t('mustChangePasswordWarning') }}
|
|
</div>
|
|
|
|
<p class="current-user">{{ t('loginUsername') }} : <strong>{{ currentUsername }}</strong></p>
|
|
|
|
<div class="section-title">{{ t('newUsername') }}</div>
|
|
<div class="field">
|
|
<input
|
|
v-model="newUsername"
|
|
type="text"
|
|
:placeholder="currentUsername"
|
|
autocomplete="username"
|
|
/>
|
|
</div>
|
|
|
|
<div class="section-title">
|
|
{{ t('newPassword') }}
|
|
<span v-if="forced" class="required-star">*</span>
|
|
</div>
|
|
<div class="field">
|
|
<input
|
|
v-model="newPassword"
|
|
type="password"
|
|
:placeholder="forced ? '' : t('leaveBlankToKeep')"
|
|
autocomplete="new-password"
|
|
/>
|
|
</div>
|
|
|
|
<template v-if="newPassword || forced">
|
|
<div class="section-title">{{ t('confirmPassword') }}</div>
|
|
<div class="field">
|
|
<input
|
|
v-model="confirmPassword"
|
|
type="password"
|
|
autocomplete="new-password"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="section-title">{{ t('currentPassword') }} *</div>
|
|
<div class="field">
|
|
<input
|
|
v-model="currentPassword"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
|
<p v-if="successMsg" class="success-msg">{{ successMsg }}</p>
|
|
</div>
|
|
|
|
<div class="modal-footer">
|
|
<button v-if="!forced" class="btn-cancel" @click="$emit('close')">{{ t('cancel') }}</button>
|
|
<button class="btn-save" :disabled="saving" @click="save">
|
|
{{ saving ? '…' : t('save') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref } from 'vue'
|
|
import { authApi } from '../api.js'
|
|
import { t } from '../i18n.js'
|
|
import { currentUsername } from '../auth.js'
|
|
|
|
const props = defineProps({ forced: { type: Boolean, default: false } })
|
|
const emit = defineEmits(['close', 'updated'])
|
|
|
|
const newUsername = ref('')
|
|
const newPassword = ref('')
|
|
const confirmPassword = ref('')
|
|
const currentPassword = ref('')
|
|
const errorMsg = ref('')
|
|
const successMsg = ref('')
|
|
const saving = ref(false)
|
|
|
|
const _USERNAME_RE = /^[a-zA-Z0-9._-]{1,64}$/
|
|
|
|
function _validateClient() {
|
|
if (props.forced && !newPassword.value) {
|
|
errorMsg.value = t('newPasswordRequired')
|
|
return false
|
|
}
|
|
if (newUsername.value && !_USERNAME_RE.test(newUsername.value)) {
|
|
errorMsg.value = t('usernameInvalid')
|
|
return false
|
|
}
|
|
if (newPassword.value) {
|
|
if (newPassword.value.length < 8) {
|
|
errorMsg.value = t('passwordTooShort')
|
|
return false
|
|
}
|
|
if (!/[a-zA-Z]/.test(newPassword.value) || !/[0-9]/.test(newPassword.value)) {
|
|
errorMsg.value = t('passwordTooWeak')
|
|
return false
|
|
}
|
|
if (newPassword.value !== confirmPassword.value) {
|
|
errorMsg.value = t('passwordMismatch')
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function save() {
|
|
errorMsg.value = ''
|
|
successMsg.value = ''
|
|
|
|
if (!currentPassword.value) {
|
|
errorMsg.value = t('currentPassword') + ' ' + t('loginPassword').toLowerCase()
|
|
return
|
|
}
|
|
if (!_validateClient()) return
|
|
if (!newUsername.value && !newPassword.value) return
|
|
|
|
saving.value = true
|
|
try {
|
|
const payload = { current_password: currentPassword.value }
|
|
if (newUsername.value) payload.new_username = newUsername.value
|
|
if (newPassword.value) payload.new_password = newPassword.value
|
|
const { data } = await authApi.updateAccount(payload)
|
|
successMsg.value = t('accountUpdated')
|
|
emit('updated', {
|
|
token: data.access_token,
|
|
username: data.username,
|
|
mustChangePassword: data.must_change_password || false,
|
|
})
|
|
newUsername.value = ''
|
|
newPassword.value = ''
|
|
confirmPassword.value = ''
|
|
currentPassword.value = ''
|
|
} catch (e) {
|
|
const detail = e.response?.data?.detail || ''
|
|
const MAP = {
|
|
'Current password is incorrect': 'wrongPassword',
|
|
'password_too_short': 'passwordTooShort',
|
|
'password_too_weak': 'passwordTooWeak',
|
|
'username_invalid': 'usernameInvalid',
|
|
}
|
|
errorMsg.value = t(MAP[detail] || 'saveError')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.modal-overlay {
|
|
position: fixed; inset: 0;
|
|
background: var(--modal-overlay);
|
|
display: flex; align-items: center; justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal {
|
|
background: var(--bg-card);
|
|
border: 1.5px solid var(--border);
|
|
border-radius: 14px;
|
|
width: 100%;
|
|
max-width: 420px;
|
|
box-shadow: var(--shadow-modal);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 18px 20px 14px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.modal-header h2 { font-size: 16px; font-weight: 700; color: var(--text-primary); }
|
|
|
|
.close-btn {
|
|
background: none; border: none; font-size: 16px;
|
|
color: var(--text-muted); cursor: pointer; line-height: 1;
|
|
}
|
|
.close-btn:hover { color: var(--text-primary); }
|
|
|
|
.modal-body {
|
|
padding: 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.forced-warning {
|
|
font-size: 13px;
|
|
color: #92400E;
|
|
background: #FEF3C7;
|
|
border: 1px solid #FCD34D;
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
:global(html.dark .forced-warning) {
|
|
background: #2D2000;
|
|
border-color: #5C4200;
|
|
color: #FCD34D;
|
|
}
|
|
|
|
.required-star { color: #EF4444; margin-left: 2px; }
|
|
|
|
.current-user {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 8px;
|
|
}
|
|
.current-user strong { color: var(--text-primary); }
|
|
|
|
.section-title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.field input {
|
|
width: 100%;
|
|
padding: 8px 11px;
|
|
border: 1.5px solid var(--border);
|
|
border-radius: 7px;
|
|
font-size: 13px;
|
|
background: var(--bg-input);
|
|
color: var(--text-primary);
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.field input:focus { border-color: #3B82F6; }
|
|
|
|
.error-msg {
|
|
font-size: 12px; color: #EF4444;
|
|
background: #FEF2F2; border: 1px solid #FECACA;
|
|
border-radius: 6px; padding: 7px 10px;
|
|
}
|
|
.success-msg {
|
|
font-size: 12px; color: #15803D;
|
|
background: #F0FDF4; border: 1px solid #BBF7D0;
|
|
border-radius: 6px; padding: 7px 10px;
|
|
}
|
|
|
|
:global(html.dark .error-msg) { background: #2A1515; border-color: #3D1F1F; }
|
|
:global(html.dark .success-msg) { background: #0F2D1A; border-color: #1A4D2A; color: #4ADE80; }
|
|
|
|
.modal-footer {
|
|
display: flex; justify-content: flex-end; gap: 8px;
|
|
padding: 14px 20px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.btn-cancel {
|
|
padding: 7px 16px; background: var(--bg-chip);
|
|
color: var(--text-secondary); border: 1.5px solid var(--border);
|
|
border-radius: 7px; font-size: 13px; cursor: pointer;
|
|
}
|
|
.btn-cancel:hover { background: var(--bg-chip-hover); }
|
|
|
|
.btn-save {
|
|
padding: 7px 18px; background: #3B82F6;
|
|
color: #fff; border: none; border-radius: 7px;
|
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
|
}
|
|
.btn-save:hover:not(:disabled) { background: #2563EB; }
|
|
.btn-save:disabled { opacity: 0.6; cursor: default; }
|
|
</style>
|