diff --git a/backend/app/api/admin_routes.py b/backend/app/api/admin_routes.py index 05f9992..570843f 100644 --- a/backend/app/api/admin_routes.py +++ b/backend/app/api/admin_routes.py @@ -23,13 +23,13 @@ def create_user(): """Create a user + provision a password (invite). Admin or super-admin only.""" data = request.get_json(silent=True) or {} name = (data.get("name") or "").strip() - email = (data.get("email") or "").strip().lower() + username = (data.get("username") or data.get("email") or "").strip().lower() password = data.get("password") or "" role = (data.get("role") or "user").strip() org_id = (data.get("org_id") or current_user().get("org_id") or "org-default").strip() - if not email or not password: - raise ApiError("email and password are required") + if not username or not password: + raise ApiError("username and password are required") if role not in Config.ROLES: raise ApiError(f"invalid role: {role}") # Only super_admin can create another admin/super_admin @@ -38,7 +38,7 @@ def create_user(): raise ApiError("only super_admin can grant admin roles", 403) try: user = _store().create_user( - org_id=org_id, email=email, password=password, name=name, role=role + org_id=org_id, username=username, password=password, name=name, role=role ) except AuthError as exc: raise ApiError(str(exc)) @@ -57,14 +57,14 @@ def list_users(): return jsonify({"users": users}) -@admin_bp.put("/users/") +@admin_bp.put("/users/") @require_auth @require_roles("admin") -def update_user(email: str): +def update_user(username: str): data = request.get_json(silent=True) or {} - email = email.strip().lower() + username = username.strip().lower() actor = current_user() - target = _store().get_user_or_none(email) + target = _store().get_user_or_none(username) if not target: raise ApiError("user not found", 404) @@ -75,14 +75,20 @@ def update_user(email: str): raise ApiError(f"invalid role: {role}") if actor.get("role") != "super_admin": raise ApiError("only super_admin can change roles") - _store().set_role(email, role) + _store().set_role(username, role) if "active" in data: if actor.get("role") != "super_admin": raise ApiError("only super_admin can activate/deactivate users") - _store().set_active(email, bool(data.get("active"))) + _store().set_active(username, bool(data.get("active"))) if "password" in data and data.get("password"): - _store().set_password(email, data.get("password")) + _store().set_password(username, data.get("password")) - return jsonify({"user": _store().public_user(_store().get_user(email))}) + if "email" in data: + try: + _store().set_email(username, data.get("email")) + except AuthError as exc: + raise ApiError(str(exc)) + + return jsonify({"user": _store().public_user(_store().get_user(username))}) diff --git a/backend/app/api/auth_routes.py b/backend/app/api/auth_routes.py index 03bda76..23a1431 100644 --- a/backend/app/api/auth_routes.py +++ b/backend/app/api/auth_routes.py @@ -1,4 +1,4 @@ -"""Auth routes: login + current user. No self-registration.""" +"""Auth routes: login, current user, first-time admin setup. No self-registration.""" from __future__ import annotations from flask import Blueprint, jsonify, request @@ -15,22 +15,53 @@ def _store(): return current_app.extensions["user_store"] +def _login_body(data: dict) -> str: + # Accept `username` (primary) or `email` (fallback), lower-cased. + return (data.get("username") or data.get("email") or "").strip().lower() + + @auth_bp.post("/login") def login(): data = request.get_json(silent=True) or {} - email = (data.get("email") or "").strip().lower() + username = _login_body(data) password = data.get("password") or "" - if not email or not password: - raise ApiError("email and password are required") + if not username or not password: + raise ApiError("username and password are required") try: - user = _store().verify(email, password) + user = _store().verify(username, password) token = _store().issue_token(user) except AuthError as exc: raise ApiError(str(exc), 401) - return jsonify({"token": token, "user": _store().public_user(user)}) + return jsonify({ + "token": token, + "user": _store().public_user(user), + "must_setup": bool(user.get("must_setup")), + }) @auth_bp.get("/me") @require_auth def me(): return jsonify({"user": _store().public_user(current_user())}) + + +@auth_bp.post("/setup") +@require_auth +def setup(): + """First-time admin setup: set email + change password, then clear must_setup.""" + user = current_user() + data = request.get_json(silent=True) or {} + username = (data.get("username") or user.get("username") or user.get("id") or "").strip().lower() + email = (data.get("email") or "").strip() + new_password = data.get("password") or "" + if not email or not new_password: + raise ApiError("email and new password are required") + try: + updated = _store().complete_setup(username, email, new_password) + except AuthError as exc: + raise ApiError(str(exc), 400) + return jsonify({ + "ok": True, + "user": _store().public_user(updated), + "must_setup": False, + }) diff --git a/backend/app/auth/users.py b/backend/app/auth/users.py index 06b0cf5..9795836 100644 --- a/backend/app/auth/users.py +++ b/backend/app/auth/users.py @@ -1,7 +1,13 @@ -"""User + organization store and auth logic (JWT, password hashing, roles).""" +"""User + organization store and auth logic (JWT, password hashing, roles). + +Login identity is the user's `username` (stable id). `email` is an optional +separate field that admins/users can set; the default admin must set an email ++before first real use (enforced via `must_setup`). +""" from __future__ import annotations import datetime +import re from pathlib import Path from typing import Any @@ -11,6 +17,8 @@ from werkzeug.security import check_password_hash, generate_password_hash from ..config import Config from ..storage.store import JsonStore, StoreError, new_id +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + class AuthError(Exception): pass @@ -32,71 +40,114 @@ class UserStore: return self.orgs.get(org_id) # ── users ────────────────────────────────────────────────────────── + @staticmethod + def _norm(username: str) -> str: + return username.strip().lower() + def create_user( self, *, org_id: str, - email: str, + username: str, password: str, name: str, role: str = "user", + email: str | None = None, + must_setup: bool = False, ) -> dict[str, Any]: if role not in Config.ROLES: raise AuthError(f"invalid role: {role}") - org = self.orgs.get(org_id) - email = email.strip().lower() - if not email or not password: - raise AuthError("email and password are required") - if self.users.get_or_none(email) is not None: - raise AuthError("a user with this email already exists") + self.orgs.get(org_id) + username = self._norm(username) + if not username or not password: + raise AuthError("username and password are required") + if not re.fullmatch(r"[a-zA-Z0-9_.-]{2,64}", username): + raise AuthError("invalid username (letters/numbers/._- only, 2-64 chars)") + if self.users.get_or_none(username) is not None: + raise AuthError("a user with this username already exists") + email = (email or "").strip().lower() or None + if email: + if not EMAIL_RE.fullmatch(email): + raise AuthError("invalid email") + if self.email_exists(email): + raise AuthError("a user with this email already exists") user = { - "id": email, # email = unique id/username + "id": username, + "username": username, "email": email, "org_id": org_id, - "org_name": org.get("name", ""), - "name": name.strip() or email, + "org_name": self.orgs.get(org_id).get("name", ""), + "name": name.strip() or username, "password_hash": generate_password_hash(password), "role": role, + "must_setup": must_setup, "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "active": True, } - return self.users.create(user, key=email) + return self.users.create(user, key=username) - def get_user(self, email: str) -> dict[str, Any]: - email = email.strip().lower() - return self.users.get(email) + def get_user(self, username: str) -> dict[str, Any]: + return self.users.get(self._norm(username)) - def get_user_or_none(self, email: str) -> dict[str, Any] | None: - return self.users.get_or_none(email.strip().lower()) + def get_user_or_none(self, username: str) -> dict[str, Any] | None: + return self.users.get_or_none(self._norm(username)) + + def by_email(self, email: str) -> dict[str, Any] | None: + email = (email or "").strip().lower() + if not email: + return None + for u in self.users.all(): + if u.get("email") and u["email"] == email: + return u + return None + + def email_exists(self, email: str) -> bool: + return self.by_email(email) is not None def list_users(self, *, org_id: str | None = None) -> list[dict[str, Any]]: users = self.users.all() if org_id: users = [u for u in users if u.get("org_id") == org_id] - # Redact password hash for u in users: u.pop("password_hash", None) return users - def set_active(self, email: str, active: bool) -> dict[str, Any]: - return self.users.update(email.strip().lower(), active=active) + def set_active(self, username: str, active: bool) -> dict[str, Any]: + return self.users.update(self._norm(username), active=active) - def set_role(self, email: str, role: str) -> dict[str, Any]: + def set_role(self, username: str, role: str) -> dict[str, Any]: if role not in Config.ROLES: raise AuthError(f"invalid role: {role}") - return self.users.update(email.strip().lower(), role=role) + return self.users.update(self._norm(username), role=role) - def set_password(self, email: str, new_password: str) -> dict[str, Any]: + def set_password(self, username: str, new_password: str) -> dict[str, Any]: if not new_password: raise AuthError("password is required") return self.users.update( - email.strip().lower(), + self._norm(username), password_hash=generate_password_hash(new_password), ) + def set_email(self, username: str, email: str) -> dict[str, Any]: + email = (email or "").strip().lower() + if not EMAIL_RE.fullmatch(email): + raise AuthError("invalid email") + existing = self.by_email(email) + if existing and existing["id"] != self._norm(username): + raise AuthError("a user with this email already exists") + return self.users.update(self._norm(username), email=email) + + def complete_setup(self, username: str, email: str, new_password: str) -> dict[str, Any]: + """First-time admin setup: set email + password, clear must_setup.""" + if not new_password or len(new_password) < 4: + raise AuthError("password must be at least 4 characters") + self.set_email(username, email) + self.set_password(username, new_password) + return self.users.update(self._norm(username), must_setup=False) + # ── auth ─────────────────────────────────────────────────────────── - def verify(self, email: str, password: str) -> dict[str, Any]: - user = self.get_user_or_none(email) + def verify(self, username: str, password: str) -> dict[str, Any]: + user = self.get_user_or_none(username) if not user or not user.get("active", True): raise AuthError("invalid credentials") if not check_password_hash(user["password_hash"], password): @@ -106,7 +157,7 @@ class UserStore: def issue_token(self, user: dict[str, Any]) -> str: now = datetime.datetime.now(datetime.timezone.utc) payload = { - "sub": user["email"], + "sub": user.get("username") or user.get("id"), "org_id": user["org_id"], "role": user["role"], "iat": now, @@ -116,9 +167,7 @@ class UserStore: def decode_token(self, token: str) -> dict[str, Any]: try: - return jwt.decode( - token, Config.SECRET_KEY, algorithms=[Config.JWT_ALGO] - ) + return jwt.decode(token, Config.SECRET_KEY, algorithms=[Config.JWT_ALGO]) except jwt.PyJWTError as exc: raise AuthError("invalid or expired token") from exc diff --git a/backend/app/factory.py b/backend/app/factory.py index bb6ec4b..620707e 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -11,20 +11,24 @@ from .config import Config def bootstrap_admin(users: UserStore) -> None: - """Ensure a default org + super-admin exists on first run (no self-registration).""" - email = "admin@salestrainer.local" + """Ensure a default org + super-admin exists on first run (no self-registration). + + Default admin logs in with username `admin` / `1234`, then MUST set an email + and change the password on first login (`must_setup=True`). + """ org = users.orgs.get_or_none("org-default") if org is None: org = users.create_org("Default Organization", org_id="org-default") - if users.get_user_or_none(email) is None: + if users.get_user_or_none("admin") is None: users.create_user( org_id=org["id"], - email=email, - password="admin123", + username="admin", + password="1234", name="Super Admin", role="super_admin", + must_setup=True, ) - print("[bootstrap] created default super-admin:", email, "/ admin123") + print("[bootstrap] created default super-admin: admin / 1234 (must set email + password)") def create_app() -> Flask: diff --git a/backend/scripts/test_e2e.py b/backend/scripts/test_e2e.py index c86f3ff..10d4c13 100644 --- a/backend/scripts/test_e2e.py +++ b/backend/scripts/test_e2e.py @@ -28,7 +28,7 @@ def main(): client = app.test_client() # admin login - r = client.post("/api/auth/login", json={"email": "admin@salestrainer.local", "password": "admin123"}) + r = client.post("/api/auth/login", json={"username": "admin", "password": "1234"}) AT = r.get_json()["token"] AH = {"Authorization": f"Bearer {AT}"} @@ -57,8 +57,8 @@ def main(): # create a trainee client.post("/api/admin/users", json={ - "name": "Trainee", "email": "t@x.com", "password": "pass123", "role": "user"}, headers=AH) - r = client.post("/api/auth/login", json={"email": "t@x.com", "password": "pass123"}) + "name": "Trainee", "username": "trainee9", "password": "pass123", "role": "user"}, headers=AH) + r = client.post("/api/auth/login", json={"username": "trainee9", "password": "pass123"}) UT = r.get_json()["token"] UH = {"Authorization": f"Bearer {UT}"} diff --git a/backend/scripts/test_m0.py b/backend/scripts/test_m0.py index 91ee4a2..c4612e9 100644 --- a/backend/scripts/test_m0.py +++ b/backend/scripts/test_m0.py @@ -36,10 +36,10 @@ def main() -> None: assert r.status_code == 404, f"register should not exist, got {r.status_code}" print("[ok] no self-registration (register -> 404)") - # 3. Bootstrap super-admin login + # 3. Bootstrap super-admin login (username admin / 1234) r = client.post( "/api/auth/login", - json={"email": "admin@salestrainer.local", "password": "admin123"}, + json={"username": "admin", "password": "1234"}, ) assert r.status_code == 200, r.get_json() admin_token = r.get_json()["token"] @@ -54,14 +54,14 @@ def main() -> None: # 5. Create a regular user (admin) r = client.post( "/api/admin/users", - json={"name": "Trainee One", "email": "t1@x.com", "password": "pass123", "role": "user"}, + json={"name": "Trainee One", "username": "trainee1", "password": "pass123", "role": "user"}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert r.status_code == 201, r.get_json() print("[ok] admin creates user") # 6. Trainee login + cannot access admin users list (403) - r = client.post("/api/auth/login", json={"email": "t1@x.com", "password": "pass123"}) + r = client.post("/api/auth/login", json={"username": "trainee1", "password": "pass123"}) user_token = r.get_json()["token"] r = client.get("/api/admin/users", headers={"Authorization": f"Bearer {user_token}"}) assert r.status_code == 403, f"trainee should be denied, got {r.status_code}" @@ -76,29 +76,29 @@ def main() -> None: # create an 'admin' actor first client.post( "/api/admin/users", - json={"name": "Admin Two", "email": "a2@x.com", "password": "pass123", "role": "admin"}, + json={"name": "Admin Two", "username": "admin2", "password": "pass123", "role": "admin"}, headers={"Authorization": f"Bearer {admin_token}"}, ) r = client.post( - "/api/auth/login", json={"email": "a2@x.com", "password": "pass123"} + "/api/auth/login", json={"username": "admin2", "password": "pass123"} ) admin2_token = r.get_json()["token"] r = client.post( "/api/admin/users", - json={"name": "Bogus Admin", "email": "ba@x.com", "password": "pass123", "role": "super_admin"}, + json={"name": "Bogus Admin", "username": "bogus", "password": "pass123", "role": "super_admin"}, headers={"Authorization": f"Bearer {admin2_token}"}, ) assert r.status_code == 403, f"admin should not promote, got {r.status_code}" print("[ok] admin cannot grant super_admin (403)") - # 9. Duplicate email rejected + # 9. Duplicate username rejected r = client.post( "/api/admin/users", - json={"name": "Dup", "email": "t1@x.com", "password": "pass123", "role": "user"}, + json={"name": "Dup", "username": "trainee1", "password": "pass123", "role": "user"}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert r.status_code == 400 - print("[ok] duplicate email rejected (400)") + print("[ok] duplicate username rejected (400)") print("\nALL M0 TESTS PASSED") diff --git a/backend/scripts/test_m1.py b/backend/scripts/test_m1.py index 892251d..1bb60ba 100644 --- a/backend/scripts/test_m1.py +++ b/backend/scripts/test_m1.py @@ -29,7 +29,7 @@ def main(): # login as super-admin r = client.post("/api/auth/login", json={ - "email": "admin@salestrainer.local", "password": "admin123"}) + "username": "admin", "password": "1234"}) assert r.status_code == 200, r.get_json() token = r.get_json()["token"] H = {"Authorization": f"Bearer {token}"} @@ -64,8 +64,8 @@ def main(): # trainee created; can list groups but only 'ready' ones (this one is 'failed' -> hidden) client.post("/api/admin/users", json={ - "name": "Trainee", "email": "t@x.com", "password": "pass123", "role": "user"}, headers=H) - r = client.post("/api/auth/login", json={"email": "t@x.com", "password": "pass123"}) + "name": "Trainee", "username": "trainee1", "password": "pass123", "role": "user"}, headers=H) + r = client.post("/api/auth/login", json={"username": "trainee1", "password": "pass123"}) ut = r.get_json()["token"] UH = {"Authorization": f"Bearer {ut}"} r = client.get("/api/groups", headers=UH) diff --git a/backend/scripts/test_routes.py b/backend/scripts/test_routes.py index aca6e5d..0c66f43 100644 --- a/backend/scripts/test_routes.py +++ b/backend/scripts/test_routes.py @@ -24,8 +24,8 @@ def main(): app = create_app() rules = sorted({str(rule) for rule in app.url_map.iter_rules() if str(rule).startswith("/api")}) expected = [ - "/api/auth/login", "/api/auth/me", - "/api/admin/users", "/api/admin/users/", + "/api/auth/login", "/api/auth/me", "/api/auth/setup", + "/api/admin/users", "/api/admin/users/", "/api/groups", "/api/groups/", "/api/groups//analyze", "/api/groups//personas", "/api/groups//personas/", "/api/groups//personas/", diff --git a/backend/scripts/test_security.py b/backend/scripts/test_security.py index aa2dcaf..e797e9a 100644 --- a/backend/scripts/test_security.py +++ b/backend/scripts/test_security.py @@ -26,8 +26,7 @@ def main(): client = app.test_client() # admin login (org-default) - client.post("/api/auth/login", json={"email": "admin@salestrainer.local", "password": "admin123"}) - r = client.post("/api/auth/login", json={"email": "admin@salestrainer.local", "password": "admin123"}) + r = client.post("/api/auth/login", json={"username": "admin", "password": "1234"}) AT = r.get_json()["token"] AH = {"Authorization": f"Bearer {AT}"} @@ -56,7 +55,7 @@ def main(): # Cross-org IDOR: create org B + a group in org-default; org B user must be denied. client.post("/api/admin/users", json={ - "name": "Other Admin", "email": "b-admin@x.com", "password": "pass123", "role": "admin"}, + "name": "Other Admin", "username": "badmin", "password": "pass123", "role": "admin"}, headers=AH) # Create a group as A (current default org) r = client.post("/api/groups", json={"product": "A product"}, headers=AH) @@ -68,8 +67,8 @@ def main(): # and admin cannot read a PERSONAL group belonging to a different user. # Create a trainee, give them a personal group via /me/personas/generate (mock) -> owner_user_id set. client.post("/api/admin/users", json={ - "name": "Trainee T", "email": "t2@x.com", "password": "pass123", "role": "user"}, headers=AH) - r = client.post("/api/auth/login", json={"email": "t2@x.com", "password": "pass123"}) + "name": "Trainee T", "username": "trainee2", "password": "pass123", "role": "user"}, headers=AH) + r = client.post("/api/auth/login", json={"username": "trainee2", "password": "pass123"}) TT = r.get_json()["token"] TH = {"Authorization": f"Bearer {TT}"} # trainee creates own persona -> personal group owned by t2 @@ -81,8 +80,8 @@ def main(): # The admin (different actor) must be able to access it (super_admin not needed; admin same org). # For a strict IDOR test, a DIFFERENT trainee must be denied. Create t3. client.post("/api/admin/users", json={ - "name": "Trainee T3", "email": "t3@x.com", "password": "pass123", "role": "user"}, headers=AH) - r = client.post("/api/auth/login", json={"email": "t3@x.com", "password": "pass123"}) + "name": "Trainee T3", "username": "trainee3", "password": "pass123", "role": "user"}, headers=AH) + r = client.post("/api/auth/login", json={"username": "trainee3", "password": "pass123"}) T3T = r.get_json()["token"] T3H = {"Authorization": f"Bearer {T3T}"} # t3 tries to read t2's personal group personas -> must be denied (owner check) diff --git a/backend/scripts/test_setup.py b/backend/scripts/test_setup.py new file mode 100644 index 0000000..1f0dece --- /dev/null +++ b/backend/scripts/test_setup.py @@ -0,0 +1,67 @@ +"""Test the first-time admin setup flow: admin/1234 -> must_setup -> set email+password.""" +import os +import sys +import tempfile +import warnings +from pathlib import Path + +warnings.filterwarnings("ignore", message="The HMAC key is") +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + +tempdir = tempfile.mkdtemp(prefix="st_setup_") +os.environ["DATA_DIR"] = tempdir +os.environ["JWT_SECRET"] = "test-secret-key-0123456789abcdef" + +from app.factory import create_app # noqa: E402 +from app.config import Config # noqa: E402 + +Config.DATA_DIR = Path(tempdir) +Config.LLM_API_KEY = "" +Config.LLM_BASE_URL = "" + + +def main(): + app = create_app() + client = app.test_client() + + # 1. Default admin logs in with admin / 1234 and must_setup is true + r = client.post("/api/auth/login", json={"username": "admin", "password": "1234"}) + assert r.status_code == 200, r.get_json() + assert r.get_json()["must_setup"] is True, "default admin should require setup" + token = r.get_json()["token"] + H = {"Authorization": f"Bearer {token}"} + me = client.get("/api/auth/me", headers=H).get_json()["user"] + assert me.get("must_setup") is True + print("[ok] default admin login admin/1234 -> must_setup=true") + + # 2. Cannot set up with short password / bad email + r = client.post("/api/auth/setup", json={"username": "admin", "email": "bad", "password": "12"}, headers=H) + assert r.status_code == 400, r.get_json() + print("[ok] setup rejects bad email/short password") + + # 3. Successful setup: email + new password, clears must_setup + r = client.post("/api/auth/setup", json={"username": "admin", "email": "admin@corp.com", "password": "NewPass!42"}, headers=H) + assert r.status_code == 200, r.get_json() + assert r.get_json()["must_setup"] is False + print("[ok] setup completes -> must_setup=false") + + # 4. Old password no longer works; new one does + r = client.post("/api/auth/login", json={"username": "admin", "password": "1234"}) + assert r.status_code == 401, "old default password should be invalid" + r = client.post("/api/auth/login", json={"username": "admin", "password": "NewPass!42"}) + assert r.status_code == 200 + new_token = r.get_json()["token"] + assert r.get_json()["must_setup"] is False + print("[ok] old password rejected; new password logs in") + + # 5. Admin can now use the app (create user, etc.) + NH = {"Authorization": f"Bearer {new_token}"} + r = client.post("/api/admin/users", json={"name": "T1", "username": "t1", "password": "pass123", "role": "user"}, headers=NH) + assert r.status_code == 201, r.get_json() + print("[ok] admin can use the app after setup") + + print("\nALL SETUP TESTS PASSED") + + +if __name__ == "__main__": + main() diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 37b75a0..6de0928 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -33,11 +33,12 @@ async function request(method, url, body, isForm = false) { } export const api = { - login: (email, password) => request('POST', '/api/auth/login', { email, password }), + login: (username, password) => request('POST', '/api/auth/login', { username, password }), me: () => request('GET', '/api/auth/me'), + setup: (b) => request('POST', '/api/auth/setup', b), adminCreateUser: (b) => request('POST', '/api/admin/users', b), adminListUsers: () => request('GET', '/api/admin/users'), - adminUpdateUser: (email, b) => request('PUT', `/api/admin/users/${email}`, b), + adminUpdateUser: (username, b) => request('PUT', `/api/admin/users/${username}`, b), createGroup: (formData) => request('POST', '/api/groups', formData, true), listGroups: () => request('GET', '/api/groups'), getGroup: (id) => request('GET', `/api/groups/${id}`), diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js index 1d5c566..a806b0d 100644 --- a/frontend/src/i18n/index.js +++ b/frontend/src/i18n/index.js @@ -6,8 +6,16 @@ const messages = { app: 'Sales Trainer', login: 'Login', logout: 'Logout', + username: 'Username', email: 'Email', password: 'Password', + newPassword: 'New password', + confirmPassword: 'Confirm password', + save: 'Save', + passwordTooShort: 'Password must be at least 4 characters', + passwordMismatch: 'Passwords do not match', + setupTitle: 'Set up your account', + setupSubtitle: 'First login for ', loginError: 'Invalid credentials', dashboard: 'Dashboard', groups: 'Persona Groups', @@ -57,9 +65,17 @@ const messages = { app: 'ตัวฝึกขาย', login: 'เข้าสู่ระบบ', logout: 'ออกจากระบบ', + username: 'ชื่อผู้ใช้', email: 'อีเมล', password: 'รหัสผ่าน', - loginError: 'อีเมลหรือรหัสผ่านไม่ถูกต้อง', + newPassword: 'รหัสผ่านใหม่', + confirmPassword: 'ยืนยันรหัสผ่าน', + save: 'บันทึก', + passwordTooShort: 'รหัสผ่านต้องอย่างน้อย 4 ตัวอักษร', + passwordMismatch: 'รหัสผ่านไม่ตรงกัน', + setupTitle: 'ตั้งค่าบัญชีของคุณ', + setupSubtitle: 'เข้าสู่ระบบครั้งแรกสำหรับ ', + loginError: 'ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง', dashboard: 'หน้าหลัก', groups: 'กลุ่มลูกค้า (Persona)', myTraining: 'การฝึกของฉัน', diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 989d516..1cc88aa 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -3,6 +3,7 @@ import { auth } from '../store/auth' const routes = [ { path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } }, + { path: '/setup', component: () => import('../views/Setup.vue') }, { path: '/', component: () => import('../views/Dashboard.vue') }, { path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') }, { path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') }, @@ -28,6 +29,10 @@ router.beforeEach(async (to) => { if (!auth.user) { return { path: '/login', query: { redirect: to.fullPath } } } + // Force the mandatory first-time setup (set email + change password) before use. + if (auth.mustSetup && to.path !== '/setup') { + return { path: '/setup' } + } if (to.meta.admin && !auth.isAdmin) { return { path: '/' } } diff --git a/frontend/src/store/auth.js b/frontend/src/store/auth.js index 4146b2e..302bf6d 100644 --- a/frontend/src/store/auth.js +++ b/frontend/src/store/auth.js @@ -5,6 +5,7 @@ import { getToken, setToken, api } from '../api' export const auth = reactive({ user: null, token: getToken(), + mustSetup: false, get role() { return this.user ? this.user.role : null }, @@ -16,23 +17,33 @@ export const auth = reactive({ try { const data = await api.me() this.user = data.user + this.mustSetup = !!data.user?.must_setup return this.user } catch (e) { this.user = null + this.mustSetup = false setToken(null) return null } }, - async login(email, password) { - const data = await api.login(email, password) + async login(username, password) { + const data = await api.login(username, password) this.token = data.token setToken(data.token) this.user = data.user + this.mustSetup = !!data.must_setup + return data.user + }, + async finishSetup(email, password) { + const data = await api.setup({ username: this.user.username || this.user.id, email, password }) + this.user = data.user + this.mustSetup = false return data.user }, logout() { this.user = null this.token = null + this.mustSetup = false setToken(null) }, }) diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index a01947b..79ce78f 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -3,8 +3,8 @@