feat: My Account — modifier username, nom, email, mot de passe
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped

Backend:
- PUT /api/settings/account (full_name, login, email, password)
- Vérifie que le username n'est pas déjà pris
- Password min 6 caractères

Frontend (Settings → My Account):
- Champs éditables: Username, Full name, Email
- Section Change password avec toggle 👁/🙈
- Bouton Save changes + Update password
- Message de confirmation ✓ / ✗ avec timeout 3s
This commit is contained in:
2026-07-13 13:42:11 -04:00
parent e42c5e1e4b
commit 4ea512d007
2 changed files with 108 additions and 9 deletions
+36
View File
@@ -1361,3 +1361,39 @@ async def search_by_tags(request: Request, tags: str = ""):
"size_display": _format_size(size),
})
return {"items": items}
# ── Account update ──
@router.put("/api/settings/account")
async def update_account(request: Request):
"""Update current user's profile: full_name, login, email, password."""
import json
from app.password_utils import hash_password
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
uid = user["id"]
if "full_name" in body:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid))
if "login" in body:
new_login = body["login"].strip()
if new_login and new_login != user.get("login"):
existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone()
if existing:
return JSONResponse({"error": "Username already taken"}, status_code=409)
conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid))
if "email" in body:
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid))
if "password" in body and body["password"].strip():
pw = body["password"].strip()
if len(pw) < 6:
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid))
conn.commit()
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (uid,)).fetchone()
return {"status": "ok", "user": dict(row)}
+72 -9
View File
@@ -150,17 +150,47 @@
</template>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Full name</div>
<div class="setting-desc" x-text="userInfo.full_name || userInfo.login"></div>
</div>
<!-- Editable fields -->
<div class="setting-group">
<h3>Account details</h3>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">Username</div>
<input type="text" class="settings-input" x-model="accountForm.login" style="max-width:300px;">
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">Full name</div>
<input type="text" class="settings-input" x-model="accountForm.full_name" style="max-width:300px;">
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">Email</div>
<input type="email" class="settings-input" x-model="accountForm.email" style="max-width:300px;">
</div>
<div style="margin-top:12px;">
<button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving" style="width:auto;padding:8px 20px;font-size:13px;">
<span x-show="!accountSaving">💾 Save changes</span>
<span x-show="accountSaving">Saving…</span>
</button>
<span x-show="accountMsg" x-text="accountMsg" style="margin-left:12px;font-size:13px;color:#0F7B6C;"></span>
</div>
</div>
<!-- Password change -->
<div class="setting-group">
<h3>Change password</h3>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:4px;">
<div class="setting-label">New password</div>
<div class="pw-wrapper" style="max-width:300px;">
<input :type="pwVisible ? 'text' : 'password'" class="settings-input" x-model="accountForm.password" placeholder="Leave blank to keep current" style="padding-right:36px;">
<button type="button" class="pw-toggle" @click="pwVisible=!pwVisible" x-text="pwVisible ? '🙈' : '👁'" style="top:50%;"></button>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Email</div>
<div class="setting-desc" x-text="userInfo.email || '—'"></div>
</div>
<div style="margin-top:12px;">
<button class="btn btn-primary" @click="saveAccount()" :disabled="accountSaving || !accountForm.password" style="width:auto;padding:8px 20px;font-size:13px;">
<span x-show="!accountSaving">🔒 Update password</span>
<span x-show="accountSaving">Saving…</span>
</button>
</div>
</div>
</div>
@@ -472,6 +502,11 @@ document.addEventListener('alpine:init', function() {
defaultView: localStorage.getItem('fd_default_view') || 'tree',
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
// Account form
accountForm: {login:'{{ user.login or "" }}', full_name:'{{ user.full_name or "" }}', email:'{{ user.email or "" }}', password:''},
accountSaving: false,
accountMsg: '',
pwVisible: false,
// Admin data
adminUsers: [],
adminStats: {},
@@ -628,6 +663,34 @@ document.addEventListener('alpine:init', function() {
} catch(e) {}
},
async saveAccount() {
this.accountSaving = true;
this.accountMsg = '';
var body = {};
var f = this.accountForm;
if (f.full_name !== this.userInfo.full_name) body.full_name = f.full_name;
if (f.login !== this.userInfo.login) body.login = f.login;
if (f.email !== this.userInfo.email) body.email = f.email;
if (f.password) body.password = f.password;
if (Object.keys(body).length === 0) { this.accountSaving = false; return; }
try {
var r = await fetch('/api/settings/account', {
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify(body)
});
var d = await r.json();
if (r.ok) {
this.userInfo = {login: d.user.login, full_name: d.user.full_name, email: d.user.email};
this.accountForm.password = '';
this.accountMsg = '✓ Saved!';
setTimeout(() => { this.accountMsg = ''; }, 3000);
} else {
this.accountMsg = '✗ ' + (d.error || 'Error');
}
} catch(e) { this.accountMsg = '✗ Network error'; }
this.accountSaving = false;
},
async uploadAvatar(ev) {
var file = ev.target.files[0];
if (!file) return;