Просмотр исходного кода

Merge pull request #10803 from growilabs/master

Release v7.4.6
mergify[bot] 4 недель назад
Родитель
Сommit
02f2c04489
30 измененных файлов с 569 добавлено и 359 удалено
  1. 0 144
      .claude/agents/security-reviewer.md
  2. 22 0
      .claude/hooks/session-start.sh
  3. 10 0
      .claude/settings.json
  4. 1 2
      AGENTS.md
  5. 0 11
      apps/app/.claude/skills/app-commands/SKILL.md
  6. 1 2
      apps/app/package.json
  7. 27 27
      apps/app/public/static/locales/fr_FR/admin.json
  8. 3 1
      apps/app/public/static/locales/fr_FR/commons.json
  9. 53 52
      apps/app/public/static/locales/fr_FR/translation.json
  10. 2 2
      apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx
  11. 1 1
      apps/app/src/client/components/ForbiddenPage.tsx
  12. 14 10
      apps/app/src/client/components/PageHeader/PageHeader.tsx
  13. 9 0
      apps/app/src/features/openai/server/routes/delete-thread.ts
  14. 1 0
      apps/app/src/features/openai/server/routes/edit/index.ts
  15. 4 2
      apps/app/src/features/openai/server/routes/get-threads.ts
  16. 9 0
      apps/app/src/features/openai/server/routes/message/get-messages.ts
  17. 4 1
      apps/app/src/features/openai/server/routes/message/post-message.ts
  18. 12 2
      apps/app/src/features/openai/server/services/openai.ts
  19. 13 1
      apps/app/src/features/search/client/components/SearchForm.tsx
  20. 9 0
      apps/app/src/features/search/client/interfaces/downshift.ts
  21. 150 0
      apps/app/src/features/search/utils/disable-user-pages.spec.ts
  22. 10 0
      apps/app/src/features/search/utils/disable-user-pages.ts
  23. 1 1
      apps/app/src/server/routes/apiv3/admin-home.ts
  24. 150 0
      apps/app/src/server/service/search-query.spec.ts
  25. 25 21
      apps/app/src/server/service/search.ts
  26. 12 50
      apps/app/src/server/util/runtime-versions.ts
  27. 23 0
      apps/app/src/states/ui/device.ts
  28. 1 1
      apps/slackbot-proxy/package.json
  29. 1 1
      package.json
  30. 1 27
      pnpm-lock.yaml

+ 0 - 144
.claude/agents/security-reviewer.md

@@ -1,144 +0,0 @@
----
-name: security-reviewer
-description: Security vulnerability detection specialist for GROWI. Use after writing code that handles user input, authentication, API endpoints, or sensitive data. Flags secrets, injection, XSS, and OWASP Top 10 vulnerabilities.
-tools: Read, Write, Edit, Bash, Grep, Glob
-model: opus
----
-
-# Security Reviewer
-
-You are a security specialist focused on identifying vulnerabilities in the GROWI codebase. Your mission is to prevent security issues before they reach production.
-
-## GROWI Security Stack
-
-GROWI uses these security measures:
-- **helmet**: Security headers
-- **express-mongo-sanitize**: NoSQL injection prevention
-- **xss**, **rehype-sanitize**: XSS prevention
-- **Passport.js**: Authentication (Local, LDAP, SAML, OAuth)
-
-## Security Review Workflow
-
-### 1. Automated Checks
-```bash
-# Check for vulnerable dependencies
-pnpm audit
-
-# Search for potential secrets
-grep -r "api[_-]?key\|password\|secret\|token" --include="*.ts" --include="*.tsx" .
-```
-
-### 2. OWASP Top 10 Checklist
-
-1. **Injection (NoSQL)** - Are Mongoose queries safe? No string concatenation in queries?
-2. **Broken Authentication** - Passwords hashed? Sessions secure? Passport configured correctly?
-3. **Sensitive Data Exposure** - Secrets in env vars? HTTPS enforced? Logs sanitized?
-4. **Broken Access Control** - Authorization on all routes? CORS configured?
-5. **Security Misconfiguration** - Helmet enabled? Debug mode off in production?
-6. **XSS** - Output escaped? Content-Security-Policy set?
-7. **Components with Vulnerabilities** - `pnpm audit` clean?
-8. **Insufficient Logging** - Security events logged?
-
-## Vulnerability Patterns
-
-### Hardcoded Secrets (CRITICAL)
-```typescript
-// ❌ CRITICAL
-const apiKey = "sk-xxxxx"
-
-// ✅ CORRECT
-const apiKey = process.env.API_KEY
-```
-
-### NoSQL Injection (CRITICAL)
-```typescript
-// ❌ CRITICAL: Unsafe query
-const user = await User.findOne({ email: req.body.email, password: req.body.password })
-
-// ✅ CORRECT: Use express-mongo-sanitize middleware + validate input
-```
-
-### XSS (HIGH)
-```typescript
-// ❌ HIGH: Direct HTML insertion
-element.innerHTML = userInput
-
-// ✅ CORRECT: Use textContent or sanitize
-element.textContent = userInput
-// OR use xss library
-import xss from 'xss'
-element.innerHTML = xss(userInput)
-```
-
-### SSRF (HIGH)
-```typescript
-// ❌ HIGH: User-controlled URL
-const response = await fetch(userProvidedUrl)
-
-// ✅ CORRECT: Validate URL against allowlist
-const allowedDomains = ['api.example.com']
-const url = new URL(userProvidedUrl)
-if (!allowedDomains.includes(url.hostname)) {
-  throw new Error('Invalid URL')
-}
-```
-
-### Authorization Check (CRITICAL)
-```typescript
-// ❌ CRITICAL: No authorization
-app.get('/api/page/:id', async (req, res) => {
-  const page = await Page.findById(req.params.id)
-  res.json(page)
-})
-
-// ✅ CORRECT: Check user access
-app.get('/api/page/:id', loginRequired, async (req, res) => {
-  const page = await Page.findById(req.params.id)
-  if (!page.isAccessibleBy(req.user)) {
-    return res.status(403).json({ error: 'Forbidden' })
-  }
-  res.json(page)
-})
-```
-
-## Security Report Format
-
-```markdown
-## Security Review Summary
-- **Critical Issues:** X
-- **High Issues:** Y
-- **Risk Level:** 🔴 HIGH / 🟡 MEDIUM / 🟢 LOW
-
-### Issues Found
-1. **[SEVERITY]** Description @ `file:line`
-   - Impact: ...
-   - Fix: ...
-```
-
-## When to Review
-
-**ALWAYS review when:**
-- New API endpoints added
-- Authentication/authorization changed
-- User input handling added
-- Database queries modified
-- File upload features added
-- Dependencies updated
-
-## Best Practices
-
-1. **Defense in Depth** - Multiple security layers
-2. **Least Privilege** - Minimum permissions
-3. **Fail Securely** - Errors don't expose data
-4. **Separation of Concerns** - Isolate security-critical code
-5. **Keep it Simple** - Complex code has more vulnerabilities
-6. **Don't Trust Input** - Validate everything
-7. **Update Regularly** - Keep dependencies current
-
-## Emergency Response
-
-If CRITICAL vulnerability found:
-1. Document the issue
-2. Provide secure code fix
-3. Check if vulnerability was exploited
-4. Rotate any exposed secrets

+ 22 - 0
.claude/hooks/session-start.sh

@@ -0,0 +1,22 @@
+#!/bin/bash
+set -euo pipefail
+
+# Only run in remote (Claude Code on the web) environments
+if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
+  exit 0
+fi
+
+cd "$CLAUDE_PROJECT_DIR"
+
+# Install all workspace dependencies.
+# turbo (root devDependency) and all workspace packages will be installed.
+pnpm install
+
+# Install turbo globally (mirrors devcontainer postCreateCommand.sh) so it is
+# available as a bare `turbo` command in subsequent Claude tool calls.
+# Falls back to adding node_modules/.bin to PATH if the pnpm global store is
+# not yet configured in this environment.
+if ! command -v turbo &> /dev/null; then
+  pnpm install turbo --global 2>/dev/null \
+    || echo "export PATH=\"$CLAUDE_PROJECT_DIR/node_modules/.bin:\$PATH\"" >> "$CLAUDE_ENV_FILE"
+fi

+ 10 - 0
.claude/settings.json

@@ -1,5 +1,15 @@
 {
   "hooks": {
+    "SessionStart": [
+      {
+        "hooks": [
+          {
+            "type": "command",
+            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
+          }
+        ]
+      }
+    ],
     "PostToolUse": [
       {
         "matcher": "Write|Edit",

+ 1 - 2
AGENTS.md

@@ -116,8 +116,7 @@ Always execute these checks:
 
 ```bash
 # From workspace root (recommended)
-turbo run lint:typecheck --filter @growi/app
-turbo run lint:biome --filter @growi/app
+turbo run lint --filter @growi/app
 turbo run test --filter @growi/app
 turbo run build --filter @growi/app
 ```

+ 0 - 11
apps/app/.claude/skills/app-commands/SKILL.md

@@ -39,17 +39,6 @@ pnpm run lint:styles      # Stylelint only
 
 > **Running individual test files**: See the `testing` rule (`.claude/rules/testing.md`).
 
-### Common Mistake
-
-```bash
-# ❌ WRONG: lint:typecheck is NOT a Turborepo task
-turbo run lint:typecheck --filter @growi/app
-# Error: could not find task `lint:typecheck` in project
-
-# ✅ CORRECT: Use pnpm for package-specific scripts
-pnpm --filter @growi/app run lint:typecheck
-```
-
 ## Quick Reference
 
 | Task | Command |

+ 1 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.5",
+  "version": "7.4.6-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -109,7 +109,6 @@
     "browser-bunyan": "^1.8.0",
     "bson-objectid": "^2.0.4",
     "bunyan": "^1.8.15",
-    "check-node-version": "^4.2.1",
     "compression": "^1.7.4",
     "connect-flash": "~0.1.1",
     "connect-mongo": "^4.6.0",

+ 27 - 27
apps/app/public/static/locales/fr_FR/admin.json

@@ -15,8 +15,8 @@
     "security_settings": "Sécurité",
     "scope_of_page_disclosure": "Confidentialité de la page",
     "set_point": "Valeur",
-    "Guest Users Access": "Accès invité",
-    "readonly_users_access": "Accès des utilisateurs lecture seule",
+    "Guest Users Access": "Accès public",
+    "readonly_users_access": "Utilisateurs en lecture seule",
     "always_hidden": "Toujours caché",
     "always_displayed": "Toujours affiché",
     "Fixed by env var": "Configuré par la variable d'environnement <code>{{key}}={{value}}</code>.",
@@ -36,25 +36,25 @@
     "page_listing_2_desc": "Voir les pages restreintes au groupe utilisateur lors de la recherche",
     "page_access_rights": "Lecture",
     "page_delete_rights": "Suppression",
-    "page_delete": "Suppression de page",
-    "page_delete_completely": "Suppression complète de page",
-    "comment_manage_rights": "Droits de gestion des commentaires",
-    "other_options": "Paramètres supplémentaires",
-    "deletion_explanation": "Restreindre les utilisateurs pouvant supprimer une page.",
-    "complete_deletion_explanation": "Restreindre les utilisateurs pouvant supprimer complètement une page.",
+    "page_delete": "Corbeille",
+    "page_delete_completely": "Suppression définitive",
+    "comment_manage_rights": "Gestion des commentaires",
+    "other_options": "Autres options",
+    "deletion_explanation": "Restreindre les utilisateurs pouvant mettre à la corbeille une page.",
+    "complete_deletion_explanation": "Restreindre les utilisateurs pouvant supprimer définitivement une page.",
     "recursive_deletion_explain": "Restreindre les utilisateurs pouvant récursivement supprimer une page.",
     "recursive_complete_deletion_explain": "Restreindre les utilisateurs pouvant récursivement supprimer complètement une page.",
     "is_all_group_membership_required_for_page_complete_deletion": "L'utilisateur doit faire partie de tout les groupes ayant l'accès à la page",
     "is_all_group_membership_required_for_page_complete_deletion_explanation": "Effectif lorsque les paramètres de page sont \"Seulement groupes spécifiés\".",
-    "inherit": "Hériter(Utilise le même paramètre que pour une page)",
-    "admin_only": "Administrateur seulement",
+    "inherit": "Hériter",
+    "admin_only": "Administrateur",
     "admin_and_author": "Administrateur et auteur",
     "anyone": "Tout le monde",
     "user_homepage_deletion": {
-      "user_homepage_deletion": "Suppression de page d'accueil utilisateur",
-      "enable_user_homepage_deletion": "Suppression de page d'accueil utilisateur",
+      "user_homepage_deletion": "Page d'utilisateur",
+      "enable_user_homepage_deletion": "Autoriser la suppression",
       "enable_force_delete_user_homepage_on_user_deletion": "Supprimer la page d'accueil et ses pages enfants",
-      "desc": "Les pages d'accueil utilisateurs pourront être supprimées."
+      "desc": "La page d'accueuil d'un utilisateur supprimé sera supprimée."
     },
     "disable_user_pages": {
       "disable_user_pages": "Désactiver les pages utilisateur",
@@ -63,13 +63,13 @@
     },
     "session": "Session",
     "max_age": "Âge maximal (ms)",
-    "max_age_desc": "Spécifie (en milliseconde) l'âge maximal d'une session <br>Par défaut: 2592000000 (30 jours)",
-    "max_age_caution": "Un rédemarrage du serveur est nécessaire lorsque cette valeur est modifiée",
+    "max_age_desc": "L'âge maximal (en millisecondes) d'une session <br>Par défaut: 2592000000 (30 jours)",
+    "max_age_caution": "La modification de cette valeur nécessite un rédemarrage du serveur.",
     "forced_update_desc": "Ce paramètre à été modifié. Valeur précedente: ",
     "page_delete_rights_caution": "Lorsque \"Supprimer / Supprimer récursivement\" est activé, le paramètre is \"Supprimer / Supprimer complètement\" est écrasé. <br> <br> Administrateur seulement > Administrateur et auteur > Tout le monde",
     "Authentication mechanism settings": "Mécanisme d'authentification",
     "setup_is_not_yet_complete": "Configuration incomplète",
-    "xss_prevent_setting": "Prévenir les attaques XSS(Cross Site Scripting)",
+    "xss_prevent_setting": "Prévention des attaques XSS",
     "xss_prevent_setting_link": "Paramètres Markdown",
     "callback_URL": "URL de Callback",
     "providerName": "Nom du fournisseur",
@@ -89,8 +89,8 @@
     "updated_general_security_setting": "Paramètres mis à jour",
     "setup_not_completed_yet": "Configuration incomplète",
     "guest_mode": {
-      "deny": "Refuser (Utilisateurs inscrits seulement)",
-      "readonly": "Autoriser (Lecture seule)"
+      "deny": "Interdit",
+      "readonly": "Lecture seule"
     },
     "read_only_users_comment": {
       "deny": "Ne peut pas commenter",
@@ -298,7 +298,7 @@
     "normalize_description": "Réparer les indices cassés.",
     "rebuild": "Reconstruire",
     "rebuild_button": "Reconstruire",
-    "rebuild_description_1": "Reconstruire l'index est les données de pages",
+    "rebuild_description_1": "Reconstruire l'index et les données de pages",
     "rebuild_description_2": "Cela peut prendre un certain temps."
   },
   "mailer_setup_required": "La <a href='/admin/app'>configuration du SMTP</a> est requise.",
@@ -360,9 +360,9 @@
     "confidential_name": "Nom interne",
     "confidential_example": "ex): usage interne seulement",
     "default_language": "Langue par défaut",
-    "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
-    "default_read_only_for_new_user": "Restriction d'édition pour les nouveaux utilisateurs",
-    "set_read_only_for_new_user": "Rendre les nouveaux utilisateurs en lecture seule",
+    "default_mail_visibility": "Confidentialité de l'adresse courriel",
+    "default_read_only_for_new_user": "Permissions des nouveaux utilisateurs",
+    "set_read_only_for_new_user": "Lecture seule",
     "file_uploading": "Téléversement de fichiers",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "enable_page_bulk_export": "Activer l'exportation groupée",
@@ -433,9 +433,9 @@
     "lineBreak_desc": "Conversion du saut de ligne automatique.",
     "lineBreak_options": {
       "enable_lineBreak": "Saut de ligne",
-      "enable_lineBreak_desc": "Convertir le saut de ligne<code>&lt;br&gt;</code>en HTML",
+      "enable_lineBreak_desc": "Convertir le saut de ligne vers un tag HTML <code>&lt;br&gt;</code>.",
       "enable_lineBreak_for_comment": "Saut de ligne dans les commentaires",
-      "enable_lineBreak_for_comment_desc": "Convertir le saut de ligne dans les commentaires<code>&lt;br&gt;</code>en HTML"
+      "enable_lineBreak_for_comment_desc": "Convertir le saut de ligne dans les commentaires vers un tag HTML <code>&lt;br&gt;</code>."
     },
     "indent_header": "Indentation",
     "indent_desc": "Taille d'indentation dans une page.",
@@ -737,14 +737,14 @@
   },
   "user_management": {
     "user_management": "Utilisateurs",
-    "invite_users": "Nouvel utilisateur temporaire",
+    "invite_users": "Nouvel utilisateur",
     "click_twice_same_checkbox": "Il est nécessaire de sélectionner une option.",
     "status": "Statut",
     "invite_modal": {
       "emails": "Adresse(s) courriel(s) (Supporte l'usage de plusieurs lignes)",
-      "description1": "Créer des utilisateurs temporaires avec une adresse courriel.",
+      "description1": "Créer des utilisateurs avec une adresse courriel.",
       "description2": "Un mot de passe temporaire est généré automatiquement.",
-      "invite_thru_email": "Courriel d'invitation",
+      "invite_thru_email": "Envoyer une invitation",
       "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Paramètres courriel</a>",
       "valid_email": "Adresse courriel valide requise",
       "temporary_password": "Cette utilisateur a un mot de passe temporaire",

+ 3 - 1
apps/app/public/static/locales/fr_FR/commons.json

@@ -1,10 +1,12 @@
 {
   "Show": "Afficher",
+  "View": "Lecture",
+  "Edit": "Écriture",
   "Hide": "Cacher",
   "Add": "Ajouter",
   "Insert": "Insérer",
   "Reset": "Réinitialiser",
-  "Sign out": "Se déconnecter",
+  "Sign out": "Déconnexion",
   "New": "Nouveau",
   "Delete": "Supprimer",
   "meta": {

+ 53 - 52
apps/app/public/static/locales/fr_FR/translation.json

@@ -16,7 +16,7 @@
   "tablet": "Tablette",
   "Click to copy": "Cliquer pour copier",
   "Rename": "Renommer",
-  "Move/Rename": "Déplacer/renommer",
+  "Move/Rename": "Déplacer",
   "Redirected": "Redirigé",
   "Unlinked": "Délié",
   "unlink_redirection": "Délier la redirection",
@@ -39,11 +39,11 @@
   "Category": "Catégorie",
   "User": "Utilisateur",
   "account_id": "Identifiant de compte",
-  "Update": "Mettre à jour",
+  "Update": "Enregistrer",
   "Update Page": "Mettre à jour la page",
   "Error": "Erreur",
   "Warning": "Avertissement",
-  "Sign in": "Se connecter",
+  "Sign in": "Connexion",
   "Sign in with External auth": "Se connecter avec {{signin}}",
   "Sign up is here": "Inscription",
   "Sign in is here": "Connexion",
@@ -61,15 +61,14 @@
   "History": "Historique",
   "attachment_data": "Pièces jointes",
   "No_attachments_yet": "Aucune pièce jointe.",
-  "Presentation Mode": "Mode présentation",
+  "Presentation Mode": "Présentation",
   "Not available for guest": "Indisponible pour les invités",
   "Not available in this version": "Indisponible dans cette version",
   "Not available when \"anyone with the link\" is selected": "Si \"Tous les utilisateurs disposant du lien\" est sélectionné, la portée ne peut pas être modifiée",
-  "No users have liked this yet": "Aucun utilisateur n'a aimé cette page",
   "No users have liked this yet.": "Aucun utilisateur n'a aimé cette page.",
   "No users have bookmarked yet": "Aucun utilisateur n'a mis en favoris cette page",
   "Create Archive Page": "Créer page d'archive",
-  "Create Sidebar Page": "Créer page <strong>/Sidebar</strong>",
+  "Create Sidebar Page": "Créer la page <strong>/Sidebar</strong>",
   "File type": "Type de fichier",
   "Target page": "Page cible",
   "Include Attachment File": "Inclure le fichier de pièces jointes",
@@ -87,7 +86,7 @@
   "Create/Edit Template": "Créer/Modifier page modèle",
   "Go to this version": "Voir cette version",
   "View diff": "Voir le diff",
-  "No diff": "Aucune différences",
+  "No diff": "Aucune différence",
   "Latest": "Dernière version",
   "User ID": "Identifiant utilisateur",
   "User Settings": "Paramètres utilisateur",
@@ -123,6 +122,8 @@
   "UserGroup": "Groupe utilisateur",
   "Basic Settings": "Paramètres de base",
   "The contents entered here will be shown in the header etc": "Le contenu entré ici sera visible dans l'en-tête",
+  "Browsing of this page is restricted": "Le contenu de cette page est restreint.",
+  "Forbidden": "Accès interdit",
   "Public": "Tout le monde",
   "Anyone with the link": "Tous les utilisateurs disposant du lien",
   "Specified users only": "Utilisateurs spécifiés",
@@ -230,7 +231,7 @@
     "notice": {
       "apitoken_issued": "Aucun jeton d'API existant.",
       "update_token1": "Un nouveau jeton peut être généré.",
-      "update_token2": "Modifiez le jeton aux endroits où celui-ci est utilisé."
+      "update_token2": "Modifiez le jeton aux endroits où celui-ci est utilisé, car l'ancien sera invalide."
     },
     "form_help": {}
   },
@@ -295,17 +296,17 @@
   "Other Settings": "Autres paramètres",
   "in_app_notification_settings": {
     "in_app_notification_settings": "Notifications",
-    "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",
+    "subscribe_settings": "Notifications d'application",
     "default_subscribe_rules": {
-      "page_create": "S'abonner aux modifications d'une page lors de sa création."
+      "page_create": "Abonne systématiquement aux notifications de modification d'une page lors de sa création."
     }
   },
   "ui_settings": {
     "ui_settings": "Interface",
     "side_bar_mode": {
       "settings": "Paramètres navigation latérale",
-      "side_bar_mode_setting": "Épingler la navigation latérale",
-      "description": "Activer pour toujours afficher la barre de navigation latérale lorsque l'écran est large. Si la largeur d'écran est faible, le cas inverse est applicable."
+      "side_bar_mode_setting": "Épingler la barre latérale",
+      "description": "Épingle sur l'écran la barre de navigation latérale. Si la résolution de l'écran est trop faible, la barre latérale ne sera plus épinglée."
     }
   },
   "color_mode_settings": {
@@ -368,7 +369,7 @@
     "keymap": "Raccourcis",
     "indent": "Indentation",
     "paste": {
-      "title": "Comportement du collage",
+      "title": "Collage",
       "both": "Texte et fichier",
       "text": "Texte seulement",
       "file": "Fichier seulement"
@@ -377,7 +378,7 @@
     "editor_assistant": "Assistant d'édition",
     "Show active line": "Surligner la ligne active",
     "auto_format_table": "Formatter les tableaux",
-    "overwrite_scopes": "{{operation}} et écraser les scopes des pages enfants",
+    "overwrite_scopes": "{{operation}} et écraser les permissions des pages enfants",
     "notice": {
       "conflict": "Sauvegarde impossible, la page est en cours de modification par un autre utilisateur. Recharger la page."
     },
@@ -385,10 +386,10 @@
   },
   "page_comment": {
     "comments": "Commentaires",
-    "comment": "Commmenter",
-    "preview": "Prévisualiser",
-    "write": "Écrire",
-    "add_a_comment": "Ajouter un commentaire",
+    "comment": "Cer",
+    "preview": "Visualiser",
+    "write": "Rédaction",
+    "add_a_comment": "Nouveau commentaire",
     "display_the_page_when_posting_this_comment": "Afficher la page en postant le commentaire",
     "no_user_found": "Aucun utilisateur trouvé",
     "reply": "Répondre",
@@ -408,22 +409,22 @@
     "revision": "Révision",
     "comparing_source": "Source",
     "comparing_target": "Cible",
-    "comparing_revisions": "Comparer les modifications",
+    "comparing_revisions": "Historique des modifications",
     "compare_latest": "Comparer avec la version la plus récente",
     "compare_previous": "Comparer avec la version précédente"
   },
   "modal_rename": {
     "label": {
-      "Move/Rename page": "Déplacer/renommer page",
-      "New page name": "Nouveau chemin",
+      "Move/Rename page": "Déplacer ou renommer la page",
+      "New page name": "Nouvel emplacement",
       "Failed to get subordinated pages": "échec de récupération des pages subordinnées",
       "Failed to get exist path": "échec de la récupération du chemin",
-      "Current page name": "Chemin actuel",
+      "Current page name": "Emplacement actuel",
       "Rename this page only": "Renommer cette page",
       "Force rename all child pages": "Forcer le renommage des pages",
       "Other options": "Autres options",
-      "Do not update metadata": "Ne pas modifier les métadonnées",
-      "Redirect": "Redirection automatique"
+      "Do not update metadata": "Conserver les métadonnées",
+      "Redirect": "Redirection"
     },
     "help": {
       "redirect": "Redirige automatiquement vers le nouveau chemin de la page.",
@@ -456,18 +457,18 @@
   },
   "modal_duplicate": {
     "label": {
-      "Duplicate page": "Dupliquer",
-      "New page name": "Nom de la page",
+      "Duplicate page": "Dupliquer la page",
+      "New page name": "Emplacement de la nouvelle page",
       "Failed to get subordinated pages": "échec de récupération des pages subordinnées",
-      "Current page name": "Nom de la page courante",
-      "Recursively": "Récursivement",
+      "Current page name": "Emplacement actuel",
+      "Recursively": "Récursif",
       "Duplicate without exist path": "Dupliquer sans le chemin existant",
       "Same page already exists": "Une page avec ce chemin existe déjà",
-      "Only duplicate user related pages": "Seul les pages dupliquées auquelles vous avez accès"
+      "Only duplicate user related pages": "Hériter les permissions"
     },
     "help": {
-      "recursive": "Dupliquer les pages enfants récursivement",
-      "only_inherit_user_related_groups": "Si la page est configuré en \"Seulement dans le groupe\", les groupes auxquels vous n'appartenez pas perdront l'accès aux pages dupliquées"
+      "recursive": "Duplique également les pages enfants.",
+      "only_inherit_user_related_groups": "Les pages restreintes a un groupe hériteront des permissions assignées."
     }
   },
   "duplicated_pages": "{{fromPath}} dupliquée",
@@ -704,20 +705,20 @@
   "template": {
     "modal_label": {
       "Select template": "Sélectionner modèle",
-      "Create/Edit Template Page": "Créer/modifier page modèle",
+      "Create/Edit Template Page": "Créer un modèle",
       "Create template under": "Créer une page modèle enfant"
     },
     "option_label": {
-      "create/edit": "Créer/modifier page modèle",
+      "create/edit": "Modèle",
       "select": "Sélectionner type de page modèle"
     },
     "children": {
       "label": "Modèle pour page enfant",
-      "desc": "Applicable aux pages de même niveau que la page modèle"
+      "desc": "Le modèle est appliqué aux pages du même niveau dans l'arbre."
     },
-    "decendants": {
+    "descendants": {
       "label": "Modèle pour descendants",
-      "desc": "Applicable aux page descendantes"
+      "desc": "Le modèle est appliqué à toutes les pages enfants."
     }
   },
   "sandbox": {
@@ -836,14 +837,14 @@
   "page_export": {
     "failed_to_export": "Échec de l'export",
     "failed_to_count_pages": "Échec du compte des pages",
-    "export_page_markdown": "Exporter la page en Markdown",
+    "export_page_markdown": "Exporter",
     "export_page_pdf": "Exporter la page en PDF",
-    "bulk_export": "Exporter la page et toutes les pages enfants",
+    "bulk_export": "Exporter les pages enfants",
     "bulk_export_download_explanation": "Une notification sera envoyée lorsque l’exportation sera terminée. Pour télécharger le fichier exporté, cliquez sur la notification.",
     "bulk_export_exec_time_warning": "Si le nombre de pages est important, l'exportation peut prendre un certain temps.",
-    "large_bulk_export_warning": "Pour préserver les ressources du système, veuillez éviter d'exporter un grand nombre de pages consécutivement",
+    "large_bulk_export_warning": "Pour préserver les ressources du système, veuillez éviter d'exporter un grand nombre de pages consécutivement.",
     "markdown": "Markdown",
-    "choose_export_format": "Sélectionnez le format d'exportation",
+    "choose_export_format": "Format de l'export",
     "bulk_export_started": "Patientez s'il-vous-plait...",
     "bulk_export_download_expired": "La période de téléchargement a expiré",
     "bulk_export_job_expired": "Le traitement a été interrompu car le temps d'exportation était trop long",
@@ -978,7 +979,7 @@
     }
   },
   "tooltip": {
-    "like": "Like!",
+    "like": "Aimer",
     "cancel_like": "Annuler",
     "bookmark": "Ajouter aux favoris",
     "cancel_bookmark": "Retirer des favoris",
@@ -998,8 +999,8 @@
   },
   "user_home_page": {
     "bookmarks": "Favoris",
-    "recently_created": "Page récentes",
-    "recent_activity": "Activité récente",
+    "recently_created": "Pages récentes",
+    "recent_activity": "Modifications récentes",
     "unknown_action": "a effectué une modification non spécifiée",
     "page_create": "a créé une page",
     "page_update": "a mis à jour une page",
@@ -1034,7 +1035,7 @@
   },
   "tag_edit_modal": {
     "edit_tags": "Étiquettes",
-    "done": "Mettre à jour",
+    "done": "Assigner",
     "tags_input": {
       "tag_name": "Choisir ou créer une étiquette"
     }
@@ -1049,24 +1050,24 @@
     "select_page_location": "Sélectionner emplacement de la page"
   },
   "wip_page": {
-    "save_as_wip": "Sauvegarder comme brouillon",
+    "save_as_wip": "Enregistrer comme brouillon",
     "success_save_as_wip": "Sauvegardée en tant que brouillon",
     "fail_save_as_wip": "Échec de la sauvegarde du brouillon",
-    "alert": "Page en cours d'écriture",
-    "publish_page": "Publier page",
+    "alert": "Page en cours d'écriture.",
+    "publish_page": "Publier",
     "success_publish_page": "Page publiée",
     "fail_publish_page": "Échec de publication de la page"
   },
   "sidebar_header": {
-    "show_wip_page": "Voir brouillon",
+    "show_wip_page": "Inclure les brouillons",
     "compact_view": "Vue compacte"
   },
   "sync-latest-revision-body": {
     "menuitem": "Synchroniser avec la dernière révision",
-    "confirm": "Supprime les données en brouillon et synchronise avec la dernière révision. Synchroniser?",
-    "alert": "Il se peut que le texte le plus récent n'ait pas été synchronisé. Veuillez recharger et vérifier à nouveau.",
-    "success-toaster": "Dernier texte synchronisé",
-    "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
+    "confirm": "Le contenu non enregistré sera écrasé. Synchroniser?",
+    "alert": "Certaines modifications n'ont pas été enregistrées. Veuillez rafraîchir votre onglet de navigateur.",
+    "success-toaster": "Dernière révision synchronisée",
+    "skipped-toaster": "Le mode édition doit être activé pour déclencher la synchronisation. Synchronisation annulée.",
     "error-toaster": "Synchronisation échouée"
   }
 }

+ 2 - 2
apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx

@@ -303,7 +303,7 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
                 onChange={() => onChangeTriggerEvents(TriggerEventType.EDIT)}
               >
                 <span className="badge rounded-pill bg-warning text-dark">
-                  <span className="imaterial-symbols-outlined">edit</span> EDIT
+                  <span className="material-symbols-outlined">edit</span> EDIT
                 </span>
               </TriggerEventCheckBox>
             </div>
@@ -314,7 +314,7 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
                 checked={triggerEvents.has(TriggerEventType.MOVE)}
                 onChange={() => onChangeTriggerEvents(TriggerEventType.MOVE)}
               >
-                <span className="badge rounded-pill bg-pink">
+                <span className="badge rounded-pill bg-secondary">
                   <span className="material-symbols-outlined">redo</span>MOVE
                 </span>
               </TriggerEventCheckBox>

+ 1 - 1
apps/app/src/client/components/ForbiddenPage.tsx

@@ -16,7 +16,7 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
             <span className="material-symbols-outlined" aria-hidden="true">
               block
             </span>
-            Forbidden
+            {t('Forbidden')}
           </h2>
         </div>
       </div>

+ 14 - 10
apps/app/src/client/components/PageHeader/PageHeader.tsx

@@ -1,6 +1,7 @@
 import { type JSX, useCallback, useEffect, useRef, useState } from 'react';
 
 import { useCurrentPageData } from '~/states/page';
+import { useDeviceLargerThanSm } from '~/states/ui/device';
 import { usePageControlsX } from '~/states/ui/page';
 
 import { PagePathHeader } from './PagePathHeader';
@@ -13,23 +14,26 @@ const moduleClass = styles['page-header'] ?? '';
 export const PageHeader = (): JSX.Element => {
   const currentPage = useCurrentPageData();
   const pageControlsX = usePageControlsX();
+  const [isLargerThanSm] = useDeviceLargerThanSm();
   const pageHeaderRef = useRef<HTMLDivElement>(null);
 
-  const [maxWidth, setMaxWidth] = useState<number>();
+  const [maxWidth, setMaxWidth] = useState<number>(300);
 
   const calcMaxWidth = useCallback(() => {
-    if (pageControlsX == null || pageHeaderRef.current == null) {
-      // Length that allows users to use PageHeader functionality.
-      setMaxWidth(300);
+    if (pageHeaderRef.current == null) {
       return;
     }
 
-    // PageControls.x - PageHeader.x
-    const maxWidth =
-      pageControlsX - pageHeaderRef.current.getBoundingClientRect().x;
-
-    setMaxWidth(maxWidth);
-  }, [pageControlsX]);
+    const pageHeaderX = pageHeaderRef.current.getBoundingClientRect().x;
+    setMaxWidth(
+      !isLargerThanSm
+        ? window.innerWidth - pageHeaderX
+        : pageControlsX != null
+          ? pageControlsX - pageHeaderX
+          : // Length that allows users to use PageHeader functionality.
+            300,
+    );
+  }, [isLargerThanSm, pageControlsX]);
 
   useEffect(() => {
     calcMaxWidth();

+ 9 - 0
apps/app/src/features/openai/server/routes/delete-thread.ts

@@ -14,6 +14,7 @@ import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-respo
 import loggerFactory from '~/utils/logger';
 
 import type { IApiv3DeleteThreadParams } from '../../interfaces/thread-relation';
+import ThreadRelationModel from '../models/thread-relation';
 import { getOpenaiService } from '../services/openai';
 import { certifyAiService } from './middlewares/certify-ai-service';
 
@@ -68,6 +69,14 @@ export const deleteThreadFactory = (crowi: Crowi): RequestHandler[] => {
       }
 
       try {
+        const threadRelation = await ThreadRelationModel.findOne({
+          _id: threadRelationId,
+          userId: user._id,
+        });
+        if (threadRelation == null) {
+          return res.apiv3Err(new ErrorV3('Thread not found'), 404);
+        }
+
         const deletedThreadRelation =
           await openaiService.deleteThread(threadRelationId);
         return res.apiv3({ deletedThreadRelation });

+ 1 - 0
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -246,6 +246,7 @@ export const postMessageToEditHandlersFactory = (
 
       const threadRelation = await ThreadRelationModel.findOne({
         threadId: { $eq: threadId },
+        userId: user._id,
       });
       if (threadRelation == null) {
         return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);

+ 4 - 2
apps/app/src/features/openai/server/routes/get-threads.ts

@@ -70,8 +70,10 @@ export const getThreadsFactory = (crowi: Crowi): RequestHandler[] => {
           );
         }
 
-        const threads =
-          await openaiService.getThreadsByAiAssistantId(aiAssistantId);
+        const threads = await openaiService.getThreadsByAiAssistantId(
+          aiAssistantId,
+          user._id,
+        );
 
         return res.apiv3({ threads });
       } catch (err) {

+ 9 - 0
apps/app/src/features/openai/server/routes/message/get-messages.ts

@@ -12,6 +12,7 @@ import loginRequiredFactory from '~/server/middlewares/login-required';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
+import ThreadRelationModel from '../../models/thread-relation';
 import { getOpenaiService } from '../../services/openai';
 import { certifyAiService } from '../middlewares/certify-ai-service';
 
@@ -81,6 +82,14 @@ export const getMessagesFactory = (crowi: Crowi): RequestHandler[] => {
           );
         }
 
+        const threadRelation = await ThreadRelationModel.findOne({
+          threadId: { $eq: threadId },
+          userId: user._id,
+        });
+        if (threadRelation == null) {
+          return res.apiv3Err(new ErrorV3('Thread not found'), 404);
+        }
+
         const messages = await openaiService.getMessageData(
           threadId,
           user.lang,

+ 4 - 1
apps/app/src/features/openai/server/routes/message/post-message.ts

@@ -128,7 +128,10 @@ export const postMessageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
         return res.apiv3Err(new ErrorV3('AI assistant not found'), 404);
       }
 
-      const threadRelation = await ThreadRelationModel.findOne({ threadId });
+      const threadRelation = await ThreadRelationModel.findOne({
+        threadId: { $eq: threadId },
+        userId: user._id,
+      });
       if (threadRelation == null) {
         return res.apiv3Err(new ErrorV3('ThreadRelation not found'), 404);
       }

+ 12 - 2
apps/app/src/features/openai/server/services/openai.ts

@@ -98,6 +98,7 @@ export interface IOpenaiService {
   ): Promise<ThreadRelationDocument>;
   getThreadsByAiAssistantId(
     aiAssistantId: string,
+    userId?: string,
   ): Promise<ThreadRelationDocument[]>;
   deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
   deleteExpiredThreads(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
@@ -290,12 +291,21 @@ class OpenaiService implements IOpenaiService {
 
   async getThreadsByAiAssistantId(
     aiAssistantId: string,
+    userId?: string,
     type: ThreadType = ThreadType.KNOWLEDGE,
   ): Promise<ThreadRelationDocument[]> {
-    const threadRelations = await ThreadRelationModel.find({
+    const query: { aiAssistant: string; type: ThreadType; userId?: string } = {
       aiAssistant: aiAssistantId,
       type,
-    }).sort({ updatedAt: -1 });
+    };
+
+    if (userId != null) {
+      query.userId = userId;
+    }
+
+    const threadRelations = await ThreadRelationModel.find(query).sort({
+      updatedAt: -1,
+    });
     return threadRelations;
   }
 

+ 13 - 1
apps/app/src/features/search/client/components/SearchForm.tsx

@@ -41,6 +41,17 @@ export const SearchForm = (props: Props): JSX.Element => {
     [searchKeyword, onSubmit],
   );
 
+  // Prevent Downshift from intercepting Home/End keys so they move
+  // the cursor within the input field instead of navigating the list
+  const keyDownHandler = useCallback(
+    (e: React.KeyboardEvent<HTMLInputElement>) => {
+      if (e.key === 'Home' || e.key === 'End') {
+        e.nativeEvent.preventDownshiftDefault = true;
+      }
+    },
+    [],
+  );
+
   const inputOptions = useMemo(() => {
     return getInputProps({
       type: 'text',
@@ -49,8 +60,9 @@ export const SearchForm = (props: Props): JSX.Element => {
       ref: inputRef,
       value: searchKeyword,
       onChange: changeSearchTextHandler,
+      onKeyDown: keyDownHandler,
     });
-  }, [getInputProps, searchKeyword, changeSearchTextHandler]);
+  }, [getInputProps, searchKeyword, changeSearchTextHandler, keyDownHandler]);
 
   useEffect(() => {
     if (inputRef.current != null) {

+ 9 - 0
apps/app/src/features/search/client/interfaces/downshift.ts

@@ -1,5 +1,14 @@
 import type { ControllerStateAndHelpers } from 'downshift';
 
+// Augment the global Event interface with downshift's custom property.
+// downshift checks event.nativeEvent.preventDownshiftDefault to skip
+// its default key handling. See: https://www.downshift-js.com/downshift#customizing-handlers
+declare global {
+  interface Event {
+    preventDownshiftDefault?: boolean;
+  }
+}
+
 export type DownshiftItem = { url: string };
 
 export type GetItemProps =

+ 150 - 0
apps/app/src/features/search/utils/disable-user-pages.spec.ts

@@ -0,0 +1,150 @@
+import type { QueryTerms } from '~/server/interfaces/search';
+
+import { excludeUserPagesFromQuery } from './disable-user-pages';
+
+describe('excludeUserPagesFromQuery()', () => {
+  it('should exclude user page strings from query prefix', () => {
+    const userString = '/user';
+    const userStringSlash = '/user/';
+    const userStringSubPath = '/user/settings';
+    const userStringSubPathDeep = '/user/profile/edit';
+    const userStringSubPathQuery = '/user/settings?ref=top';
+
+    const query: QueryTerms = {
+      match: [],
+      not_match: [],
+      phrase: [],
+      not_phrase: [],
+      prefix: [
+        userString,
+        userStringSlash,
+        userStringSubPath,
+        userStringSubPathDeep,
+        userStringSubPathQuery,
+      ],
+      not_prefix: [],
+      tag: [],
+      not_tag: [],
+    };
+
+    excludeUserPagesFromQuery(query);
+
+    expect(query.prefix).not.toContain(userString);
+    // Should only contain '/user'
+    expect(query.not_prefix).toContain(userString);
+
+    expect(query.prefix).not.toContain(userStringSlash);
+    expect(query.not_prefix).not.toContain(userStringSlash);
+
+    expect(query.prefix).not.toContain(userStringSubPath);
+    expect(query.not_prefix).not.toContain(userStringSubPath);
+
+    expect(query.prefix).not.toContain(userStringSubPathDeep);
+    expect(query.not_prefix).not.toContain(userStringSubPathDeep);
+
+    expect(query.prefix).not.toContain(userStringSubPathQuery);
+    expect(query.not_prefix).not.toContain(userStringSubPathQuery);
+  });
+
+  it('should not exclude strings similar to /user from query prefix', () => {
+    const userRouter = '/userouter';
+    const userRoot = '/useroot';
+    const userSettings = '/user-settings';
+    const apiUser = '/api/user';
+    const emptyString = '';
+    const rootOnly = '/';
+    const upperCase = '/USER';
+    const doubleSlashStart = '//user/new';
+    const doubleSlashSub = '/user//new';
+
+    const query: QueryTerms = {
+      match: [],
+      not_match: [],
+      phrase: [],
+      not_phrase: [],
+      prefix: [
+        userRouter,
+        userRoot,
+        userSettings,
+        apiUser,
+        emptyString,
+        rootOnly,
+        upperCase,
+        doubleSlashStart,
+        doubleSlashSub,
+      ],
+      not_prefix: [],
+      tag: [],
+      not_tag: [],
+    };
+
+    excludeUserPagesFromQuery(query);
+
+    expect(query.prefix).toContain(userRouter);
+    expect(query.not_prefix).not.toContain(userRouter);
+
+    expect(query.prefix).toContain(userRoot);
+    expect(query.not_prefix).not.toContain(userRoot);
+
+    expect(query.prefix).toContain(userSettings);
+    expect(query.not_prefix).not.toContain(userSettings);
+
+    expect(query.prefix).toContain(apiUser);
+    expect(query.not_prefix).not.toContain(apiUser);
+
+    expect(query.prefix).toContain(emptyString);
+    expect(query.not_prefix).not.toContain(emptyString);
+
+    expect(query.prefix).toContain(rootOnly);
+    expect(query.not_prefix).not.toContain(rootOnly);
+
+    expect(query.prefix).toContain(upperCase);
+    expect(query.not_prefix).not.toContain(upperCase);
+
+    expect(query.prefix).toContain(doubleSlashStart);
+    expect(query.not_prefix).not.toContain(doubleSlashStart);
+
+    expect(query.prefix).toContain(doubleSlashSub);
+    expect(query.not_prefix).not.toContain(doubleSlashSub);
+  });
+
+  it('should add /user to not_prefix when it is empty', () => {
+    const query: QueryTerms = {
+      match: [],
+      not_match: [],
+      phrase: [],
+      not_phrase: [],
+      prefix: [],
+      not_prefix: [],
+      tag: [],
+      not_tag: [],
+    };
+
+    excludeUserPagesFromQuery(query);
+
+    expect(query.prefix).toHaveLength(0);
+    expect(query.not_prefix).toContain('/user');
+    expect(query.not_prefix).toHaveLength(1);
+  });
+
+  it('should remove existing /user strings and leave not_prefix with just one /user string', () => {
+    const userString = '/user';
+
+    const query: QueryTerms = {
+      match: [],
+      not_match: [],
+      phrase: [],
+      not_phrase: [],
+      prefix: [userString, userString],
+      not_prefix: [userString, userString],
+      tag: [],
+      not_tag: [],
+    };
+
+    excludeUserPagesFromQuery(query);
+
+    expect(query.prefix).toHaveLength(0);
+    expect(query.not_prefix).toContain('/user');
+    expect(query.not_prefix).toHaveLength(1);
+  });
+});

+ 10 - 0
apps/app/src/features/search/utils/disable-user-pages.ts

@@ -0,0 +1,10 @@
+import type { QueryTerms } from '~/server/interfaces/search';
+
+export function excludeUserPagesFromQuery(terms: QueryTerms): void {
+  const userRegex: RegExp = /^\/user($|\/(?!\/))/;
+
+  terms.prefix = terms.prefix.filter((p) => !userRegex.test(p));
+  terms.not_prefix = terms.not_prefix.filter((p) => !userRegex.test(p));
+
+  terms.not_prefix.push('/user');
+}

+ 1 - 1
apps/app/src/server/routes/apiv3/admin-home.ts

@@ -96,7 +96,7 @@ module.exports = (crowi: Crowi) => {
       const { getRuntimeVersions } = await import(
         '~/server/util/runtime-versions'
       );
-      const runtimeVersions = await getRuntimeVersions();
+      const runtimeVersions = getRuntimeVersions();
 
       const adminHomeParams: IResAdminHome = {
         growiVersion: getGrowiVersion(),

+ 150 - 0
apps/app/src/server/service/search-query.spec.ts

@@ -0,0 +1,150 @@
+import { vi } from 'vitest';
+import { type MockProxy, mock } from 'vitest-mock-extended';
+
+import { SearchDelegatorName } from '~/interfaces/named-query';
+import type Crowi from '~/server/crowi';
+import { configManager } from '~/server/service/config-manager/config-manager';
+
+import type { SearchDelegator } from '../interfaces/search';
+import NamedQuery from '../models/named-query';
+import SearchService from './search';
+import type ElasticsearchDelegator from './search-delegator/elasticsearch';
+
+// Mock NamedQuery
+vi.mock('~/server/models/named-query', () => {
+  const mockModel = {
+    findOne: vi.fn(),
+  };
+  return {
+    NamedQuery: mockModel,
+    default: mockModel,
+  };
+});
+
+// Mock config manager
+vi.mock('~/server/service/config-manager/config-manager', () => {
+  return {
+    default: {
+      getConfig: vi.fn(),
+    },
+    configManager: {
+      getConfig: vi.fn(),
+    },
+  };
+});
+
+class TestSearchService extends SearchService {
+  override generateFullTextSearchDelegator(): ElasticsearchDelegator {
+    return mock<ElasticsearchDelegator>();
+  }
+
+  override generateNQDelegators(): {
+    [key in SearchDelegatorName]: SearchDelegator;
+  } {
+    return {
+      [SearchDelegatorName.DEFAULT]: mock<SearchDelegator>(),
+      [SearchDelegatorName.PRIVATE_LEGACY_PAGES]: mock<SearchDelegator>(),
+    };
+  }
+
+  override registerUpdateEvent(): void {}
+
+  override get isConfigured(): boolean {
+    return false;
+  }
+}
+
+describe('searchParseQuery()', () => {
+  let searchService: TestSearchService;
+  let mockCrowi: MockProxy<Crowi>;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    mockCrowi = mock<Crowi>();
+    mockCrowi.configManager = configManager;
+    searchService = new TestSearchService(mockCrowi);
+  });
+
+  it('should contain /user in the not_prefix query when user pages are disabled', async () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string) => {
+      if (key === 'security:disableUserPages') {
+        return true;
+      }
+
+      return false;
+    });
+
+    const result = await searchService.parseSearchQuery('/user/settings', null);
+
+    expect(configManager.getConfig).toHaveBeenCalledWith(
+      'security:disableUserPages',
+    );
+    expect(result.terms.not_prefix).toContain('/user');
+    expect(result.terms.prefix).toHaveLength(0);
+  });
+
+  it('should contain /user in the not_prefix even when search query is not a user page', async () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string) => {
+      if (key === 'security:disableUserPages') {
+        return true;
+      }
+
+      return false;
+    });
+
+    const result = await searchService.parseSearchQuery('/new-task', null);
+
+    expect(configManager.getConfig).toHaveBeenCalledWith(
+      'security:disableUserPages',
+    );
+    expect(result.terms.not_prefix).toContain('/user');
+    expect(result.terms.prefix).toHaveLength(0);
+  });
+
+  it('should add specific user prefixes in the query when user pages are enabled', async () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string) => {
+      if (key === 'security:disableUserPages') {
+        return false;
+      }
+
+      return true;
+    });
+
+    const result = await searchService.parseSearchQuery('/user/settings', null);
+
+    expect(configManager.getConfig).toHaveBeenCalledWith(
+      'security:disableUserPages',
+    );
+    expect(result.terms.not_prefix).not.toContain('/user');
+    expect(result.terms.not_prefix).not.toContain('/user/settings');
+    expect(result.terms.match).toContain('/user/settings');
+  });
+
+  it('should filter user pages even when resolved from a named query alias', async () => {
+    vi.mocked(configManager.getConfig).mockImplementation((key: string) => {
+      if (key === 'security:disableUserPages') {
+        return true;
+      }
+
+      return false;
+    });
+
+    const shortcutName = 'my-shortcut';
+    const aliasPath = '/user/my-private-page';
+
+    // Mock the DB response
+    vi.mocked(NamedQuery.findOne).mockResolvedValue({
+      name: shortcutName,
+      aliasOf: aliasPath,
+    });
+
+    const result = await searchService.parseSearchQuery('dummy', shortcutName);
+
+    expect(configManager.getConfig).toHaveBeenCalledWith(
+      'security:disableUserPages',
+    );
+    expect(result.terms.not_prefix).toContain('/user');
+    expect(result.terms.match).toContain('/user/my-private-page');
+  });
+});

+ 25 - 21
apps/app/src/server/service/search.ts

@@ -8,6 +8,7 @@ import {
   isIncludeAiMenthion,
   removeAiMenthion,
 } from '~/features/search/utils/ai';
+import { excludeUserPagesFromQuery } from '~/features/search/utils/disable-user-pages';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type {
   IFormattedSearchResult,
@@ -328,34 +329,37 @@ class SearchService implements SearchQueryParser, SearchResolver {
     _queryString: string,
     nqName: string | null,
   ): Promise<ParsedQuery> {
+    const disableUserPages = configManager.getConfig(
+      'security:disableUserPages',
+    );
     const queryString = normalizeQueryString(_queryString);
-
     const terms = this.parseQueryString(queryString);
 
-    if (nqName == null) {
-      return { queryString, terms };
-    }
+    let parsedQuery: ParsedQuery = { queryString, terms };
 
-    const nq = await NamedQuery.findOne({ name: normalizeNQName(nqName) });
+    if (nqName != null) {
+      const nq = await NamedQuery.findOne({ name: normalizeNQName(nqName) });
 
-    // will delegate to full-text search
-    if (nq == null) {
-      logger.debug(
-        `Delegated to full-text search since a named query document did not found. (nqName="${nqName}")`,
-      );
-      return { queryString, terms };
-    }
+      if (nq != null) {
+        const { aliasOf, delegatorName } = nq;
 
-    const { aliasOf, delegatorName } = nq;
+        if (aliasOf != null) {
+          parsedQuery = {
+            queryString: normalizeQueryString(aliasOf),
+            terms: this.parseQueryString(aliasOf),
+          };
+        } else {
+          parsedQuery = { queryString, terms, delegatorName };
+        }
+      } else {
+        logger.debug(
+          `Delegated to full-text search since a named query document did not found. (nqName="${nqName}")`,
+        );
+      }
+    }
 
-    let parsedQuery: ParsedQuery;
-    if (aliasOf != null) {
-      parsedQuery = {
-        queryString: normalizeQueryString(aliasOf),
-        terms: this.parseQueryString(aliasOf),
-      };
-    } else {
-      parsedQuery = { queryString, terms, delegatorName };
+    if (disableUserPages) {
+      excludeUserPagesFromQuery(parsedQuery.terms);
     }
 
     return parsedQuery;

+ 12 - 50
apps/app/src/server/util/runtime-versions.ts

@@ -1,4 +1,4 @@
-import checkNodeVersion from 'check-node-version';
+import { execSync } from 'node:child_process';
 
 type RuntimeVersions = {
   node: string | undefined;
@@ -6,56 +6,18 @@ type RuntimeVersions = {
   pnpm: string | undefined;
 };
 
-// define original types because the object returned is not according to the official type definition
-type SatisfiedVersionInfo = {
-  isSatisfied: true;
-  version: {
-    version: string;
-  };
-};
-
-type NotfoundVersionInfo = {
-  isSatisfied: true;
-  notfound: true;
-};
-
-type VersionInfo = SatisfiedVersionInfo | NotfoundVersionInfo;
-
-function isNotfoundVersionInfo(info: VersionInfo): info is NotfoundVersionInfo {
-  return 'notfound' in info;
-}
-
-function isSatisfiedVersionInfo(
-  info: VersionInfo,
-): info is SatisfiedVersionInfo {
-  return 'version' in info;
-}
-
-const getVersion = (versionInfo: VersionInfo): string | undefined => {
-  if (isNotfoundVersionInfo(versionInfo)) {
+function getCommandVersion(command: string): string | undefined {
+  try {
+    return execSync(command, { encoding: 'utf8' }).trim();
+  } catch {
     return undefined;
   }
+}
 
-  if (isSatisfiedVersionInfo(versionInfo)) {
-    return versionInfo.version.version;
-  }
-
-  return undefined;
-};
-
-export function getRuntimeVersions(): Promise<RuntimeVersions> {
-  return new Promise((resolve, reject) => {
-    checkNodeVersion({}, (error, result) => {
-      if (error) {
-        reject(error);
-        return;
-      }
-
-      resolve({
-        node: getVersion(result.versions.node as unknown as VersionInfo),
-        npm: getVersion(result.versions.npm as unknown as VersionInfo),
-        pnpm: getVersion(result.versions.pnpm as unknown as VersionInfo),
-      });
-    });
-  });
+export function getRuntimeVersions(): RuntimeVersions {
+  return {
+    node: process.versions.node,
+    npm: getCommandVersion('npm --version'),
+    pnpm: getCommandVersion('pnpm --version'),
+  };
 }

+ 23 - 0
apps/app/src/states/ui/device.ts

@@ -10,6 +10,7 @@ import { atom, useAtom } from 'jotai';
 export const isDeviceLargerThanXlAtom = atom(false);
 export const isDeviceLargerThanLgAtom = atom(false);
 export const isDeviceLargerThanMdAtom = atom(false);
+export const isDeviceLargerThanSmAtom = atom(false);
 export const isMobileAtom = atom(false);
 
 export const useDeviceLargerThanXl = () => {
@@ -78,6 +79,28 @@ export const useDeviceLargerThanMd = () => {
   return [isLargerThanMd, setIsLargerThanMd] as const;
 };
 
+export const useDeviceLargerThanSm = () => {
+  const [isLargerThanSm, setIsLargerThanSm] = useAtom(isDeviceLargerThanSmAtom);
+
+  useEffect(() => {
+    const smOrAboveHandler = function (this: MediaQueryList): void {
+      // xs -> sm: matches will be true
+      // sm -> xs: matches will be false
+      setIsLargerThanSm(this.matches);
+    };
+    const mql = addBreakpointListener(Breakpoint.SM, smOrAboveHandler);
+
+    // initialize
+    setIsLargerThanSm(mql.matches);
+
+    return () => {
+      cleanupBreakpointListener(mql, smOrAboveHandler);
+    };
+  }, [setIsLargerThanSm]);
+
+  return [isLargerThanSm, setIsLargerThanSm] as const;
+};
+
 export const useIsMobile = () => {
   const [isMobile, setIsMobile] = useAtom(isMobileAtom);
 

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.4.5-slackbot-proxy.0",
+  "version": "7.4.6-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.4.5",
+  "version": "7.4.6-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 1 - 27
pnpm-lock.yaml

@@ -310,9 +310,6 @@ importers:
       bunyan:
         specifier: ^1.8.15
         version: 1.8.15
-      check-node-version:
-        specifier: ^4.2.1
-        version: 4.2.1
       compression:
         specifier: ^1.7.4
         version: 1.7.4
@@ -6584,11 +6581,6 @@ packages:
     resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
     engines: {node: '>= 16'}
 
-  check-node-version@4.2.1:
-    resolution: {integrity: sha512-YYmFYHV/X7kSJhuN/QYHUu998n/TRuDe8UenM3+m5NrkiH670lb9ILqHIvBencvJc4SDh+XcbXMR4b+TtubJiw==}
-    engines: {node: '>=8.3.0'}
-    hasBin: true
-
   cheerio-select@2.1.0:
     resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
 
@@ -9707,6 +9699,7 @@ packages:
     resolution: {integrity: sha512-Quz3MvAwHxVYNXsOByL7xI5EB2WYOeFswqaHIA3qOK3isRWTxiplBEocmmru6XmxDB2L7jDNYtYA4FyimoAFEw==}
     engines: {node: '>=8.17.0'}
     hasBin: true
+    bundledDependencies: []
 
   jsonfile@3.0.1:
     resolution: {integrity: sha512-oBko6ZHlubVB5mRFkur5vgYR1UyqX+S6Y/oCfLhqNdcc2fYFlDpIoNc7AfKS1KOGcnNAkvsr0grLck9ANM815w==}
@@ -10225,9 +10218,6 @@ packages:
     resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==}
     engines: {node: '>=8'}
 
-  map-values@1.0.1:
-    resolution: {integrity: sha512-BbShUnr5OartXJe1GeccAWtfro11hhgNJg6G9/UtWKjVGvV5U4C09cg5nk8JUevhXODaXY+hQ3xxMUKSs62ONQ==}
-
   markdown-it-front-matter@0.2.4:
     resolution: {integrity: sha512-25GUs0yjS2hLl8zAemVndeEzThB1p42yxuDEKbd4JlL3jiz+jsm6e56Ya8B0VREOkNxLYB4TTwaoPJ3ElMmW+w==}
 
@@ -11097,9 +11087,6 @@ packages:
     resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
     engines: {node: '>=0.10.0'}
 
-  object-filter@1.0.2:
-    resolution: {integrity: sha512-NahvP2vZcy1ZiiYah30CEPw0FpDcSkSePJBMpzl5EQgCmISijiGuJm3SPYp7U+Lf2TljyaIw3E5EgkEx/TNEVA==}
-
   object-hash@2.2.0:
     resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
     engines: {node: '>= 6'}
@@ -21516,15 +21503,6 @@ snapshots:
 
   check-error@2.1.1: {}
 
-  check-node-version@4.2.1:
-    dependencies:
-      chalk: 3.0.0
-      map-values: 1.0.1
-      minimist: 1.2.8
-      object-filter: 1.0.2
-      run-parallel: 1.2.0
-      semver: 6.3.1
-
   cheerio-select@2.1.0:
     dependencies:
       boolbase: 1.0.0
@@ -25239,8 +25217,6 @@ snapshots:
 
   map-obj@4.3.0: {}
 
-  map-values@1.0.1: {}
-
   markdown-it-front-matter@0.2.4: {}
 
   markdown-it@13.0.2:
@@ -26478,8 +26454,6 @@ snapshots:
 
   object-assign@4.1.1: {}
 
-  object-filter@1.0.2: {}
-
   object-hash@2.2.0: {}
 
   object-inspect@1.13.4: {}