nuxt-users 1.49.3 → 1.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/cli.mjs +1 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +1 -1
  4. package/dist/runtime/constants.d.ts +1 -0
  5. package/dist/runtime/constants.js +1 -0
  6. package/dist/runtime/server/api/nuxt-users/[id].patch.js +1 -1
  7. package/dist/runtime/server/api/nuxt-users/auth/google/callback.get.js +18 -1
  8. package/dist/runtime/server/api/nuxt-users/auth/google/redirect.get.js +12 -2
  9. package/dist/runtime/server/api/nuxt-users/me.patch.js +3 -7
  10. package/dist/runtime/server/api/nuxt-users/password/forgot.post.js +3 -4
  11. package/dist/runtime/server/api/nuxt-users/password/index.patch.js +2 -1
  12. package/dist/runtime/server/api/nuxt-users/register.post.js +3 -4
  13. package/dist/runtime/server/plugins/validate-mailer-config.js +3 -0
  14. package/dist/runtime/server/services/password.js +9 -3
  15. package/dist/runtime/server/services/registration.js +6 -3
  16. package/dist/runtime/server/utils/escape-html.d.ts +4 -0
  17. package/dist/runtime/server/utils/escape-html.js +8 -0
  18. package/dist/runtime/server/utils/google-oauth.d.ts +1 -1
  19. package/dist/runtime/server/utils/google-oauth.js +3 -2
  20. package/dist/runtime/server/utils/index.d.ts +1 -0
  21. package/dist/runtime/server/utils/index.js +1 -0
  22. package/dist/runtime/server/utils/site-url.d.ts +10 -0
  23. package/dist/runtime/server/utils/site-url.js +13 -0
  24. package/dist/runtime/server/utils/user.d.ts +2 -1
  25. package/dist/runtime/server/utils/user.js +9 -1
  26. package/dist/runtime/utils/permissions.js +10 -13
  27. package/dist/shared/{nuxt-users.DtwPhils.d.mts → nuxt-users.Bday8snh.d.mts} +7 -0
  28. package/dist/shared/{nuxt-users.DtwPhils.d.ts → nuxt-users.Bday8snh.d.ts} +7 -0
  29. package/dist/shared/{nuxt-users.KrHIaTTL.mjs → nuxt-users.CcNeTWsL.mjs} +3 -0
  30. package/dist/utils.d.mts +2 -2
  31. package/dist/utils.d.ts +2 -2
  32. package/package.json +5 -5
package/dist/cli.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from 'citty';
3
3
  import { readFileSync } from 'node:fs';
4
- import { d as defaultOptions } from './shared/nuxt-users.KrHIaTTL.mjs';
4
+ import { d as defaultOptions } from './shared/nuxt-users.CcNeTWsL.mjs';
5
5
  import { defu } from 'defu';
6
6
  import { runMigrations, getAppliedMigrations } from '../dist/runtime/server/utils/migrate.js';
7
7
  import { createUser as createUser$1 } from '../dist/runtime/server/utils/user.js';
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.0.0"
6
6
  },
7
- "version": "1.49.3",
7
+ "version": "1.50.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { defineNuxtModule, createResolver, addPlugin, addImportsDir, addServerImportsDir, addServerScanDir, addServerHandler, addServerPlugin, addRouteMiddleware, addComponentsDir } from '@nuxt/kit';
2
2
  import { defu } from 'defu';
3
- import { d as defaultOptions } from './shared/nuxt-users.KrHIaTTL.mjs';
3
+ import { d as defaultOptions } from './shared/nuxt-users.CcNeTWsL.mjs';
4
4
  export * from '../dist/runtime/server/internal/index.js';
5
5
 
6
6
  const module$1 = defineNuxtModule({
@@ -1,3 +1,4 @@
1
+ export declare const OAUTH_STATE_COOKIE = "nuxt_users_oauth_state";
1
2
  export declare const PUBLIC_PAGES: string[];
2
3
  export declare const PUBLIC_API_ENDPOINTS: {
3
4
  path: string;
@@ -1,3 +1,4 @@
1
+ export const OAUTH_STATE_COOKIE = "nuxt_users_oauth_state";
1
2
  export const PUBLIC_PAGES = ["/login", "/reset-password"];
2
3
  export const PUBLIC_API_ENDPOINTS = [
3
4
  { path: "/password/forgot", methods: ["POST"] },
@@ -12,7 +12,7 @@ export default defineEventHandler(async (event) => {
12
12
  });
13
13
  }
14
14
  const body = await readBody(event);
15
- const currentUser = event.context.user;
15
+ const currentUser = event.context.nuxtUsers?.user;
16
16
  if (currentUser && currentUser.id === userId && typeof body.active !== "undefined") {
17
17
  throw createError({
18
18
  statusCode: 403,
@@ -1,4 +1,5 @@
1
- import { defineEventHandler, sendRedirect, getQuery, setCookie } from "h3";
1
+ import crypto from "node:crypto";
2
+ import { defineEventHandler, sendRedirect, getQuery, setCookie, getCookie, deleteCookie } from "h3";
2
3
  import { useRuntimeConfig } from "#imports";
3
4
  import {
4
5
  createGoogleOAuth2Client,
@@ -6,6 +7,7 @@ import {
6
7
  findOrCreateGoogleUser,
7
8
  createAuthTokenForUser
8
9
  } from "../../../../utils/index.js";
10
+ import { OAUTH_STATE_COOKIE } from "../../../../../constants.js";
9
11
  export default defineEventHandler(async (event) => {
10
12
  const { nuxtUsers } = useRuntimeConfig();
11
13
  const options = nuxtUsers;
@@ -13,6 +15,13 @@ export default defineEventHandler(async (event) => {
13
15
  if (!options.auth.google) {
14
16
  return sendRedirect(event, "/login?error=oauth_not_configured");
15
17
  }
18
+ const expectedState = getCookie(event, OAUTH_STATE_COOKIE);
19
+ deleteCookie(event, OAUTH_STATE_COOKIE, { path: "/" });
20
+ if (!isValidState(expectedState, query.state)) {
21
+ console.error("[Nuxt Users] Invalid or missing OAuth state in callback");
22
+ const errorRedirect = options.auth.google.errorRedirect || "/login?error=oauth_failed";
23
+ return sendRedirect(event, errorRedirect);
24
+ }
16
25
  if (query.error) {
17
26
  console.error("[Nuxt Users] Google OAuth error:", query.error);
18
27
  const errorRedirect = options.auth.google.errorRedirect || "/login?error=oauth_failed";
@@ -60,6 +69,14 @@ export default defineEventHandler(async (event) => {
60
69
  return sendRedirect(event, errorRedirect);
61
70
  }
62
71
  });
72
+ const isValidState = (expected, received) => {
73
+ if (!expected || typeof received !== "string") {
74
+ return false;
75
+ }
76
+ const expectedBuffer = Buffer.from(expected);
77
+ const receivedBuffer = Buffer.from(received);
78
+ return expectedBuffer.length === receivedBuffer.length && crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
79
+ };
63
80
  const getRequestURL = (event) => {
64
81
  const headers = event.node.req.headers;
65
82
  const host = headers.host || headers[":authority"];
@@ -1,6 +1,8 @@
1
- import { defineEventHandler, sendRedirect, createError } from "h3";
1
+ import crypto from "node:crypto";
2
+ import { defineEventHandler, sendRedirect, createError, setCookie } from "h3";
2
3
  import { useRuntimeConfig } from "#imports";
3
4
  import { createGoogleOAuth2Client, getGoogleAuthUrl } from "../../../../utils/index.js";
5
+ import { OAUTH_STATE_COOKIE } from "../../../../../constants.js";
4
6
  export default defineEventHandler(async (event) => {
5
7
  const { nuxtUsers } = useRuntimeConfig();
6
8
  const options = nuxtUsers;
@@ -21,7 +23,15 @@ export default defineEventHandler(async (event) => {
21
23
  const callbackPath = options.auth.google.callbackUrl || `${options.apiBasePath}/auth/google/callback`;
22
24
  const callbackUrl = `${baseUrl}${callbackPath}`;
23
25
  const oauth2Client = createGoogleOAuth2Client(options.auth.google, callbackUrl);
24
- const authUrl = getGoogleAuthUrl(oauth2Client, options.auth.google);
26
+ const state = crypto.randomBytes(32).toString("hex");
27
+ setCookie(event, OAUTH_STATE_COOKIE, state, {
28
+ httpOnly: true,
29
+ secure: process.env.NODE_ENV === "production",
30
+ sameSite: "lax",
31
+ path: "/",
32
+ maxAge: 10 * 60
33
+ });
34
+ const authUrl = getGoogleAuthUrl(oauth2Client, options.auth.google, state);
25
35
  return sendRedirect(event, authUrl);
26
36
  } catch (error) {
27
37
  console.error("[Nuxt Users] Google OAuth redirect error:", error);
@@ -4,7 +4,7 @@ import { updateUser } from "../../utils/index.js";
4
4
  export default defineEventHandler(async (event) => {
5
5
  const { nuxtUsers } = useRuntimeConfig();
6
6
  const options = nuxtUsers;
7
- const user = event.context.user;
7
+ const user = event.context.nuxtUsers?.user;
8
8
  if (!user) {
9
9
  throw createError({
10
10
  statusCode: 401,
@@ -12,12 +12,8 @@ export default defineEventHandler(async (event) => {
12
12
  });
13
13
  }
14
14
  const body = await readBody(event);
15
- if (body.role) {
16
- delete body.role;
17
- }
18
- if (body.active) {
19
- delete body.active;
20
- }
15
+ delete body.role;
16
+ delete body.active;
21
17
  try {
22
18
  const updatedUser = await updateUser(user.id, body, options);
23
19
  return { user: updatedUser };
@@ -1,5 +1,6 @@
1
- import { defineEventHandler, readBody, createError, getHeader } from "h3";
1
+ import { defineEventHandler, readBody, createError } from "h3";
2
2
  import { sendPasswordResetLink } from "../../../services/password.js";
3
+ import { getSiteUrl } from "../../../utils/site-url.js";
3
4
  import { useRuntimeConfig } from "#imports";
4
5
  export default defineEventHandler(async (event) => {
5
6
  const { nuxtUsers } = useRuntimeConfig();
@@ -13,9 +14,7 @@ export default defineEventHandler(async (event) => {
13
14
  });
14
15
  }
15
16
  try {
16
- const host = getHeader(event, "host");
17
- const protocol = getHeader(event, "x-forwarded-proto") || "http";
18
- const baseUrl = host ? `${protocol}://${host}` : void 0;
17
+ const baseUrl = getSiteUrl(event, options);
19
18
  await sendPasswordResetLink(email, options, baseUrl);
20
19
  return { message: "If a user with that email exists, a password reset link has been sent." };
21
20
  } catch (error) {
@@ -1,7 +1,7 @@
1
1
  import { createError, defineEventHandler, getCookie, readBody } from "h3";
2
2
  import bcrypt from "bcrypt";
3
3
  import { useRuntimeConfig } from "#imports";
4
- import { getCurrentUserFromToken, updateUserPassword } from "../../../utils/index.js";
4
+ import { getCurrentUserFromToken, updateUserPassword, revokeUserTokens } from "../../../utils/index.js";
5
5
  import { validatePassword, getPasswordValidationOptions } from "../../../../../utils";
6
6
  export default defineEventHandler(async (event) => {
7
7
  const { nuxtUsers } = useRuntimeConfig();
@@ -56,5 +56,6 @@ export default defineEventHandler(async (event) => {
56
56
  });
57
57
  }
58
58
  await updateUserPassword(user.email, newPassword, options);
59
+ await revokeUserTokens(user.id, options, token);
59
60
  return { message: "Password updated successfully" };
60
61
  });
@@ -1,6 +1,7 @@
1
- import { createError, defineEventHandler, readBody, getHeader } from "h3";
1
+ import { createError, defineEventHandler, readBody } from "h3";
2
2
  import { useRuntimeConfig } from "#imports";
3
3
  import { registerUser } from "../../services/registration.js";
4
+ import { getSiteUrl } from "../../utils/site-url.js";
4
5
  export default defineEventHandler(async (event) => {
5
6
  try {
6
7
  const { nuxtUsers } = useRuntimeConfig();
@@ -25,9 +26,7 @@ export default defineEventHandler(async (event) => {
25
26
  statusMessage: "Name cannot be empty"
26
27
  });
27
28
  }
28
- const host = getHeader(event, "host") || "localhost:3000";
29
- const protocol = getHeader(event, "x-forwarded-proto") || "http";
30
- const baseUrl = `${protocol}://${host}`;
29
+ const baseUrl = getSiteUrl(event, options);
31
30
  const result = await registerUser(
32
31
  {
33
32
  email: body.email.toLowerCase().trim(),
@@ -29,5 +29,8 @@ const plugin = () => {
29
29
  return;
30
30
  }
31
31
  validateMailerConfig(options);
32
+ if (process.env.NODE_ENV === "production" && !options.siteUrl) {
33
+ console.warn("[Nuxt Users] siteUrl is not configured: password reset and email confirmation requests will fail. Set nuxtUsers.siteUrl (or NUXT_NUXT_USERS_SITE_URL) to your public app URL.");
34
+ }
32
35
  };
33
36
  export default plugin;
@@ -1,7 +1,8 @@
1
1
  import { createTransport } from "nodemailer";
2
2
  import crypto from "node:crypto";
3
3
  import bcrypt from "bcrypt";
4
- import { findUserByEmail, updateUserPassword, useDb } from "../utils/index.js";
4
+ import { findUserByEmail, updateUserPassword, revokeUserTokens, useDb } from "../utils/index.js";
5
+ import { escapeHtml } from "../utils/escape-html.js";
5
6
  const TOKEN_EXPIRATION_HOURS = 1;
6
7
  export const sendPasswordResetLink = async (email, options, baseUrl) => {
7
8
  const user = await findUserByEmail(email, options);
@@ -35,6 +36,7 @@ export const sendPasswordResetLink = async (email, options, baseUrl) => {
35
36
  resetUrl.searchParams.set("token", token);
36
37
  resetUrl.searchParams.set("email", email);
37
38
  const resetLink = resetUrl.toString();
39
+ const safeLink = escapeHtml(resetLink);
38
40
  try {
39
41
  await transporter.sendMail({
40
42
  from: options.mailer.defaults?.from || '"Nuxt Users" <noreply@example.com>',
@@ -51,7 +53,7 @@ If you did not request this password reset, please ignore this email.`,
51
53
  <p>You have requested to reset your password. Please click the button below to set a new password:</p>
52
54
 
53
55
  <div style="text-align: center; margin: 30px 0;">
54
- <a href="${resetLink}"
56
+ <a href="${safeLink}"
55
57
  style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">
56
58
  Reset Password
57
59
  </a>
@@ -59,7 +61,7 @@ If you did not request this password reset, please ignore this email.`,
59
61
 
60
62
  <p>Or copy and paste the following link into your browser:</p>
61
63
  <p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 3px;">
62
- <a href="${resetLink}">${resetLink}</a>
64
+ <a href="${safeLink}">${safeLink}</a>
63
65
  </p>
64
66
 
65
67
  <p style="color: #6c757d; font-size: 14px;">
@@ -130,6 +132,10 @@ export const resetPassword = async (token, email, newPassword, options) => {
130
132
  return false;
131
133
  }
132
134
  await updateUserPassword(email, newPassword, options);
135
+ const user = await findUserByEmail(email, options);
136
+ if (user) {
137
+ await revokeUserTokens(user.id, options);
138
+ }
133
139
  await db.sql`DELETE FROM {${passwordResetTokensTable}} WHERE email = ${email}`;
134
140
  console.log(`[Nuxt Users] Password reset successful for email: ${email}`);
135
141
  return true;
@@ -2,6 +2,7 @@ import { createTransport } from "nodemailer";
2
2
  import crypto from "node:crypto";
3
3
  import bcrypt from "bcrypt";
4
4
  import { findUserByEmail, useDb, USER_EMAIL_ALREADY_EXISTS_MESSAGE } from "../utils/index.js";
5
+ import { escapeHtml } from "../utils/escape-html.js";
5
6
  import { validatePassword, getPasswordValidationOptions } from "nuxt-users/utils";
6
7
  const TOKEN_EXPIRATION_HOURS = 24;
7
8
  export const registerUser = async (userData, options, baseUrl) => {
@@ -76,6 +77,8 @@ export const sendConfirmationEmail = async (email, name, token, options, baseUrl
76
77
  confirmationUrl.searchParams.set("token", token);
77
78
  confirmationUrl.searchParams.set("email", email);
78
79
  const confirmationLink = confirmationUrl.toString();
80
+ const safeName = escapeHtml(name);
81
+ const safeLink = escapeHtml(confirmationLink);
79
82
  try {
80
83
  await transporter.sendMail({
81
84
  from: options.mailer.defaults?.from || '"Nuxt Users" <noreply@example.com>',
@@ -92,11 +95,11 @@ This link will expire in ${TOKEN_EXPIRATION_HOURS} hours.
92
95
  If you did not create an account, please ignore this email.`,
93
96
  html: `
94
97
  <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
95
- <h2>Welcome, ${name}!</h2>
98
+ <h2>Welcome, ${safeName}!</h2>
96
99
  <p>Thank you for registering! Please click the button below to confirm your email address and activate your account:</p>
97
100
 
98
101
  <div style="text-align: center; margin: 30px 0;">
99
- <a href="${confirmationLink}"
102
+ <a href="${safeLink}"
100
103
  style="background-color: #28a745; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">
101
104
  Confirm Email Address
102
105
  </a>
@@ -104,7 +107,7 @@ If you did not create an account, please ignore this email.`,
104
107
 
105
108
  <p>Or copy and paste the following link into your browser:</p>
106
109
  <p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 3px;">
107
- <a href="${confirmationLink}">${confirmationLink}</a>
110
+ <a href="${safeLink}">${safeLink}</a>
108
111
  </p>
109
112
 
110
113
  <p style="color: #6c757d; font-size: 14px;">
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Escapes a value for safe interpolation into HTML (text content and quoted attributes).
3
+ */
4
+ export declare const escapeHtml: (value: string) => string;
@@ -0,0 +1,8 @@
1
+ const HTML_ESCAPES = {
2
+ "&": "&amp;",
3
+ "<": "&lt;",
4
+ ">": "&gt;",
5
+ '"': "&quot;",
6
+ "'": "&#39;"
7
+ };
8
+ export const escapeHtml = (value) => value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
@@ -14,7 +14,7 @@ export declare function createGoogleOAuth2Client(options: GoogleOAuthOptions, ca
14
14
  /**
15
15
  * Generate authorization URL for Google OAuth
16
16
  */
17
- export declare function getGoogleAuthUrl(oauth2Client: OAuth2Client, options: GoogleOAuthOptions): string;
17
+ export declare function getGoogleAuthUrl(oauth2Client: OAuth2Client, options: GoogleOAuthOptions, state?: string): string;
18
18
  /**
19
19
  * Exchange authorization code for tokens and get user info
20
20
  */
@@ -9,12 +9,13 @@ export function createGoogleOAuth2Client(options, callbackUrl) {
9
9
  callbackUrl
10
10
  );
11
11
  }
12
- export function getGoogleAuthUrl(oauth2Client, options) {
12
+ export function getGoogleAuthUrl(oauth2Client, options, state) {
13
13
  const scopes = options.scopes || ["openid", "profile", "email"];
14
14
  return oauth2Client.generateAuthUrl({
15
15
  access_type: "offline",
16
16
  scope: scopes,
17
- include_granted_scopes: true
17
+ include_granted_scopes: true,
18
+ ...state && { state }
18
19
  });
19
20
  }
20
21
  export async function getGoogleUserFromCode(oauth2Client, code) {
@@ -8,3 +8,4 @@ export { createMigrationsTable } from './create-migrations-table.js';
8
8
  export { runMigrations, getAppliedMigrations } from './migrate.js';
9
9
  export { createGoogleOAuth2Client, getGoogleAuthUrl, getGoogleUserFromCode, findOrCreateGoogleUser, createAuthTokenForUser, generateSecurePassword } from './google-oauth.js';
10
10
  export { handleUnauthenticatedRequest, handleInvalidToken, handleUnauthorizedRequest } from './authorization.js';
11
+ export { getSiteUrl, SITE_URL_REQUIRED_MESSAGE } from './site-url.js';
@@ -8,3 +8,4 @@ export { createMigrationsTable } from "./create-migrations-table.js";
8
8
  export { runMigrations, getAppliedMigrations } from "./migrate.js";
9
9
  export { createGoogleOAuth2Client, getGoogleAuthUrl, getGoogleUserFromCode, findOrCreateGoogleUser, createAuthTokenForUser, generateSecurePassword } from "./google-oauth.js";
10
10
  export { handleUnauthenticatedRequest, handleInvalidToken, handleUnauthorizedRequest } from "./authorization.js";
11
+ export { getSiteUrl, SITE_URL_REQUIRED_MESSAGE } from "./site-url.js";
@@ -0,0 +1,10 @@
1
+ import { type H3Event } from 'h3';
2
+ import type { ModuleOptions } from 'nuxt-users/utils';
3
+ export declare const SITE_URL_REQUIRED_MESSAGE = "[Nuxt Users] siteUrl is not configured. Set nuxtUsers.siteUrl (or NUXT_NUXT_USERS_SITE_URL) to your public app URL, e.g. https://example.com, so email links are not built from the untrusted Host header.";
4
+ /**
5
+ * Resolves the base URL used for links in emails (password reset, email confirmation).
6
+ *
7
+ * The request's Host / X-Forwarded-Proto headers are attacker-controlled, so they are only
8
+ * used as a fallback outside production. In production `siteUrl` must be configured.
9
+ */
10
+ export declare const getSiteUrl: (event: H3Event, options: ModuleOptions) => string;
@@ -0,0 +1,13 @@
1
+ import { getHeader } from "h3";
2
+ export const SITE_URL_REQUIRED_MESSAGE = "[Nuxt Users] siteUrl is not configured. Set nuxtUsers.siteUrl (or NUXT_NUXT_USERS_SITE_URL) to your public app URL, e.g. https://example.com, so email links are not built from the untrusted Host header.";
3
+ export const getSiteUrl = (event, options) => {
4
+ if (options.siteUrl) {
5
+ return options.siteUrl.replace(/\/+$/, "");
6
+ }
7
+ if (process.env.NODE_ENV === "production") {
8
+ throw new Error(SITE_URL_REQUIRED_MESSAGE);
9
+ }
10
+ const host = getHeader(event, "host") || "localhost:3000";
11
+ const protocol = getHeader(event, "x-forwarded-proto") || "http";
12
+ return `${protocol}://${host}`;
13
+ };
@@ -59,8 +59,9 @@ export declare const cleanupPersonalAccessTokens: (options: ModuleOptions, inclu
59
59
  }>;
60
60
  /**
61
61
  * Revokes all tokens for a specific user.
62
+ * Pass `exceptToken` to keep one session (e.g. the one that just changed the password).
62
63
  */
63
- export declare const revokeUserTokens: (userId: number, options: ModuleOptions) => Promise<void>;
64
+ export declare const revokeUserTokens: (userId: number, options: ModuleOptions, exceptToken?: string) => Promise<void>;
64
65
  /**
65
66
  * Gets the last login time for a user based on their most recent personal access token creation.
66
67
  * This represents when the user last successfully logged in.
@@ -226,9 +226,17 @@ export const cleanupPersonalAccessTokens = async (options, includeNoExpiration =
226
226
  console.log(`[Nuxt Users] Token cleanup completed: ${expiredCount} expired + ${noExpirationCount} without expiration = ${totalCount} total tokens removed.`);
227
227
  return { expiredCount, noExpirationCount, totalCount };
228
228
  };
229
- export const revokeUserTokens = async (userId, options) => {
229
+ export const revokeUserTokens = async (userId, options, exceptToken) => {
230
230
  const db = await useDb(options);
231
231
  const personalAccessTokensTable = options.tables.personalAccessTokens;
232
+ if (exceptToken) {
233
+ await db.sql`
234
+ DELETE FROM {${personalAccessTokensTable}}
235
+ WHERE tokenable_type = 'user' AND tokenable_id = ${userId} AND token <> ${exceptToken}
236
+ `;
237
+ console.log(`[Nuxt Users] Other tokens revoked for user ID: ${userId}`);
238
+ return;
239
+ }
232
240
  await db.sql`
233
241
  DELETE FROM {${personalAccessTokensTable}}
234
242
  WHERE tokenable_type = 'user' AND tokenable_id = ${userId}
@@ -1,3 +1,8 @@
1
+ const patternToRegexSource = (pattern) => pattern.split(/(\*\*|\*)/).map((part) => {
2
+ if (part === "**") return ".*";
3
+ if (part === "*") return "[^/]*";
4
+ return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
5
+ }).join("");
1
6
  export const pathMatchesPattern = (path, pattern) => {
2
7
  if (path === pattern) {
3
8
  return true;
@@ -5,21 +10,13 @@ export const pathMatchesPattern = (path, pattern) => {
5
10
  if (pattern === "*") {
6
11
  return true;
7
12
  }
8
- if ((pattern.match(/\*/g) || []).length > 1) {
9
- const regexPattern = pattern.replace(/\*/g, "[^/]*");
10
- const regex = new RegExp(`^${regexPattern}`);
11
- return regex.test(path);
12
- }
13
- if (pattern.endsWith("/*")) {
14
- const basePattern = pattern.slice(0, -2);
15
- return path === basePattern || path.startsWith(basePattern + "/");
13
+ if (!pattern.includes("*")) {
14
+ return false;
16
15
  }
17
- if (pattern.includes("*")) {
18
- const regexPattern = pattern.replace(/\*/g, "[^/]*");
19
- const regex = new RegExp(`^${regexPattern}`);
20
- return regex.test(path);
16
+ if (pattern.endsWith("/*") && !pattern.endsWith("/**")) {
17
+ return new RegExp(`^${patternToRegexSource(pattern.slice(0, -2))}(?:/.*)?$`).test(path);
21
18
  }
22
- return false;
19
+ return new RegExp(`^${patternToRegexSource(pattern)}$`).test(path);
23
20
  };
24
21
  export const hasPermission = (userRole, path, method, permissions) => {
25
22
  const safeMethods = ["OPTIONS", "HEAD", "TRACE"];
@@ -98,6 +98,12 @@ interface RuntimeModuleOptions {
98
98
  * Uses nodemailer
99
99
  */
100
100
  mailer?: MailerOptions;
101
+ /**
102
+ * Public base URL of the app (e.g. 'https://example.com'), used to build links in emails
103
+ * (password reset, email confirmation). Required in production: the request Host header
104
+ * is attacker-controlled and is only used as a fallback in development.
105
+ */
106
+ siteUrl?: string;
101
107
  /**
102
108
  * URL path for password reset page
103
109
  * @default '/reset-password'
@@ -225,6 +231,7 @@ interface ModuleOptions {
225
231
  passwordResetTokens: string;
226
232
  };
227
233
  mailer?: MailerOptions;
234
+ siteUrl?: string;
228
235
  passwordResetUrl: string;
229
236
  emailConfirmationUrl: string;
230
237
  auth: {
@@ -98,6 +98,12 @@ interface RuntimeModuleOptions {
98
98
  * Uses nodemailer
99
99
  */
100
100
  mailer?: MailerOptions;
101
+ /**
102
+ * Public base URL of the app (e.g. 'https://example.com'), used to build links in emails
103
+ * (password reset, email confirmation). Required in production: the request Host header
104
+ * is attacker-controlled and is only used as a fallback in development.
105
+ */
106
+ siteUrl?: string;
101
107
  /**
102
108
  * URL path for password reset page
103
109
  * @default '/reset-password'
@@ -225,6 +231,7 @@ interface ModuleOptions {
225
231
  passwordResetTokens: string;
226
232
  };
227
233
  mailer?: MailerOptions;
234
+ siteUrl?: string;
228
235
  passwordResetUrl: string;
229
236
  emailConfirmationUrl: string;
230
237
  auth: {
@@ -24,6 +24,9 @@ const defaultOptions = {
24
24
  from: '"Nuxt Users Module" <noreply@example.com>'
25
25
  }
26
26
  },
27
+ // Empty by default so NUXT_NUXT_USERS_SITE_URL can override it (Nuxt only applies env vars to existing keys);
28
+ // empty falls back to the request host in development and is rejected in production
29
+ siteUrl: "",
27
30
  passwordResetUrl: "/reset-password",
28
31
  emailConfirmationUrl: "/email-confirmation",
29
32
  auth: {
package/dist/utils.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as Permission } from './shared/nuxt-users.DtwPhils.mjs';
2
- export { D as DatabaseConfig, a as DatabaseType, b as DisplayFieldsProps, G as GoogleOAuthOptions, L as LocaleMessages, c as LocaleOptions, d as LoginFormData, e as LoginFormProps, M as ModuleOptions, f as PasswordResetToken, g as PersonalAccessToken, h as ResetPasswordFormProps, R as RuntimeModuleOptions, U as User, i as UserWithoutPassword, j as defaultDisplayFields, k as defaultFieldLabels } from './shared/nuxt-users.DtwPhils.mjs';
1
+ import { P as Permission } from './shared/nuxt-users.Bday8snh.mjs';
2
+ export { D as DatabaseConfig, a as DatabaseType, b as DisplayFieldsProps, G as GoogleOAuthOptions, L as LocaleMessages, c as LocaleOptions, d as LoginFormData, e as LoginFormProps, M as ModuleOptions, f as PasswordResetToken, g as PersonalAccessToken, h as ResetPasswordFormProps, R as RuntimeModuleOptions, U as User, i as UserWithoutPassword, j as defaultDisplayFields, k as defaultFieldLabels } from './shared/nuxt-users.Bday8snh.mjs';
3
3
  export { defaultLocaleMessages, en, hu, huFormal } from '../dist/runtime/locales/index.js';
4
4
  export { getNestedValue, getTranslation } from '../dist/runtime/utils/locale.js';
5
5
  export { deepMerge } from '../dist/runtime/utils/deep-merge.js';
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { P as Permission } from './shared/nuxt-users.DtwPhils.js';
2
- export { D as DatabaseConfig, a as DatabaseType, b as DisplayFieldsProps, G as GoogleOAuthOptions, L as LocaleMessages, c as LocaleOptions, d as LoginFormData, e as LoginFormProps, M as ModuleOptions, f as PasswordResetToken, g as PersonalAccessToken, h as ResetPasswordFormProps, R as RuntimeModuleOptions, U as User, i as UserWithoutPassword, j as defaultDisplayFields, k as defaultFieldLabels } from './shared/nuxt-users.DtwPhils.js';
1
+ import { P as Permission } from './shared/nuxt-users.Bday8snh.js';
2
+ export { D as DatabaseConfig, a as DatabaseType, b as DisplayFieldsProps, G as GoogleOAuthOptions, L as LocaleMessages, c as LocaleOptions, d as LoginFormData, e as LoginFormProps, M as ModuleOptions, f as PasswordResetToken, g as PersonalAccessToken, h as ResetPasswordFormProps, R as RuntimeModuleOptions, U as User, i as UserWithoutPassword, j as defaultDisplayFields, k as defaultFieldLabels } from './shared/nuxt-users.Bday8snh.js';
3
3
  export { defaultLocaleMessages, en, hu, huFormal } from '../dist/runtime/locales/index.js';
4
4
  export { getNestedValue, getTranslation } from '../dist/runtime/utils/locale.js';
5
5
  export { deepMerge } from '../dist/runtime/utils/deep-merge.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-users",
3
- "version": "1.49.3",
3
+ "version": "1.50.0",
4
4
  "description": "A comprehensive user management module for Nuxt applications with authentication, authorization, database support, and CLI tools",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -55,7 +55,7 @@
55
55
  "scripts": {
56
56
  "prepack": "nuxt-module-build build && node scripts/post-build.js",
57
57
  "postinstall": "node -e \"try { require('fs').chmodSync('./dist/cli.mjs', '755') } catch(e) {}\" && node scripts/check-peer-dependencies.js",
58
- "predev": "node -v | grep -q 'v22' || (echo 'Please use Node.js v22 for development' && exit 1)",
58
+ "predev": "node -v | grep -qE '^v(22|24)\\.' || (echo 'Please use Node.js v22 or v24 for development' && exit 1)",
59
59
  "dev": "yarn dev:prepare && nuxi dev playground",
60
60
  "dev:build": "nuxi build playground",
61
61
  "dev:prepare": "yarn nuxt-module-build build --stub && yarn nuxt-module-build prepare && nuxi prepare playground",
@@ -65,13 +65,13 @@
65
65
  "check:locale-consistency": "tsx scripts/check-locale-consistency.ts",
66
66
  "check:npm_token": "dotenv -- sh -c 'if [ -z \"$NPM_TOKEN\" ]; then echo \"❌ NPM_TOKEN environment variable is not set in .env file. Please set it to your npm token.\"; exit 1; fi'",
67
67
  "check:npm_auth": "dotenv -- sh -c 'npm whoami --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=$NPM_TOKEN'",
68
- "release:publish:dry": "dotenv -- sh -c 'npm publish --dry-run --ignore-scripts --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=$NPM_TOKEN'",
68
+ "release:publish:dry": "npm pack --dry-run --ignore-scripts",
69
69
  "release:preflight": "yarn prerelease && yarn check:npm_token && yarn check:npm_auth && sh scripts/release-preflight.sh",
70
- "prerelease": "node -v | grep -q 'v22' || (echo 'Please use Node.js v22 for deployment' && exit 1)",
70
+ "prerelease": "node -v | grep -qE '^v(22|24)\\.' || (echo 'Please use Node.js v22 or v24 for deployment' && exit 1)",
71
71
  "release": "yarn release:preflight && yarn lint && yarn check:translations && yarn check:locale-consistency && yarn test && yarn prepack && yarn release:publish:dry && dotenv -- changelogen --release && dotenv -- sh -c 'npm publish --//registry.npmjs.org/:_authToken=$NPM_TOKEN --registry https://registry.npmjs.org/' && git push --follow-tags",
72
72
  "lint": "eslint .",
73
73
  "lint:fix": "eslint . --fix",
74
- "pretest": "node -v | grep -q 'v22' || (echo 'Please use Node.js v22 for testing' && exit 1)",
74
+ "pretest": "node -v | grep -qE '^v(22|24)\\.' || (echo 'Please use Node.js v22 or v24 for testing' && exit 1)",
75
75
  "test:quick": "yarn test:unit && yarn test:sqlite",
76
76
  "test:release": "yarn test && yarn build && yarn lint:fix",
77
77
  "test:types": "yarn dev:prepare && vue-tsc --noEmit --project tsconfig.vitest.json && cd playground && vue-tsc --noEmit",