From 12543b881013b0f2d0136ddabd8f12d85650928c Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Thu, 8 Jan 2026 01:26:28 +0000 Subject: [PATCH 01/16] Associate live show session with script revision (#831) * Associate live show session with particular script revision * Add script revision to show session table * Fix failing unit tests * Enable ruff for alembic configs and tests --- .../src/views/show/config/ConfigSessions.vue | 3 +- .../show/config/sessions/SessionList.vue | 18 ++- server/alembic_config/env.py | 1 + .../versions/29471f7cf7d2_user_deletion.py | 2 +- ...aa5d07e_cleanup_orphaned_script_objects.py | 2 +- ...dd_script_revision_association_to_show_.py | 113 ++++++++++++++++++ ...b6e67_add_session_tags_and_associations.py | 2 +- ...49df18ea818d_add_compiled_scripts_table.py | 1 + ...dd_systemsettings_table_for_jwt_secret_.py | 1 + ...38c9_add_interval_state_to_show_session.py | 1 + .../8c78b9c89ee6_add_user_last_seen_time.py | 1 + ...replace_stage_direction_with_line_type_.py | 2 +- .../a39ac9e9f085_add_user_settings.py | 1 + ...a44e01459595_add_stage_direction_styles.py | 1 + ...fix_session_table_foreign_key_reference.py | 2 +- ...946_rename_user_settings_table_to_user_.py | 2 +- .../be353176c064_detach_users_from_shows.py | 1 + .../d4f66f58158b_initial_alembic_revision.py | 2 - .../e1a2b3c4d5e6_add_user_api_token.py | 1 + ...154c79d86de_add_cue_panel_right_setting.py | 1 + .../f365c2b2b234_add_script_mode_to_show.py | 2 +- ...ee07e45fc_fix_orphaned_script_revisions.py | 2 +- .../ff9f875915b6_add_user_settings_table.py | 1 + .../controllers/api/show/session/sessions.py | 13 ++ server/digi_server/app_server.py | 47 ++++++-- server/models/session.py | 7 +- server/pyproject.toml | 5 - .../api/show/script/test_config.py | 1 - .../api/show/script/test_orphan_deletion.py | 8 +- .../api/show/script/test_revisions.py | 21 +--- .../api/show/script/test_script.py | 6 +- .../script/test_stage_direction_styles.py | 1 - .../api/show/session/test_assign_tags.py | 31 ++++- .../api/show/session/test_sessions.py | 25 +++- .../controllers/api/show/session/test_tags.py | 16 ++- .../controllers/api/show/test_characters.py | 2 +- server/test/controllers/api/show/test_cues.py | 4 - .../controllers/api/show/test_microphones.py | 1 - .../test/controllers/api/show/test_shows.py | 6 +- server/test/controllers/api/test_auth.py | 12 +- server/test/controllers/api/test_rbac.py | 1 - server/test/controllers/api/test_settings.py | 1 - server/test/controllers/api/test_websocket.py | 1 - .../controllers/api/user/test_overrides.py | 1 - server/test/controllers/test_ws_controller.py | 27 ++++- server/test/digi_server/test_digi_server.py | 10 +- server/test/models/test_script.py | 4 +- .../utils/show/test_line_type_validator.py | 4 +- server/test/utils/show/test_mic_assignment.py | 13 +- 49 files changed, 322 insertions(+), 109 deletions(-) create mode 100644 server/alembic_config/versions/4400e44b4455_add_script_revision_association_to_show_.py diff --git a/client/src/views/show/config/ConfigSessions.vue b/client/src/views/show/config/ConfigSessions.vue index 2abe3fa3..15d3f831 100644 --- a/client/src/views/show/config/ConfigSessions.vue +++ b/client/src/views/show/config/ConfigSessions.vue @@ -47,10 +47,11 @@ export default { async mounted() { await this.GET_SHOW_SESSION_DATA(); await this.GET_SESSION_TAGS(); + await this.GET_SCRIPT_REVISIONS(); this.loaded = true; }, methods: { - ...mapActions(['GET_SHOW_SESSION_DATA', 'GET_SESSION_TAGS']), + ...mapActions(['GET_SHOW_SESSION_DATA', 'GET_SESSION_TAGS', 'GET_SCRIPT_REVISIONS']), }, }; diff --git a/client/src/vue_components/show/config/sessions/SessionList.vue b/client/src/vue_components/show/config/sessions/SessionList.vue index 30773df5..0f498654 100644 --- a/client/src/vue_components/show/config/sessions/SessionList.vue +++ b/client/src/vue_components/show/config/sessions/SessionList.vue @@ -39,6 +39,14 @@

+ + @@ -70,10 +94,11 @@ import { mapActions, mapGetters } from 'vuex'; import CreateUser from '@/vue_components/user/CreateUser.vue'; import ConfigRbac from '@/vue_components/user/ConfigRbac.vue'; +import ResetPassword from '@/vue_components/user/ResetPassword.vue'; export default { name: 'ConfigUsers', - components: { CreateUser, ConfigRbac }, + components: { CreateUser, ConfigRbac, ResetPassword }, data() { return { userFields: [ @@ -84,11 +109,12 @@ export default { { key: 'btn', label: '' }, ], editUser: null, + resetUser: null, clientTimeout: null, }; }, computed: { - ...mapGetters(['SHOW_USERS', 'CURRENT_SHOW']), + ...mapGetters(['SHOW_USERS', 'CURRENT_SHOW', 'CURRENT_USER']), }, async mounted() { await this.getUsers(); @@ -103,6 +129,16 @@ export default { setEditUser(userId) { this.editUser = userId; }, + setResetUser(user) { + this.resetUser = user; + }, + async handlePasswordReset() { + await this.getUsers(); + }, + closeResetPasswordModal() { + this.$bvModal.hide('reset-password'); + this.resetUser = null; + }, async deleteUser(data) { const msg = `Are you sure you want to delete ${data.item.username}?`; const action = await this.$bvModal.msgBoxConfirm(msg, {}); diff --git a/client/src/vue_components/user/ResetPassword.vue b/client/src/vue_components/user/ResetPassword.vue new file mode 100644 index 00000000..4b63e9e5 --- /dev/null +++ b/client/src/vue_components/user/ResetPassword.vue @@ -0,0 +1,164 @@ + + + \ No newline at end of file diff --git a/client/src/vue_components/user/settings/ChangePassword.vue b/client/src/vue_components/user/settings/ChangePassword.vue new file mode 100644 index 00000000..cba8fc9b --- /dev/null +++ b/client/src/vue_components/user/settings/ChangePassword.vue @@ -0,0 +1,168 @@ + + + \ No newline at end of file diff --git a/server/alembic_config/versions/01fb1d6c6b08_add_token_version_to_user.py b/server/alembic_config/versions/01fb1d6c6b08_add_token_version_to_user.py new file mode 100644 index 00000000..9cf2c04d --- /dev/null +++ b/server/alembic_config/versions/01fb1d6c6b08_add_token_version_to_user.py @@ -0,0 +1,40 @@ +"""add_token_version_to_user + +Revision ID: 01fb1d6c6b08 +Revises: da55004052c1 +Create Date: 2026-01-11 01:10:17.249232 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "01fb1d6c6b08" +down_revision: Union[str, None] = "da55004052c1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Step 1: Add column as nullable + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.add_column(sa.Column("token_version", sa.Integer(), nullable=True)) + + # Step 2: Backfill with default value (0 for all existing users) + connection = op.get_bind() + connection.execute( + sa.text("UPDATE user SET token_version = 0 WHERE token_version IS NULL") + ) + + # Step 3: Make column non-nullable + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.alter_column("token_version", nullable=False) + + +def downgrade() -> None: + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_column("token_version") diff --git a/server/alembic_config/versions/da55004052c1_add_requires_password_change_to_user.py b/server/alembic_config/versions/da55004052c1_add_requires_password_change_to_user.py new file mode 100644 index 00000000..c8392233 --- /dev/null +++ b/server/alembic_config/versions/da55004052c1_add_requires_password_change_to_user.py @@ -0,0 +1,44 @@ +"""add_requires_password_change_to_user + +Revision ID: da55004052c1 +Revises: b5a760d2ee49 +Create Date: 2026-01-10 18:21:49.635525 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "da55004052c1" +down_revision: Union[str, None] = "b5a760d2ee49" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Step 1: Add column as nullable + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.add_column( + sa.Column("requires_password_change", sa.Boolean(), nullable=True) + ) + + # Step 2: Backfill with default value (False for all existing users) + connection = op.get_bind() + connection.execute( + sa.text( + "UPDATE user SET requires_password_change = 0 WHERE requires_password_change IS NULL" + ) + ) + + # Step 3: Make column non-nullable + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.alter_column("requires_password_change", nullable=False) + + +def downgrade() -> None: + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_column("requires_password_change") diff --git a/server/controllers/api/auth/__init__.py b/server/controllers/api/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/controllers/api/auth/token.py b/server/controllers/api/auth/token.py new file mode 100644 index 00000000..a12ae469 --- /dev/null +++ b/server/controllers/api/auth/token.py @@ -0,0 +1,78 @@ +import secrets + +from models.user import User +from services.password_service import PasswordService +from utils.web.base_controller import BaseAPIController +from utils.web.route import ApiRoute, ApiVersion +from utils.web.web_decorators import ( + api_authenticated, +) + + +@ApiRoute("auth/api-token/generate", ApiVersion.V1) +class ApiTokenGenerateController(BaseAPIController): + @api_authenticated + async def post(self): + """Generate a new API token for the authenticated user""" + with self.make_session() as session: + user = session.get(User, self.current_user["id"]) + if not user: + self.set_status(404) + await self.finish({"message": "User not found"}) + return + + # Generate a secure random token (plain text to return to user) + new_token = secrets.token_urlsafe(32) + + hashed_token = await PasswordService.hash_password(new_token) + + user.api_token = hashed_token + session.commit() + + self.set_status(200) + await self.finish( + { + "message": "API token generated successfully", + "api_token": new_token, + } + ) + + +@ApiRoute("auth/api-token/revoke", ApiVersion.V1) +class ApiTokenRevokeController(BaseAPIController): + @api_authenticated + async def post(self): + """Revoke the API token for the authenticated user""" + with self.make_session() as session: + user = session.get(User, self.current_user["id"]) + if not user: + self.set_status(404) + await self.finish({"message": "User not found"}) + return + + if not user.api_token: + self.set_status(400) + await self.finish({"message": "No API token to revoke"}) + return + + user.api_token = None + session.commit() + + self.set_status(200) + await self.finish({"message": "API token revoked successfully"}) + + +@ApiRoute("auth/api-token", ApiVersion.V1) +class ApiTokenController(BaseAPIController): + @api_authenticated + def get(self): + """Check if the authenticated user has an API token""" + with self.make_session() as session: + user = session.get(User, self.current_user["id"]) + if not user: + self.set_status(404) + self.finish({"message": "User not found"}) + return + + self.set_status(200) + self.finish({"has_token": user.api_token is not None}) diff --git a/server/controllers/api/auth.py b/server/controllers/api/auth/user.py similarity index 63% rename from server/controllers/api/auth.py rename to server/controllers/api/auth/user.py index ae406693..4af56f50 100644 --- a/server/controllers/api/auth.py +++ b/server/controllers/api/auth/user.py @@ -1,18 +1,17 @@ -import secrets from datetime import datetime, timezone -import bcrypt from sqlalchemy import select -from tornado import escape, gen -from tornado.ioloop import IOLoop +from tornado import escape from models.session import Session from models.user import User from registry.named_locks import NamedLockRegistry from schemas.schemas import UserSchema +from services.password_service import PasswordService from utils.web.base_controller import BaseAPIController from utils.web.route import ApiRoute, ApiVersion from utils.web.web_decorators import ( + allow_when_password_required, api_authenticated, no_live_session, require_admin, @@ -36,11 +35,12 @@ async def post(self): self.set_status(400) await self.finish({"message": "Password missing"}) return - if len(password) < 6: + + # Validate password strength + is_valid, error_msg = PasswordService.validate_password_strength(password) + if not is_valid: self.set_status(400) - await self.finish( - {"message": "Password must be at least 6 characters long"} - ) + await self.finish({"message": error_msg}) return is_admin = data.get("is_admin", False) @@ -54,10 +54,7 @@ async def post(self): await self.finish({"message": "Username already taken"}) return - hashed_password = await IOLoop.current().run_in_executor( - None, bcrypt.hashpw, escape.utf8(password), bcrypt.gensalt() - ) - hashed_password = escape.to_unicode(hashed_password) + hashed_password = await PasswordService.hash_password(password) session.add( User( @@ -104,30 +101,11 @@ async def post(self): async with NamedLockRegistry.acquire( f"UserLock::{user_to_delete.username}" ): - # First, log out all sessions for this user - await self.application.ws_send_to_user( - user_to_delete.id, "NOOP", "USER_LOGOUT", {} + # Force logout all sessions using UserService + await self.application.user_service.force_logout_all_sessions( + session, user_to_delete ) - # Then really make sure we have logged out the user for all sessions (basically, - # wait for the websocket ops to finish) - session_logout_attempts = 0 - user_sessions = session.scalars( - select(Session).where(Session.user_id == user_to_delete.id) - ).all() - while user_sessions and session_logout_attempts < 5: - for user_session in user_sessions: - ws_session = self.application.get_ws(user_session.internal_id) - await ws_session.write_message( - {"OP": "NOOP", "DATA": "{}", "ACTION": "USER_LOGOUT"} - ) - ws_session.current_user_id = None - await gen.sleep(0.2) - user_sessions = session.scalars( - select(Session).where(Session.user_id == user_to_delete.id) - ).all() - session_logout_attempts += 1 - # Delete all RBAC associations for this user self.application.rbac.delete_actor(user_to_delete) @@ -167,11 +145,8 @@ async def post(self): await self.finish({"message": "Invalid username/password"}) return - password_equal = await IOLoop.current().run_in_executor( - None, - bcrypt.checkpw, - escape.utf8(password), - escape.utf8(user.password), + password_equal = await PasswordService.verify_password( + password, user.password ) if password_equal: @@ -207,6 +182,7 @@ async def post(self): @ApiRoute("auth/logout", ApiVersion.V1) class LogoutHandler(BaseAPIController): @api_authenticated + @allow_when_password_required async def post(self): data = escape.json_decode(self.request.body) @@ -268,16 +244,36 @@ def get(self): @ApiRoute("auth", ApiVersion.V1) class AuthHandler(BaseAPIController): + @allow_when_password_required def get(self): self.set_status(200) self.finish(self.current_user if self.current_user else {}) -@ApiRoute("auth/api-token/generate", ApiVersion.V1) -class ApiTokenGenerateController(BaseAPIController): +@ApiRoute("auth/change-password", ApiVersion.V1) +class PasswordChangeController(BaseAPIController): + """Self-service password change endpoint""" + @api_authenticated - async def post(self): - """Generate a new API token for the authenticated user""" + @allow_when_password_required + async def patch(self): + """ + Change authenticated user's password. + + Request body: {old_password: str, new_password: str} + old_password is optional if requires_password_change=True + + :return: 200 on success, 401 if old password wrong, 400 if new password invalid + """ + data = escape.json_decode(self.request.body) + old_password = data.get("old_password", "") + new_password = data.get("new_password", "") + + if not new_password: + self.set_status(400) + await self.finish({"message": "New password is required"}) + return + with self.make_session() as session: user = session.get(User, self.current_user["id"]) if not user: @@ -285,62 +281,115 @@ async def post(self): await self.finish({"message": "User not found"}) return - # Generate a secure random token (plain text to return to user) - new_token = secrets.token_urlsafe(32) + if not user.requires_password_change: + if not old_password: + self.set_status(400) + await self.finish({"message": "Old password is required"}) + return + + password_matches = await PasswordService.verify_password( + old_password, user.password + ) + if not password_matches: + self.set_status(401) + await self.finish({"message": "Current password is incorrect"}) + return + + try: + # Increment token_version to invalidate old tokens, but don't + # broadcast logout since we're providing a new token for all sessions + await self.application.user_service.change_password( + session, + user, + new_password, + invalidate_tokens=True, + force_logout_sessions=False, + ) + except ValueError as e: + self.set_status(400) + await self.finish({"message": str(e)}) + return - # Hash the token before storing (like passwords) - hashed_token = await IOLoop.current().run_in_executor( - None, bcrypt.hashpw, escape.utf8(new_token), bcrypt.gensalt() + # Generate new JWT token with updated token_version + new_token = self.application.jwt_service.create_access_token( + data={"user_id": user.id} ) - hashed_token = escape.to_unicode(hashed_token) - user.api_token = hashed_token - session.commit() + # Broadcast new token to all user's sessions for seamless re-auth + await self.application.user_service.refresh_token_all_sessions( + user, new_token + ) self.set_status(200) await self.finish( { - "message": "API token generated successfully", - "api_token": new_token, + "message": "Password changed successfully", + "access_token": new_token, + "token_type": "bearer", } ) -@ApiRoute("auth/api-token/revoke", ApiVersion.V1) -class ApiTokenRevokeController(BaseAPIController): +@ApiRoute("auth/reset-password", ApiVersion.V1) +class AdminPasswordResetController(BaseAPIController): + """Admin-initiated password reset endpoint""" + @api_authenticated + @require_admin async def post(self): - """Revoke the API token for the authenticated user""" + """ + Reset a user's password to a temporary password (admin only). + + Generates a temporary password and forces the user to change it on next login. + Admins cannot reset their own password via this endpoint. + + Request body: {user_id: int} + + :return: 200 with temporary password on success, 400 if validation fails + """ + data = escape.json_decode(self.request.body) + user_id = data.get("user_id") + + if not user_id: + self.set_status(400) + await self.finish({"message": "user_id is required"}) + return + with self.make_session() as session: - user = session.get(User, self.current_user["id"]) - if not user: + target_user = session.get(User, int(user_id)) + if not target_user: self.set_status(404) await self.finish({"message": "User not found"}) return - if not user.api_token: + if target_user.id == self.current_user["id"]: self.set_status(400) - await self.finish({"message": "No API token to revoke"}) + await self.finish( + {"message": "Cannot reset your own password via admin endpoint"} + ) return - user.api_token = None - session.commit() - - self.set_status(200) - await self.finish({"message": "API token revoked successfully"}) - + # Generate temporary password + temp_password = PasswordService.generate_temporary_password() -@ApiRoute("auth/api-token", ApiVersion.V1) -class ApiTokenController(BaseAPIController): - @api_authenticated - def get(self): - """Check if the authenticated user has an API token""" - with self.make_session() as session: - user = session.get(User, self.current_user["id"]) - if not user: - self.set_status(404) - self.finish({"message": "User not found"}) + # Change the password and force password change on next login + try: + await self.application.user_service.change_password( + session, target_user, temp_password, invalidate_tokens=True + ) + except ValueError as e: + self.set_status(400) + await self.finish({"message": str(e)}) return + # Ensure requires_password_change is set + target_user.requires_password_change = True + session.commit() + self.set_status(200) - self.finish({"has_token": user.api_token is not None}) + await self.finish( + { + "message": "Password reset successfully", + "temporary_password": temp_password, + } + ) diff --git a/server/controllers/api/rbac.py b/server/controllers/api/rbac.py index f7c4c2cf..a36df59b 100644 --- a/server/controllers/api/rbac.py +++ b/server/controllers/api/rbac.py @@ -8,11 +8,16 @@ from registry.schema import get_registry from utils.web.base_controller import BaseAPIController from utils.web.route import ApiRoute, ApiVersion -from utils.web.web_decorators import api_authenticated, require_admin +from utils.web.web_decorators import ( + allow_when_password_required, + api_authenticated, + require_admin, +) @ApiRoute("rbac/roles", ApiVersion.V1) class RBACRolesHandler(BaseAPIController): + @allow_when_password_required async def get(self): self.set_status(200) await self.finish( @@ -95,6 +100,7 @@ async def get(self): @ApiRoute("rbac/user/roles", ApiVersion.V1) class RBACUserRolesHandler(BaseAPIController): @api_authenticated + @allow_when_password_required async def get(self): with self.make_session() as session: res = defaultdict(list) diff --git a/server/controllers/api/settings.py b/server/controllers/api/settings.py index 24709d0c..4a0fefc3 100644 --- a/server/controllers/api/settings.py +++ b/server/controllers/api/settings.py @@ -4,11 +4,17 @@ from digi_server.settings import Settings from utils.web.base_controller import BaseAPIController from utils.web.route import ApiRoute, ApiVersion -from utils.web.web_decorators import api_authenticated, no_live_session, require_admin +from utils.web.web_decorators import ( + allow_when_password_required, + api_authenticated, + no_live_session, + require_admin, +) @ApiRoute("settings", ApiVersion.V1) class SettingsController(BaseAPIController): + @allow_when_password_required async def get(self): settings: Settings = self.application.digi_settings settings_json = await settings.as_json() diff --git a/server/controllers/api/user/settings.py b/server/controllers/api/user/settings.py index 6ce35350..dbf3fdd5 100644 --- a/server/controllers/api/user/settings.py +++ b/server/controllers/api/user/settings.py @@ -5,12 +5,13 @@ from schemas.schemas import UserSettingsSchema from utils.web.base_controller import BaseAPIController from utils.web.route import ApiRoute, ApiVersion -from utils.web.web_decorators import api_authenticated +from utils.web.web_decorators import allow_when_password_required, api_authenticated @ApiRoute("user/settings", ApiVersion.V1) class UserSettingsController(BaseAPIController): @api_authenticated + @allow_when_password_required async def get(self): schema = UserSettingsSchema() with self.make_session() as session: diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py index 208f00c3..eab787a2 100644 --- a/server/digi_server/app_server.py +++ b/server/digi_server/app_server.py @@ -27,6 +27,7 @@ from models.show import Show from models.user import User from rbac.rbac import RBACController +from services.user_service import UserService from utils.database import DigiSQLAlchemy from utils.exceptions import DatabaseTypeException, DatabaseUpgradeRequired from utils.module_discovery import get_resource_path, is_frozen @@ -106,6 +107,9 @@ class AlembicVersion(self._db.Model): # Configure the JWT service once we have set up the database self.jwt_service = self._configure_jwt() + # Configure the User service + self.user_service = UserService(self) + # On startup, perform the following checks/operations with the database: with self._db.sessionmaker() as session: # 1. Check for presence of admin user, and update settings to match diff --git a/server/models/user.py b/server/models/user.py index 0e5ba9e6..5075859e 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -68,6 +68,8 @@ class User(db.Model): last_login: Mapped[datetime.datetime | None] = mapped_column() last_seen: Mapped[datetime.datetime | None] = mapped_column() api_token: Mapped[str | None] = mapped_column(index=True) + requires_password_change: Mapped[bool] = mapped_column(default=False) + token_version: Mapped[int] = mapped_column(default=0) sessions: Mapped[List["Session"]] = relationship(back_populates="user") diff --git a/server/requirements.txt b/server/requirements.txt index 4790a133..4e6355d9 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -9,4 +9,5 @@ anytree==2.13.0 alembic==1.17.2 marshmallow<5 pyjwt[crypto]==2.10.1 -setuptools==80.9.0 \ No newline at end of file +setuptools==80.9.0 +xkcdpass==1.20.0 \ No newline at end of file diff --git a/server/services/__init__.py b/server/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/services/password_service.py b/server/services/password_service.py new file mode 100644 index 00000000..ded36598 --- /dev/null +++ b/server/services/password_service.py @@ -0,0 +1,86 @@ +"""Password service for hashing, verification, and generation""" + +import bcrypt +import xkcdpass.xkcd_password as xp +from tornado import escape +from tornado.ioloop import IOLoop + + +class PasswordService: + """Service for password-related operations""" + + @staticmethod + async def hash_password(password: str) -> str: + """ + Hash a password using bcrypt with auto-generated salt. + + :param password: Plain text password to hash + :type password: str + :return: Bcrypt hash string suitable for database storage + :rtype: str + """ + loop = IOLoop.current() + hashed = await loop.run_in_executor( + None, bcrypt.hashpw, escape.utf8(password), bcrypt.gensalt() + ) + return escape.to_unicode(hashed) + + @staticmethod + async def verify_password(password: str, hashed: str) -> bool: + """ + Verify a password against a bcrypt hash. + + :param password: Plain text password to verify + :type password: str + :param hashed: Bcrypt hash to verify against + :type hashed: str + :return: True if password matches hash, False otherwise + :rtype: bool + """ + loop = IOLoop.current() + matches = await loop.run_in_executor( + None, bcrypt.checkpw, escape.utf8(password), escape.utf8(hashed) + ) + return matches + + @staticmethod + def validate_password_strength(password: str) -> tuple[bool, str]: + """ + Validate password meets strength requirements. + + Current requirements: + - Minimum 6 characters + + :param password: Password to validate + :type password: str + :return: Tuple of (is_valid, error_message). is_valid is True if password + meets requirements. error_message is empty string if valid, error + description if invalid. + :rtype: tuple[bool, str] + """ + if len(password) < 6: + return False, "Password must be at least 6 characters long" + + return True, "" + + @staticmethod + def generate_temporary_password(word_count: int = 3) -> str: + """ + Generate a human-readable temporary password using EFF wordlist. + + Uses the xkcdpass library with the EFF long wordlist (7776 words) + for cryptographically secure passphrase generation. + + - 3 words = ~38.7 bits of entropy + - 4 words = ~51.6 bits of entropy + + :param word_count: Number of words to use (default: 3) + :type word_count: int + :return: Dash-separated password like "correct-horse-battery" + :rtype: str + """ + wordlist = xp.generate_wordlist(wordfile=xp.locate_wordfile()) + password = xp.generate_xkcdpassword( + wordlist, numwords=word_count, delimiter="-" + ) + return password diff --git a/server/services/user_service.py b/server/services/user_service.py new file mode 100644 index 00000000..f518640d --- /dev/null +++ b/server/services/user_service.py @@ -0,0 +1,116 @@ +"""User service for user management operations""" + +from sqlalchemy import select +from tornado import gen + +from models.session import Session +from models.user import User +from services.password_service import PasswordService + + +class UserService: + """Service for user-level operations""" + + def __init__(self, application): + """ + Initialize UserService. + + :param application: Tornado application instance + """ + self.application = application + + async def change_password( + self, + session, + user: User, + new_password: str, + invalidate_tokens: bool = True, + force_logout_sessions: bool = True, + ) -> None: + """ + Change user's password and optionally invalidate all sessions. + + :param session: SQLAlchemy session + :param user: User model instance + :type user: User + :param new_password: New password (plaintext) + :type new_password: str + :param invalidate_tokens: If True, increment token_version to invalidate JWTs + :type invalidate_tokens: bool + :param force_logout_sessions: If True, broadcast logout to all sessions + :type force_logout_sessions: bool + :raises ValueError: If password validation fails + """ + is_valid, error_msg = PasswordService.validate_password_strength(new_password) + if not is_valid: + raise ValueError(error_msg) + + hashed = await PasswordService.hash_password(new_password) + + user.password = hashed + user.requires_password_change = False + + if invalidate_tokens: + # Increment token version to invalidate all existing JWTs + user.token_version += 1 + + if force_logout_sessions: + # Force logout all WebSocket sessions + await self.force_logout_all_sessions(session, user) + + session.commit() + + async def refresh_token_all_sessions(self, user: User, new_token: str) -> None: + """ + Broadcast new JWT token to all user's active sessions for seamless re-auth. + + Sends TOKEN_REFRESH WebSocket message to all user sessions with the new token. + Each session can then update its stored auth token without interruption. + + :param user: User model instance + :type user: User + :param new_token: New JWT access token + :type new_token: str + """ + await self.application.ws_send_to_user( + user.id, + "NOOP", + "TOKEN_REFRESH", + {"access_token": new_token, "token_type": "bearer"}, + ) + + async def force_logout_all_sessions(self, session, user: User) -> None: + """ + Force logout user from all active sessions via WebSocket. + + Process: + 1. Send USER_LOGOUT WebSocket message to all user sessions + 2. Wait for sessions to clear (with retry loop) + 3. Manually send logout to any remaining sessions + Best-effort - some sessions may not receive message if offline + + :param session: SQLAlchemy session + :param user: User model instance + :type user: User + """ + await self.application.ws_send_to_user(user.id, "NOOP", "USER_LOGOUT", {}) + + session_logout_attempts = 0 + user_sessions = session.scalars( + select(Session).where(Session.user_id == user.id) + ).all() + + while user_sessions and session_logout_attempts < 5: + for user_session in user_sessions: + ws_session = self.application.get_ws(user_session.internal_id) + if ws_session: + await ws_session.write_message( + {"OP": "NOOP", "DATA": "{}", "ACTION": "USER_LOGOUT"} + ) + ws_session.current_user_id = None + + await gen.sleep(0.2) + user_sessions = session.scalars( + select(Session).where(Session.user_id == user.id) + ).all() + session_logout_attempts += 1 diff --git a/server/test/controllers/api/test_auth.py b/server/test/controllers/api/test_auth.py index 417174b9..825cba51 100644 --- a/server/test/controllers/api/test_auth.py +++ b/server/test/controllers/api/test_auth.py @@ -532,3 +532,540 @@ def test_get_users(self): self.assertIn("admin", usernames) self.assertIn("user1", usernames) self.assertIn("user2", usernames) + + def test_change_password_success(self): + """Test successful password change with valid old password""" + # Create and login a user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "testuser", "password": "oldpass123", "is_admin": False} + ), + ) + + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "testuser", "password": "oldpass123"}), + ) + token = escape.json_decode(response.body)["access_token"] + + # Change password + response = self.fetch( + "/api/v1/auth/change-password", + method="PATCH", + body=escape.json_encode( + {"old_password": "oldpass123", "new_password": "newpass456"} + ), + headers={"Authorization": f"Bearer {token}"}, + ) + + self.assertEqual(200, response.code) + response_body = escape.json_decode(response.body) + self.assertEqual("Password changed successfully", response_body["message"]) + self.assertIn("access_token", response_body) + self.assertIn("token_type", response_body) + + new_token = response_body["access_token"] + + # Verify old token is invalidated (should get 401) + response = self.fetch( + "/api/v1/auth", method="GET", headers={"Authorization": f"Bearer {token}"} + ) + self.assertEqual(401, response.code) + + # Verify new token works (user stays logged in) + response = self.fetch( + "/api/v1/auth", + method="GET", + headers={"Authorization": f"Bearer {new_token}"}, + ) + self.assertEqual(200, response.code) + + # Verify can login with new password + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "testuser", "password": "newpass456"}), + ) + self.assertEqual(200, response.code) + + def test_change_password_incorrect_old_password(self): + """Test password change fails with incorrect old password""" + # Create and login a user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "testuser", "password": "oldpass123", "is_admin": False} + ), + ) + + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "testuser", "password": "oldpass123"}), + ) + token = escape.json_decode(response.body)["access_token"] + + # Try to change password with wrong old password + response = self.fetch( + "/api/v1/auth/change-password", + method="PATCH", + body=escape.json_encode( + {"old_password": "wrongpass", "new_password": "newpass456"} + ), + headers={"Authorization": f"Bearer {token}"}, + ) + + self.assertEqual(401, response.code) + response_body = escape.json_decode(response.body) + self.assertEqual("Current password is incorrect", response_body["message"]) + + def test_change_password_missing_new_password(self): + """Test password change fails when new password is missing""" + # Create and login a user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "testuser", "password": "oldpass123", "is_admin": False} + ), + ) + + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "testuser", "password": "oldpass123"}), + ) + token = escape.json_decode(response.body)["access_token"] + + # Try to change password without new password + response = self.fetch( + "/api/v1/auth/change-password", + method="PATCH", + body=escape.json_encode({"old_password": "oldpass123"}), + headers={"Authorization": f"Bearer {token}"}, + ) + + self.assertEqual(400, response.code) + response_body = escape.json_decode(response.body) + self.assertEqual("New password is required", response_body["message"]) + + def test_change_password_weak_password(self): + """Test password change fails with weak new password""" + # Create and login a user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "testuser", "password": "oldpass123", "is_admin": False} + ), + ) + + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "testuser", "password": "oldpass123"}), + ) + token = escape.json_decode(response.body)["access_token"] + + # Try to change to weak password (< 6 characters) + response = self.fetch( + "/api/v1/auth/change-password", + method="PATCH", + body=escape.json_encode( + {"old_password": "oldpass123", "new_password": "123"} + ), + headers={"Authorization": f"Bearer {token}"}, + ) + + self.assertEqual(400, response.code) + response_body = escape.json_decode(response.body) + self.assertIn("at least 6 characters", response_body["message"]) + + def test_change_password_requires_authentication(self): + """Test password change requires authentication""" + response = self.fetch( + "/api/v1/auth/change-password", + method="PATCH", + body=escape.json_encode( + {"old_password": "oldpass", "new_password": "newpass"} + ), + ) + + self.assertEqual(401, response.code) + + def test_change_password_with_requires_password_change_flag(self): + """Test password change works without old password when requires_password_change=True""" + # Create user via API to ensure password is properly hashed + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + { + "username": "forcedchange", + "password": "temppass123", + "is_admin": False, + } + ), + ) + + # Set requires_password_change flag + with self._app.get_db().sessionmaker() as session: + user = session.scalars( + select(User).where(User.username == "forcedchange") + ).first() + user.requires_password_change = True + session.commit() + user_id = user.id + + # Login + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode( + {"username": "forcedchange", "password": "temppass123"} + ), + ) + token = escape.json_decode(response.body)["access_token"] + + # Change password without providing old password + response = self.fetch( + "/api/v1/auth/change-password", + method="PATCH", + body=escape.json_encode({"new_password": "newpass123"}), + headers={"Authorization": f"Bearer {token}"}, + ) + + self.assertEqual(200, response.code) + + # Verify requires_password_change is now False + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + self.assertFalse(user.requires_password_change) + + def test_password_enforcement_blocks_regular_endpoints(self): + """Test that requires_password_change blocks access to regular endpoints""" + # Create user with requires_password_change=True + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + { + "username": "forcechange", + "password": "password123", + "is_admin": False, + } + ), + ) + + # Log in to get JWT + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode( + {"username": "forcechange", "password": "password123"} + ), + ) + self.assertEqual(200, response.code) + token = escape.json_decode(response.body)["access_token"] + + # Set requires_password_change=True + with self._app.get_db().sessionmaker() as session: + user = session.scalars( + select(User).where(User.username == "forcechange") + ).first() + user.requires_password_change = True + session.commit() + + # Try to access a regular endpoint (should be blocked with 403) + response = self.fetch( + "/api/v1/auth/api-token", + method="GET", + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(403, response.code) + + def test_password_enforcement_allows_change_password_endpoint(self): + """Test that requires_password_change allows access to change-password""" + # Create user with requires_password_change=True + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + { + "username": "forcechange2", + "password": "password123", + "is_admin": False, + } + ), + ) + + # Log in to get JWT + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode( + {"username": "forcechange2", "password": "password123"} + ), + ) + self.assertEqual(200, response.code) + token = escape.json_decode(response.body)["access_token"] + + # Set requires_password_change=True + with self._app.get_db().sessionmaker() as session: + user = session.scalars( + select(User).where(User.username == "forcechange2") + ).first() + user.requires_password_change = True + session.commit() + + # Try to change password (should be allowed) + response = self.fetch( + "/api/v1/auth/change-password", + method="PATCH", + body=escape.json_encode({"new_password": "newpassword123"}), + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(200, response.code) + + def test_password_enforcement_allows_logout_endpoint(self): + """Test that requires_password_change allows access to logout""" + # Create user with requires_password_change=True + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + { + "username": "forcechange3", + "password": "password123", + "is_admin": False, + } + ), + ) + + # Log in to get JWT + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode( + {"username": "forcechange3", "password": "password123"} + ), + ) + self.assertEqual(200, response.code) + token = escape.json_decode(response.body)["access_token"] + + # Set requires_password_change=True + with self._app.get_db().sessionmaker() as session: + user = session.scalars( + select(User).where(User.username == "forcechange3") + ).first() + user.requires_password_change = True + session.commit() + + # Try to logout (should be allowed) + response = self.fetch( + "/api/v1/auth/logout", + method="POST", + body=escape.json_encode({}), + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(200, response.code) + + def test_admin_reset_password_success(self): + """Test admin can successfully reset another user's password""" + # Create admin user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "admin", "password": "adminpass", "is_admin": True} + ), + ) + + # Login as admin + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "admin", "password": "adminpass"}), + ) + admin_token = escape.json_decode(response.body)["access_token"] + + # Create a regular user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "regularuser", "password": "userpass", "is_admin": False} + ), + ) + + # Get user ID + with self._app.get_db().sessionmaker() as session: + user = session.scalars( + select(User).where(User.username == "regularuser") + ).first() + user_id = user.id + + # Admin resets user password + response = self.fetch( + "/api/v1/auth/reset-password", + method="POST", + body=escape.json_encode({"user_id": user_id}), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + self.assertEqual(200, response.code) + response_body = escape.json_decode(response.body) + self.assertEqual("Password reset successfully", response_body["message"]) + self.assertIn("temporary_password", response_body) + + temp_password = response_body["temporary_password"] + + # Verify temporary password works + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode( + {"username": "regularuser", "password": temp_password} + ), + ) + self.assertEqual(200, response.code) + + # Verify requires_password_change is True + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + self.assertTrue(user.requires_password_change) + + def test_admin_reset_password_cannot_reset_own(self): + """Test admin cannot reset their own password via admin endpoint""" + # Create admin user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "admin", "password": "adminpass", "is_admin": True} + ), + ) + + # Login as admin + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "admin", "password": "adminpass"}), + ) + admin_token = escape.json_decode(response.body)["access_token"] + + # Get admin user ID + with self._app.get_db().sessionmaker() as session: + admin = session.scalars( + select(User).where(User.username == "admin") + ).first() + admin_id = admin.id + + # Try to reset own password + response = self.fetch( + "/api/v1/auth/reset-password", + method="POST", + body=escape.json_encode({"user_id": admin_id}), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + self.assertEqual(400, response.code) + response_body = escape.json_decode(response.body) + self.assertIn("Cannot reset your own password", response_body["message"]) + + def test_admin_reset_password_requires_admin(self): + """Test password reset requires admin privileges""" + # Create regular user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "regularuser", "password": "userpass", "is_admin": False} + ), + ) + + # Login as regular user + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode( + {"username": "regularuser", "password": "userpass"} + ), + ) + user_token = escape.json_decode(response.body)["access_token"] + + # Try to reset password without admin privileges + response = self.fetch( + "/api/v1/auth/reset-password", + method="POST", + body=escape.json_encode({"user_id": 999}), + headers={"Authorization": f"Bearer {user_token}"}, + ) + + self.assertEqual(401, response.code) + + def test_admin_reset_password_user_not_found(self): + """Test password reset fails for non-existent user""" + # Create admin user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "admin", "password": "adminpass", "is_admin": True} + ), + ) + + # Login as admin + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "admin", "password": "adminpass"}), + ) + admin_token = escape.json_decode(response.body)["access_token"] + + # Try to reset password for non-existent user + response = self.fetch( + "/api/v1/auth/reset-password", + method="POST", + body=escape.json_encode({"user_id": 99999}), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + self.assertEqual(404, response.code) + response_body = escape.json_decode(response.body) + self.assertEqual("User not found", response_body["message"]) + + def test_admin_reset_password_missing_user_id(self): + """Test password reset fails when user_id is missing""" + # Create admin user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + {"username": "admin", "password": "adminpass", "is_admin": True} + ), + ) + + # Login as admin + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "admin", "password": "adminpass"}), + ) + admin_token = escape.json_decode(response.body)["access_token"] + + # Try to reset password without user_id + response = self.fetch( + "/api/v1/auth/reset-password", + method="POST", + body=escape.json_encode({}), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + self.assertEqual(400, response.code) + response_body = escape.json_decode(response.body) + self.assertEqual("user_id is required", response_body["message"]) diff --git a/server/test/services/test_password_service.py b/server/test/services/test_password_service.py new file mode 100644 index 00000000..5534a968 --- /dev/null +++ b/server/test/services/test_password_service.py @@ -0,0 +1,133 @@ +from tornado.testing import AsyncTestCase, gen_test + +from services.password_service import PasswordService + + +class TestPasswordService(AsyncTestCase): + """Unit tests for PasswordService""" + + @gen_test + async def test_hash_password_returns_valid_bcrypt_hash(self): + """Test that hash_password returns a valid bcrypt hash string""" + password = "test_password_123" + hashed = await PasswordService.hash_password(password) + + # Bcrypt hashes are always 60 characters and start with $2b$ or $2a$ + self.assertIsNotNone(hashed) + self.assertEqual(60, len(hashed)) + self.assertTrue(hashed.startswith("$2b$") or hashed.startswith("$2a$")) + + @gen_test + async def test_hash_password_produces_different_hashes(self): + """Test that hashing the same password twice produces different hashes""" + password = "test_password_123" + hash1 = await PasswordService.hash_password(password) + hash2 = await PasswordService.hash_password(password) + + # Each hash should be unique due to random salt + self.assertNotEqual(hash1, hash2) + + @gen_test + async def test_verify_password_with_correct_password(self): + """Test that verify_password returns True for correct password""" + password = "correct_password" + hashed = await PasswordService.hash_password(password) + + result = await PasswordService.verify_password(password, hashed) + self.assertTrue(result) + + @gen_test + async def test_verify_password_with_incorrect_password(self): + """Test that verify_password returns False for incorrect password""" + password = "correct_password" + wrong_password = "wrong_password" + hashed = await PasswordService.hash_password(password) + + result = await PasswordService.verify_password(wrong_password, hashed) + self.assertFalse(result) + + @gen_test + async def test_verify_password_case_sensitive(self): + """Test that password verification is case-sensitive""" + password = "TestPassword123" + hashed = await PasswordService.hash_password(password) + + result = await PasswordService.verify_password("testpassword123", hashed) + self.assertFalse(result) + + def test_validate_password_strength_minimum_length(self): + """Test that passwords must be at least 6 characters""" + # Valid passwords + is_valid, error_msg = PasswordService.validate_password_strength("123456") + self.assertTrue(is_valid) + self.assertEqual("", error_msg) + + is_valid, error_msg = PasswordService.validate_password_strength( + "longer_password" + ) + self.assertTrue(is_valid) + self.assertEqual("", error_msg) + + def test_validate_password_strength_too_short(self): + """Test that passwords under 6 characters are rejected""" + is_valid, error_msg = PasswordService.validate_password_strength("12345") + self.assertFalse(is_valid) + self.assertEqual("Password must be at least 6 characters long", error_msg) + + is_valid, error_msg = PasswordService.validate_password_strength("") + self.assertFalse(is_valid) + self.assertEqual("Password must be at least 6 characters long", error_msg) + + def test_generate_temporary_password_default_word_count(self): + """Test that generate_temporary_password produces 3-word password by default""" + password = PasswordService.generate_temporary_password() + + # Should be dash-separated words + words = password.split("-") + self.assertEqual(3, len(words)) + + # Each word should be non-empty and alphanumeric + for word in words: + self.assertGreater(len(word), 0) + self.assertTrue(word.isalpha()) + + def test_generate_temporary_password_custom_word_count(self): + """Test that generate_temporary_password respects word_count parameter""" + for word_count in [2, 3, 4, 5]: + password = PasswordService.generate_temporary_password( + word_count=word_count + ) + words = password.split("-") + self.assertEqual(word_count, len(words)) + + def test_generate_temporary_password_uniqueness(self): + """Test that generate_temporary_password produces unique passwords""" + passwords = set() + for _ in range(10): + password = PasswordService.generate_temporary_password() + passwords.add(password) + + # With 7776 words in EFF wordlist, collision probability is extremely low + # All 10 passwords should be unique + self.assertEqual(10, len(passwords)) + + def test_generate_temporary_password_format(self): + """Test that generated passwords only contain lowercase letters and dashes""" + password = PasswordService.generate_temporary_password() + + # Should only contain lowercase letters and dashes + allowed_chars = set("abcdefghijklmnopqrstuvwxyz-") + self.assertTrue(all(c in allowed_chars for c in password)) + + # Should not start or end with dash + self.assertFalse(password.startswith("-")) + self.assertFalse(password.endswith("-")) + + def test_generate_temporary_password_meets_minimum_length(self): + """Test that generated temporary passwords meet 6-character minimum""" + password = PasswordService.generate_temporary_password() + is_valid, _ = PasswordService.validate_password_strength(password) + + # Generated passwords should always be valid + self.assertTrue(is_valid) + self.assertGreaterEqual(len(password), 6) diff --git a/server/test/services/test_user_service.py b/server/test/services/test_user_service.py new file mode 100644 index 00000000..57e71313 --- /dev/null +++ b/server/test/services/test_user_service.py @@ -0,0 +1,219 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +from sqlalchemy import select +from tornado.testing import gen_test + +from models.session import Session +from models.user import User +from test.conftest import DigiScriptTestCase + + +class TestUserService(DigiScriptTestCase): + """Unit tests for UserService""" + + def setUp(self): + super().setUp() + self.user_service = self._app.user_service + + @gen_test + async def test_change_password_success(self): + """Test that change_password successfully updates user password""" + # Create a test user + with self._app.get_db().sessionmaker() as session: + user = User(username="testuser", password="old_hash") + session.add(user) + session.commit() + user_id = user.id + + # Change password + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + await self.user_service.change_password( + session, user, "new_password_123", invalidate_tokens=False + ) + + # Verify password was changed + session.refresh(user) + self.assertNotEqual("old_hash", user.password) + self.assertFalse(user.requires_password_change) + + @gen_test + async def test_change_password_sets_requires_password_change_false(self): + """Test that change_password sets requires_password_change to False""" + # Create a user that requires password change + with self._app.get_db().sessionmaker() as session: + user = User( + username="testuser", + password="old_hash", + requires_password_change=True, + ) + session.add(user) + session.commit() + user_id = user.id + + # Change password + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + await self.user_service.change_password( + session, user, "new_password_123", invalidate_tokens=False + ) + + # Verify requires_password_change is now False + session.refresh(user) + self.assertFalse(user.requires_password_change) + + @gen_test + async def test_change_password_validates_password_strength(self): + """Test that change_password rejects weak passwords""" + # Create a test user + with self._app.get_db().sessionmaker() as session: + user = User(username="testuser", password="old_hash") + session.add(user) + session.commit() + user_id = user.id + + # Try to change to weak password (< 6 characters) + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + with self.assertRaises(ValueError) as context: + await self.user_service.change_password( + session, user, "short", invalidate_tokens=False + ) + + self.assertIn("at least 6 characters", str(context.exception)) + + @gen_test + async def test_change_password_with_invalidate_tokens(self): + """Test that change_password can invalidate tokens and force logout""" + # Create a test user + with self._app.get_db().sessionmaker() as session: + user = User(username="testuser", password="old_hash") + session.add(user) + session.commit() + user_id = user.id + + # Mock the WebSocket send method + with patch.object( + self._app, "ws_send_to_user", new_callable=AsyncMock + ) as mock_ws_send: + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + await self.user_service.change_password( + session, user, "new_password_123", invalidate_tokens=True + ) + + # Verify WebSocket message was sent to log out user + mock_ws_send.assert_called_once_with(user_id, "NOOP", "USER_LOGOUT", {}) + + @gen_test + async def test_force_logout_all_sessions_sends_websocket_message(self): + """Test that force_logout_all_sessions sends USER_LOGOUT message""" + # Create a test user + with self._app.get_db().sessionmaker() as session: + user = User(username="testuser", password="old_hash") + session.add(user) + session.commit() + user_id = user.id + + # Mock the WebSocket send method + with patch.object( + self._app, "ws_send_to_user", new_callable=AsyncMock + ) as mock_ws_send: + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + await self.user_service.force_logout_all_sessions(session, user) + + # Verify WebSocket message was sent + mock_ws_send.assert_called_once_with(user_id, "NOOP", "USER_LOGOUT", {}) + + @gen_test + async def test_force_logout_all_sessions_with_active_sessions(self): + """Test that force_logout_all_sessions handles active WebSocket sessions""" + # Create a test user and session + with self._app.get_db().sessionmaker() as session: + user = User(username="testuser", password="old_hash") + session.add(user) + session.flush() + + ws_session = Session(internal_id="test-session-id", user_id=user.id) + session.add(ws_session) + session.commit() + user_id = user.id + + # Mock the WebSocket controller + mock_ws_controller = MagicMock() + mock_ws_controller.write_message = AsyncMock() + mock_ws_controller.current_user_id = user_id + + with patch.object( + self._app, "ws_send_to_user", new_callable=AsyncMock + ) as mock_ws_send: + with patch.object(self._app, "get_ws", return_value=mock_ws_controller): + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + + # Clear the session so logout loop has nothing to clear + session.execute(select(Session).where(Session.user_id == user_id)) + + await self.user_service.force_logout_all_sessions(session, user) + + # Verify WebSocket send was called + mock_ws_send.assert_called() + + @gen_test + async def test_force_logout_all_sessions_without_websocket(self): + """Test that force_logout_all_sessions handles missing WebSocket gracefully""" + # Create a test user with a session but no active WebSocket + with self._app.get_db().sessionmaker() as session: + user = User(username="testuser", password="old_hash") + session.add(user) + session.flush() + + ws_session = Session(internal_id="test-session-id", user_id=user.id) + session.add(ws_session) + session.commit() + user_id = user.id + + # Mock get_ws to return None (no active WebSocket) + with patch.object( + self._app, "ws_send_to_user", new_callable=AsyncMock + ) as mock_ws_send: + with patch.object(self._app, "get_ws", return_value=None): + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + + # Should not raise an error even without active WebSocket + await self.user_service.force_logout_all_sessions(session, user) + + # Verify WebSocket send was still called + mock_ws_send.assert_called_once() + + @gen_test + async def test_change_password_hashes_password(self): + """Test that change_password properly hashes the new password""" + # Create a test user + with self._app.get_db().sessionmaker() as session: + user = User(username="testuser", password="old_hash") + session.add(user) + session.commit() + user_id = user.id + + new_password = "new_password_123" + + # Change password + with self._app.get_db().sessionmaker() as session: + user = session.get(User, user_id) + await self.user_service.change_password( + session, user, new_password, invalidate_tokens=False + ) + + session.refresh(user) + + # Verify password is not stored in plain text + self.assertNotEqual(new_password, user.password) + + # Verify it's a bcrypt hash (60 chars, starts with $2b$) + self.assertEqual(60, len(user.password)) + self.assertTrue( + user.password.startswith("$2b$") or user.password.startswith("$2a$") + ) diff --git a/server/utils/web/base_controller.py b/server/utils/web/base_controller.py index ec7bcf00..181505af 100644 --- a/server/utils/web/base_controller.py +++ b/server/utils/web/base_controller.py @@ -64,6 +64,10 @@ async def prepare( payload = self.application.jwt_service.decode_access_token(token) if payload and "user_id" in payload: + # Validate token version to check if token is still valid + if not self.application.jwt_service.is_token_version_valid(payload): + raise HTTPError(401, log_message="JWT token version invalid") + user = session.get(User, int(payload["user_id"])) if user: self.current_user = user_schema.dump(user) @@ -95,6 +99,21 @@ async def prepare( else: raise HTTPError(401, log_message="Invalid API key") + # Enforce password change requirement + if self.current_user and self.current_user.get("requires_password_change"): + # Check if the current HTTP method is marked as exempt + method_name = self.request.method.lower() + handler_method = getattr(self, method_name, None) + is_exempt = getattr( + handler_method, "_allow_when_password_required", False + ) + + if not is_exempt: + raise HTTPError( + 403, + log_message="Password change required before accessing this resource", + ) + current_show = await self.application.digi_settings.get("current_show") if current_show: show = session.get(Show, current_show) diff --git a/server/utils/web/jwt_service.py b/server/utils/web/jwt_service.py index 169e510f..db83b779 100644 --- a/server/utils/web/jwt_service.py +++ b/server/utils/web/jwt_service.py @@ -6,6 +6,7 @@ from sqlalchemy import select from models.settings import SystemSettings +from models.user import User class JWTService: @@ -74,7 +75,7 @@ def create_access_token( self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None ) -> str: """ - Create a new JWT access token + Create a new JWT access token with user token_version """ to_encode = data.copy() @@ -83,6 +84,13 @@ def create_access_token( else: expire = datetime.now(tz=timezone.utc) + self._default_expiry + # Add token version if user_id is provided + if "user_id" in data and self.application: + with self.application.get_db().sessionmaker() as session: + user = session.get(User, data["user_id"]) + if user: + to_encode["token_version"] = user.token_version + to_encode.update({"exp": expire, "iat": datetime.now(tz=timezone.utc)}) encoded_jwt = self._jwt.encode( to_encode, self.get_secret(), algorithm=self._jwt_algorithm @@ -105,6 +113,29 @@ def decode_access_token(self, token: str) -> Optional[Dict[str, Any]]: except PyJWTError: return None + def is_token_version_valid(self, payload: Dict[str, Any]) -> bool: + """ + Validate that token version matches current user token version + + :param payload: Decoded JWT payload + :type payload: Dict[str, Any] + :return: True if token version is valid, False otherwise + :rtype: bool + """ + user_id = payload.get("user_id") + token_version = payload.get("token_version") + + if user_id is None or token_version is None: + # Old tokens without version or missing user_id + return False + + with self.application.get_db().sessionmaker() as session: + user = session.get(User, user_id) + if not user: + return False + + return user.token_version == token_version + @staticmethod def get_token_from_authorization_header(auth_header: str) -> Optional[str]: """ diff --git a/server/utils/web/web_decorators.py b/server/utils/web/web_decorators.py index 67b6cd73..bdd9d877 100644 --- a/server/utils/web/web_decorators.py +++ b/server/utils/web/web_decorators.py @@ -53,3 +53,27 @@ def wrapper(self: BaseController, *args, **kwargs) -> Optional[Awaitable[None]]: return method(self, *args, **kwargs) return wrapper + + +def allow_when_password_required( + method: Callable[..., Optional[Awaitable[None]]], +) -> Callable[..., Optional[Awaitable[None]]]: + """ + Decorator to mark a handler method as accessible even when user has requires_password_change=True. + + Apply this to specific HTTP methods (get, post, patch, etc.) that should be accessible + during forced password change, such as change-password and logout endpoints. + + Example: + @allow_when_password_required + async def patch(self): + # Password change logic + """ + + @functools.wraps(method) + def wrapper(self: BaseController, *args, **kwargs) -> Optional[Awaitable[None]]: + return method(self, *args, **kwargs) + + # Mark the wrapper with an attribute so prepare() can detect it + wrapper._allow_when_password_required = True # type: ignore + return wrapper From 5eb7dd97ebd755117d3075e68c89dcbbe53a4e20 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Sun, 11 Jan 2026 20:21:17 +0000 Subject: [PATCH 08/16] Add Prettier config and update ESLint --- .github/workflows/nodelint.yml | 2 +- client/.prettierignore | 16 + client/eslint.config.mjs | 35 +- client/package-lock.json | 127 +++++- client/package.json | 11 +- client/prettier.config.mjs | 40 ++ client/src/App.vue | 138 +++---- client/src/assets/styles/dark.scss | 8 +- client/src/assets/styles/light.scss | 2 +- client/src/constants/textAlignment.js | 2 +- client/src/js/customValidators.js | 4 +- client/src/js/http-interceptor.js | 6 +- client/src/js/micConflictUtils.js | 7 +- client/src/js/micConflictUtils.test.js | 241 +++++++++--- client/src/js/scriptUtils.js | 11 +- client/src/main.js | 71 ++-- client/src/mixins/cueDisplayMixin.js | 2 +- client/src/mixins/passwordValidation.js | 2 +- client/src/mixins/scriptDisplayMixin.js | 34 +- client/src/mixins/scriptNavigationMixin.js | 30 +- client/src/router/index.js | 28 +- client/src/store/modules/help.js | 10 +- client/src/store/modules/script.js | 48 +-- client/src/store/modules/scriptConfig.js | 14 +- client/src/store/modules/show.js | 23 +- client/src/store/modules/user/settings.js | 79 ++-- client/src/store/modules/user/user.js | 28 +- client/src/store/modules/websocket.js | 34 +- client/src/store/store.js | 79 ++-- client/src/views/AboutView.vue | 2 +- client/src/views/HelpView.vue | 26 +- client/src/views/config/ConfigView.vue | 15 +- client/src/views/help/HelpDocView.vue | 40 +- client/src/views/show/ShowConfigView.vue | 52 +-- client/src/views/show/ShowLiveView.vue | 363 +++++++++--------- client/src/views/show/config/ConfigActs.vue | 88 +---- client/src/views/show/config/ConfigCast.vue | 76 +--- .../show/config/ConfigCharacterGroups.vue | 91 ++--- .../views/show/config/ConfigCharacters.vue | 91 ++--- client/src/views/show/config/ConfigCues.vue | 83 +--- client/src/views/show/config/ConfigMics.vue | 51 +-- client/src/views/show/config/ConfigScenes.vue | 182 ++++----- client/src/views/show/config/ConfigScript.vue | 16 +- .../show/config/ConfigScriptRevisions.vue | 35 +- .../src/views/show/config/ConfigSessions.vue | 19 +- client/src/views/show/config/ConfigShow.vue | 71 +--- .../views/user/ForcePasswordChangeView.vue | 58 +-- client/src/views/user/LoginView.vue | 50 +-- client/src/views/user/Settings.vue | 23 +- .../src/vue_components/MarkdownRenderer.vue | 60 +-- .../vue_components/config/ConfigSettings.vue | 39 +- .../vue_components/config/ConfigSystem.vue | 130 ++----- .../src/vue_components/config/ConfigUsers.vue | 48 +-- .../show/config/cast/CastLineStats.vue | 40 +- .../config/characters/CharacterLineStats.vue | 36 +- .../show/config/cues/CueCountStats.vue | 40 +- .../show/config/cues/CueEditor.vue | 105 +++-- .../show/config/cues/JumpToCueModal.vue | 76 +--- .../show/config/cues/ScriptLineCueEditor.vue | 261 ++++++------- .../show/config/mics/MicAllocations.vue | 126 +++--- .../show/config/mics/MicAutoPopulateModal.vue | 137 ++----- .../show/config/mics/MicList.vue | 70 +--- .../show/config/mics/MicTimeline.vue | 84 ++-- .../show/config/mics/ResourceAvailability.vue | 76 +--- .../show/config/mics/SceneDensityHeatmap.vue | 68 +--- .../show/config/script/CompiledScripts.vue | 58 ++- .../config/script/RevisionDetailModal.vue | 41 +- .../show/config/script/RevisionGraph.vue | 108 ++---- .../show/config/script/ScriptEditor.vue | 251 ++++++------ .../show/config/script/ScriptLineEditor.vue | 91 ++--- .../show/config/script/ScriptLineViewer.vue | 106 +++-- .../show/config/script/ScriptRevisions.vue | 129 +++---- .../config/script/StageDirectionStyles.vue | 139 +++---- .../show/config/sessions/SessionList.vue | 35 +- .../config/sessions/SessionTagDropdown.vue | 22 +- .../show/config/sessions/SessionTagList.vue | 69 +--- .../show/live/ScriptLineViewer.vue | 263 ++++++------- .../show/live/ScriptLineViewerCompact.vue | 215 +++++------ client/src/vue_components/user/ConfigRbac.vue | 21 +- client/src/vue_components/user/CreateUser.vue | 18 +- .../src/vue_components/user/RbacResource.vue | 21 +- .../src/vue_components/user/ResetPassword.vue | 74 +--- .../user/settings/AboutUser.vue | 5 +- .../vue_components/user/settings/ApiToken.vue | 73 ++-- .../user/settings/ChangePassword.vue | 20 +- .../user/settings/CueColourPreferences.vue | 79 ++-- .../vue_components/user/settings/Settings.vue | 38 +- .../user/settings/StageDirectionStyles.vue | 162 ++++---- 88 files changed, 2506 insertions(+), 3492 deletions(-) create mode 100644 client/.prettierignore create mode 100644 client/prettier.config.mjs diff --git a/.github/workflows/nodelint.yml b/.github/workflows/nodelint.yml index 1e1e4716..17f4bd11 100644 --- a/.github/workflows/nodelint.yml +++ b/.github/workflows/nodelint.yml @@ -1,4 +1,4 @@ -name: ESLint +name: Lint & Format (ESLint + Prettier) on: [push] diff --git a/client/.prettierignore b/client/.prettierignore new file mode 100644 index 00000000..9a906847 --- /dev/null +++ b/client/.prettierignore @@ -0,0 +1,16 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +../server/static/ + +# Test outputs +coverage/ +junit/ + +# Backups +*.backup + +# Generated files +src/docs/ \ No newline at end of file diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index 023ed3fe..a62fecf1 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -2,6 +2,8 @@ import js from '@eslint/js'; import pluginVue from 'eslint-plugin-vue'; import globals from 'globals'; import babelParser from '@babel/eslint-parser'; +import prettierConfig from 'eslint-config-prettier'; +import prettierPlugin from 'eslint-plugin-prettier'; export default [ { @@ -11,12 +13,16 @@ export default [ '../server/static/**', 'junit/**', '*.backup', + 'src/docs/**', ], }, js.configs.recommended, ...pluginVue.configs['flat/vue2-recommended'], { files: ['**/*.{js,vue}'], + plugins: { + prettier: prettierPlugin, + }, languageOptions: { ecmaVersion: 2021, sourceType: 'module', @@ -37,27 +43,24 @@ export default [ }, }, rules: { + // Prettier integration - runs Prettier as an ESLint rule + 'prettier/prettier': 'error', + + // Disable formatting rules that conflict with Prettier + ...prettierConfig.rules, + + // Let Prettier handle line length (via printWidth config) + 'max-len': 'off', + + // Custom linting rules (non-formatting) 'no-unused-vars': 'off', 'vue/no-unused-vars': 'off', 'no-plusplus': 'off', - 'no-param-reassign': ['error', { - props: true, - ignorePropertyModificationsFor: [ - 'state', - 'acc', - 'e', - ], - }], - 'max-len': [ + 'no-param-reassign': [ 'error', - 150, - 2, { - ignoreUrls: true, - ignoreComments: false, - ignoreRegExpLiterals: true, - ignoreStrings: true, - ignoreTemplateLiterals: true, + props: true, + ignorePropertyModificationsFor: ['state', 'acc', 'e'], }, ], }, diff --git a/client/package-lock.json b/client/package-lock.json index 2e1a31a2..24ab8ddb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -42,9 +42,12 @@ "@vitest/ui": "^4.0.16", "@vue/test-utils": "^2.4.6", "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^9.33.0", "globals": "^15.14.0", "jsdom": "^27.4.0", + "prettier": "^3.7.4", "sass": "1.97.2", "vite": "^7.3.1", "vitest": "^4.0.16" @@ -2933,6 +2936,19 @@ "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -3360,6 +3376,23 @@ "prettier": "^1.18.2 || ^2.0.0" } }, + "node_modules/@types/vuelidate/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/@types/vuelidate/node_modules/vue": { "version": "2.7.16", "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz", @@ -4446,6 +4479,53 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-plugin-vue": { "version": "9.33.0", "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", @@ -4704,6 +4784,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -5824,22 +5911,34 @@ } }, "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "optional": true, "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -6305,6 +6404,22 @@ "dev": true, "license": "MIT" }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/client/package.json b/client/package.json index 2fd369db..bd9eb50e 100644 --- a/client/package.json +++ b/client/package.json @@ -6,9 +6,13 @@ "prebuild": "scripts/copy-docs.sh && node scripts/generate-doc-manifest.js", "build": "vite build", "build:analyze": "vite build --mode analyze", - "lint": "eslint 'src/**/*.{js,vue}' --fix", - "ci-lint": "eslint 'src/**/*.{js,vue}'", + "lint": "npm run format && npm run lint:eslint", + "lint:eslint": "eslint 'src/**/*.{js,vue}' --fix", + "ci-lint": "npm run format:check && npm run lint:eslint-check", + "lint:eslint-check": "eslint 'src/**/*.{js,vue}'", "lint:filter": "./scripts/eslint-filter.sh", + "format": "prettier --write 'src/**/*.{js,vue,json,css,scss}'", + "format:check": "prettier --check 'src/**/*.{js,vue,json,css,scss}'", "test": "vitest", "test:ui": "vitest --ui", "test:run": "vitest run", @@ -54,9 +58,12 @@ "@vitest/ui": "^4.0.16", "@vue/test-utils": "^2.4.6", "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-vue": "^9.33.0", "globals": "^15.14.0", "jsdom": "^27.4.0", + "prettier": "^3.7.4", "sass": "1.97.2", "vite": "^7.3.1", "vitest": "^4.0.16" diff --git a/client/prettier.config.mjs b/client/prettier.config.mjs new file mode 100644 index 00000000..67b1fa21 --- /dev/null +++ b/client/prettier.config.mjs @@ -0,0 +1,40 @@ +/** + * Prettier configuration for DigiScript frontend + * Based on Vue.js community recommendations + * @see https://prettier.io/docs/en/configuration.html + * @see https://vuejs.org/style-guide/ + */ +export default { + // Standard Prettier defaults with Vue community preferences + printWidth: 100, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: true, + quoteProps: 'as-needed', + trailingComma: 'es5', + bracketSpacing: true, + bracketSameLine: false, + arrowParens: 'always', + endOfLine: 'lf', + + // Vue-specific options + vueIndentScriptAndStyle: false, // Don't indent diff --git a/client/src/views/show/config/ConfigActs.vue b/client/src/views/show/config/ConfigActs.vue index 912333a6..bbb8d744 100644 --- a/client/src/views/show/config/ConfigActs.vue +++ b/client/src/views/show/config/ConfigActs.vue @@ -1,8 +1,5 @@