@onmax/nuxt-better-auth 0.0.2 → 0.0.3

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.
package/dist/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onmax/nuxt-better-auth",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "configKey": "auth",
5
5
  "compatibility": {
6
6
  "nuxt": ">=4.0.0"
package/dist/module.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, statSync, writeFileSync, readFileSync } from 'node:fs';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
- import { getLayerDirectories, updateTemplates, addServerImportsDir, addServerImports, addServerScanDir, addServerHandler, addImportsDir, addPlugin, addComponentsDir, hasNuxtModule, installModule, extendPages, addTemplate, addTypeTemplate, defineNuxtModule, createResolver } from '@nuxt/kit';
3
+ import { getLayerDirectories, updateTemplates, addServerImportsDir, addServerImports, addServerScanDir, addServerHandler, addImportsDir, addPlugin, addComponentsDir, extendPages, addTemplate, hasNuxtModule, addTypeTemplate, defineNuxtModule, createResolver } from '@nuxt/kit';
4
4
  import { consola as consola$1 } from 'consola';
5
5
  import { isAbsolute, join, relative, dirname } from 'pathe';
6
6
  import { defu } from 'defu';
@@ -10,7 +10,7 @@ import { randomBytes } from 'node:crypto';
10
10
  import { isCI, isTest } from 'std-env';
11
11
  export { defineClientAuth, defineServerAuth } from '../dist/runtime/config.js';
12
12
 
13
- const version = "0.0.2";
13
+ const version = "0.0.3";
14
14
 
15
15
  const CONFIG_EXTENSIONS = [".ts", ".js"];
16
16
  const CONFIG_EXTENSION_RE = /\.(?:ts|js)$/;
@@ -224,8 +224,6 @@ async function registerDevtools(input) {
224
224
  const isProduction = process.env.NODE_ENV === "production" || !nuxt.options.dev;
225
225
  if (isProduction || clientOnly)
226
226
  return;
227
- if (!hasNuxtModule("@nuxt/ui"))
228
- await installModule("@nuxt/ui");
229
227
  setupDevTools(nuxt);
230
228
  addServerHandler({ route: "/api/_better-auth/config", method: "get", handler: resolve("./runtime/server/api/_better-auth/config.get") });
231
229
  if (hasHubDb) {
@@ -238,7 +236,7 @@ async function registerDevtools(input) {
238
236
  handlers.forEach((handler) => addServerHandler(handler));
239
237
  }
240
238
  extendPages((pages) => {
241
- pages.push({ name: "better-auth-devtools", path: "/__better-auth-devtools", file: resolve("./runtime/app/pages/__better-auth-devtools.vue") });
239
+ pages.push({ name: "better-auth-devtools", path: "/__better-auth-devtools", file: resolve("./runtime/app/pages/__better-auth-devtools.vue"), meta: { layout: false } });
242
240
  });
243
241
  }
244
242
  function registerRouteRulesMetaHook(nuxt) {
@@ -295,7 +293,7 @@ async function generateDrizzleSchema(authOptions, dialect, schemaOptions) {
295
293
  }
296
294
  return result.code;
297
295
  }
298
- async function loadUserAuthConfig(configPath, throwOnError = false, alias) {
296
+ async function loadUserAuthConfig(configPath, throwOnError = false, alias, runtimeConfig = {}) {
299
297
  const { createJiti } = await import('jiti');
300
298
  const { defineServerAuth: runtimeDefineServerAuth } = await import('../dist/runtime/config.js');
301
299
  const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, alias });
@@ -312,7 +310,7 @@ async function loadUserAuthConfig(configPath, throwOnError = false, alias) {
312
310
  const mod = await jiti.import(configPath);
313
311
  const configFn = mod.default;
314
312
  if (typeof configFn === "function") {
315
- return configFn({ runtimeConfig: {}, db: null });
313
+ return configFn({ runtimeConfig, db: null });
316
314
  }
317
315
  consola$1.warn("[@onmax/nuxt-better-auth] auth.config.ts does not export default. Expected: export default defineServerAuth(...)");
318
316
  if (throwOnError) {
@@ -388,7 +386,7 @@ async function loadAuthOptions(context) {
388
386
  const alias = Object.fromEntries(
389
387
  Object.entries(context.nuxt.options.alias).filter(([, value]) => typeof value === "string").map(([key, value]) => [key, value])
390
388
  );
391
- const userConfig = await loadUserAuthConfig(configFile, isProduction, alias);
389
+ const userConfig = await loadUserAuthConfig(configFile, isProduction, alias, context.nuxt.options.runtimeConfig);
392
390
  const extendedConfig = {};
393
391
  await context.nuxt.callHook("better-auth:config:extend", extendedConfig);
394
392
  const plugins = [...userConfig.plugins || [], ...extendedConfig.plugins || []];
@@ -0,0 +1 @@
1
+ export declare function runWithSessionRefresh<TResult>(runner: () => Promise<TResult>): Promise<TResult>;
@@ -0,0 +1,12 @@
1
+ import { isAuthActionErrorResult } from "../internal/auth-action-handles.js";
2
+ import { refreshSessionAfterAuthAction } from "../internal/wrap-auth-method.js";
3
+ import { useUserSession } from "./useUserSession.js";
4
+ export async function runWithSessionRefresh(runner) {
5
+ if (typeof runner !== "function")
6
+ throw new TypeError("runWithSessionRefresh(runner) requires an async function");
7
+ const auth = useUserSession();
8
+ const result = await runner();
9
+ if (!isAuthActionErrorResult(result))
10
+ await refreshSessionAfterAuthAction(auth.fetchSession, auth.loggedIn, auth.waitForSession);
11
+ return result;
12
+ }
@@ -14,5 +14,8 @@ export type ActionHandleFor<T> = T extends (...args: infer A) => Promise<infer R
14
14
  export type ActionHandleMap<T> = {
15
15
  [K in keyof T]: ActionHandleFor<T[K]>;
16
16
  };
17
+ export declare function isAuthActionErrorResult(value: unknown): value is {
18
+ error: unknown;
19
+ };
17
20
  export declare function createActionHandle<TArgs extends unknown[], TResult>(getMethod: () => (...args: TArgs) => Promise<TResult>, options?: CreateActionHandleOptions): UserAuthActionHandle<TArgs, TResult>;
18
21
  export declare function createActionHandles<T extends object>(getTarget: () => T, targetName: string): ActionHandleMap<T>;
@@ -1,7 +1,7 @@
1
1
  import { ref } from "#imports";
2
2
  import { normalizeAuthActionError } from "./auth-action-error.js";
3
3
  import { isRecord } from "./utils.js";
4
- function isErrorResult(value) {
4
+ export function isAuthActionErrorResult(value) {
5
5
  if (!isRecord(value))
6
6
  return false;
7
7
  if (!("error" in value))
@@ -36,7 +36,7 @@ export function createActionHandle(getMethod, options = {}) {
36
36
  error.value = null;
37
37
  try {
38
38
  const result = await getMethod()(...args);
39
- if (isErrorResult(result)) {
39
+ if (isAuthActionErrorResult(result)) {
40
40
  const normalizedError = normalizeAuthActionError(result.error);
41
41
  if (callId === latestCallId) {
42
42
  status.value = "error";
@@ -1,4 +1,7 @@
1
1
  import type { ComputedRef } from 'vue';
2
+ export declare function refreshSessionAfterAuthAction(fetchSession: (options?: {
3
+ force?: boolean;
4
+ }) => Promise<void>, loggedIn: ComputedRef<boolean>, waitForSession: () => Promise<void>): Promise<void>;
2
5
  export declare function wrapOnSuccess(fetchSession: (options?: {
3
6
  force?: boolean;
4
7
  }) => Promise<void>, loggedIn: ComputedRef<boolean>, waitForSession: () => Promise<void>, cb: (ctx: unknown) => void | Promise<void>): (ctx: unknown) => Promise<void>;
@@ -1,11 +1,14 @@
1
1
  import { nextTick } from "#imports";
2
2
  import { isRecord } from "./utils.js";
3
+ export async function refreshSessionAfterAuthAction(fetchSession, loggedIn, waitForSession) {
4
+ await fetchSession({ force: true });
5
+ if (!loggedIn.value)
6
+ await waitForSession();
7
+ await nextTick();
8
+ }
3
9
  export function wrapOnSuccess(fetchSession, loggedIn, waitForSession, cb) {
4
10
  return async (ctx) => {
5
- await fetchSession({ force: true });
6
- if (!loggedIn.value)
7
- await waitForSession();
8
- await nextTick();
11
+ await refreshSessionAfterAuthAction(fetchSession, loggedIn, waitForSession);
9
12
  await cb(ctx);
10
13
  };
11
14
  }
@@ -1,11 +1,14 @@
1
1
  import { defu } from "defu";
2
2
  import { createRouter, toRouteMatcher } from "radix3";
3
3
  import { createError, defineNuxtRouteMiddleware, getRouteRules, navigateTo, useNuxtApp, useRequestHeaders, useRuntimeConfig } from "#imports";
4
+ import { shouldSkipAuthRouteRules } from "../../internal/auth-route-rules.js";
4
5
  import { matchesUser } from "../../utils/match-user.js";
5
6
  import { useUserSession } from "../composables/useUserSession.js";
6
7
  let authRouteRulesPromise = null;
7
8
  let routeRulesMatcherPromise = null;
8
9
  export default defineNuxtRouteMiddleware(async (to) => {
10
+ if (shouldSkipAuthRouteRules(to.path))
11
+ return;
9
12
  const nuxtApp = useNuxtApp();
10
13
  if (to.meta.auth === void 0) {
11
14
  const routeRulesMatcher = await getRouteRulesMatcher();
@@ -1,420 +1,380 @@
1
1
  <script setup>
2
+ import { useFetch, useRuntimeConfig } from "#imports";
2
3
  import { useDevtoolsClient } from "@nuxt/devtools-kit/iframe-client";
3
- import { refDebounced } from "@vueuse/core";
4
- definePageMeta({ layout: false });
5
- const toast = useToast();
4
+ import { computed, ref, watch, watchEffect } from "vue";
5
+ const pageSize = 20;
6
+ const tabLabels = {
7
+ sessions: "Sessions",
8
+ users: "Users",
9
+ accounts: "Accounts",
10
+ config: "Config"
11
+ };
6
12
  const devtoolsClient = useDevtoolsClient();
7
13
  const runtimeConfig = useRuntimeConfig();
8
14
  const hasDb = computed(() => runtimeConfig.public.auth?.useDatabase ?? false);
9
15
  const isDark = computed(() => devtoolsClient.value?.host?.app?.colorMode?.value === "dark");
10
16
  watchEffect(() => {
11
- if (import.meta.client) {
17
+ if (import.meta.client)
12
18
  document.documentElement.classList.toggle("dark", isDark.value);
13
- }
19
+ });
20
+ const activeTab = ref(hasDb.value ? "sessions" : "config");
21
+ const tabs = computed(() => hasDb.value ? ["sessions", "users", "accounts", "config"] : ["config"]);
22
+ watch(hasDb, (enabled) => {
23
+ if (!enabled)
24
+ activeTab.value = "config";
14
25
  });
15
26
  const sessionsPage = ref(1);
16
27
  const usersPage = ref(1);
17
28
  const accountsPage = ref(1);
18
29
  const deleteConfirm = ref(null);
19
- const sessionsSearchRaw = ref("");
20
- const usersSearchRaw = ref("");
21
- const accountsSearchRaw = ref("");
22
- const sessionsSearch = refDebounced(sessionsSearchRaw, 300);
23
- const usersSearch = refDebounced(usersSearchRaw, 300);
24
- const accountsSearch = refDebounced(accountsSearchRaw, 300);
30
+ const notice = ref("");
31
+ const sessionsSearch = ref("");
32
+ const usersSearch = ref("");
33
+ const accountsSearch = ref("");
25
34
  watch(sessionsSearch, () => sessionsPage.value = 1);
26
35
  watch(usersSearch, () => usersPage.value = 1);
27
36
  watch(accountsSearch, () => accountsPage.value = 1);
28
- const sessionsQuery = computed(() => ({ page: sessionsPage.value, limit: 20, search: sessionsSearch.value }));
29
- const usersQuery = computed(() => ({ page: usersPage.value, limit: 20, search: usersSearch.value }));
30
- const accountsQuery = computed(() => ({ page: accountsPage.value, limit: 20, search: accountsSearch.value }));
37
+ const sessionsQuery = computed(() => ({ page: sessionsPage.value, limit: pageSize, search: sessionsSearch.value }));
38
+ const usersQuery = computed(() => ({ page: usersPage.value, limit: pageSize, search: usersSearch.value }));
39
+ const accountsQuery = computed(() => ({ page: accountsPage.value, limit: pageSize, search: accountsSearch.value }));
31
40
  const { data: sessionsData, refresh: refreshSessions } = await useFetch("/api/_better-auth/sessions", { query: sessionsQuery, immediate: hasDb.value });
32
41
  const { data: usersData, refresh: refreshUsers } = await useFetch("/api/_better-auth/users", { query: usersQuery, immediate: hasDb.value });
33
42
  const { data: accountsData, refresh: refreshAccounts } = await useFetch("/api/_better-auth/accounts", { query: accountsQuery, immediate: hasDb.value });
34
43
  const { data: configData } = await useFetch("/api/_better-auth/config");
35
- const tabs = computed(() => {
36
- const dbTabs = [
37
- { label: "Sessions", value: "sessions", icon: "i-lucide-key", slot: "sessions" },
38
- { label: "Users", value: "users", icon: "i-lucide-users", slot: "users" },
39
- { label: "Accounts", value: "accounts", icon: "i-lucide-link", slot: "accounts" }
40
- ];
41
- const configTab = { label: "Config", value: "config", icon: "i-lucide-settings", slot: "config" };
42
- return hasDb.value ? [...dbTabs, configTab] : [configTab];
43
- });
44
+ const sessions = computed(() => sessionsData.value?.sessions ?? []);
45
+ const users = computed(() => usersData.value?.users ?? []);
46
+ const accounts = computed(() => accountsData.value?.accounts ?? []);
47
+ let noticeTimer;
48
+ function showNotice(message) {
49
+ notice.value = message;
50
+ if (noticeTimer)
51
+ clearTimeout(noticeTimer);
52
+ noticeTimer = setTimeout(() => notice.value = "", 2200);
53
+ }
44
54
  function isExpired(date) {
45
- if (!date)
46
- return false;
47
- return new Date(date) < /* @__PURE__ */ new Date();
55
+ return date ? new Date(date) < /* @__PURE__ */ new Date() : false;
48
56
  }
49
57
  function formatDate(date) {
50
- if (!date)
51
- return "-";
52
- return new Date(date).toLocaleString();
58
+ return date ? new Date(date).toLocaleString() : "-";
53
59
  }
54
- function truncate(str, len = 12) {
60
+ function truncate(str, len = 14) {
55
61
  if (!str)
56
62
  return "-";
57
63
  if (str.length <= len)
58
64
  return str;
59
- const half = Math.floor((len - 1) / 2);
60
- return `${str.slice(0, half)}\u2026${str.slice(-half)}`;
65
+ const half = Math.floor((len - 3) / 2);
66
+ return `${str.slice(0, half)}...${str.slice(-half)}`;
67
+ }
68
+ function totalPages(total) {
69
+ return Math.max(1, Math.ceil((total ?? 0) / pageSize));
70
+ }
71
+ function pageBack(page) {
72
+ page.value = Math.max(1, page.value - 1);
73
+ }
74
+ function pageNext(page, total) {
75
+ page.value = Math.min(totalPages(total), page.value + 1);
61
76
  }
62
77
  async function copyToClipboard(text, label = "Value") {
63
78
  try {
64
79
  await navigator.clipboard.writeText(text);
65
- toast.add({ title: `${label} copied`, icon: "i-lucide-check", color: "success" });
80
+ showNotice(`${label} copied`);
66
81
  } catch {
67
- toast.add({ title: "Copy failed", icon: "i-lucide-x", color: "error" });
82
+ showNotice("Copy failed");
68
83
  }
69
84
  }
70
85
  function generateConfigMarkdown() {
71
86
  const config = configData.value?.config;
72
87
  if (!config)
73
88
  return "";
74
- const moduleJson = JSON.stringify(config.module, null, 2);
75
- const serverJson = JSON.stringify(config.server, null, 2);
76
89
  return `## Module Config (\`nuxt.config.ts\`)
77
90
 
78
91
  \`\`\`json
79
- ${moduleJson}
92
+ ${JSON.stringify(config.module ?? {}, null, 2)}
80
93
  \`\`\`
81
94
 
82
95
  ## Server Config (\`server/auth.config.ts\`)
83
96
 
84
97
  \`\`\`json
85
- ${serverJson}
98
+ ${JSON.stringify(config.server ?? {}, null, 2)}
86
99
  \`\`\`
87
100
  `;
88
101
  }
102
+ function json(value) {
103
+ return JSON.stringify(value ?? {}, null, 2);
104
+ }
89
105
  async function deleteSession(id) {
90
106
  try {
91
- await $fetch("/api/_better-auth/sessions", { method: "DELETE", body: { id } });
92
- toast.add({ title: "Session deleted", icon: "i-lucide-trash-2", color: "success" });
107
+ const response = await fetch("/api/_better-auth/sessions", {
108
+ method: "DELETE",
109
+ headers: { "content-type": "application/json" },
110
+ body: JSON.stringify({ id })
111
+ });
112
+ if (!response.ok)
113
+ throw new Error("Delete failed");
93
114
  deleteConfirm.value = null;
94
- refreshSessions();
115
+ showNotice("Session deleted");
116
+ await refreshSessions();
95
117
  } catch {
96
- toast.add({ title: "Failed to delete session", icon: "i-lucide-x", color: "error" });
118
+ showNotice("Failed to delete session");
97
119
  }
98
120
  }
99
- const sessionColumns = [
100
- { accessorKey: "id", header: "ID", cell: ({ row }) => h("span", { class: "font-mono text-sm" }, truncate(row.original.id)) },
101
- {
102
- accessorKey: "userId",
103
- header: "User",
104
- cell: ({ row }) => h("div", { class: "min-w-0" }, [
105
- h("p", { class: "font-mono text-sm truncate" }, truncate(row.original.userId)),
106
- h("p", { class: "text-sm text-muted-foreground font-mono" }, row.original.ipAddress || "No IP")
107
- ])
108
- },
109
- { accessorKey: "userAgent", header: "User Agent", cell: ({ row }) => h("span", { class: "text-sm text-muted-foreground max-w-48 truncate block" }, truncate(row.original.userAgent, 30)) },
110
- {
111
- accessorKey: "expiresAt",
112
- header: "Status",
113
- cell: ({ row }) => {
114
- const expired = isExpired(row.original.expiresAt);
115
- return h(resolveComponent("UBadge"), { color: expired ? "error" : "success", variant: "subtle", size: "sm" }, () => expired ? "Expired" : "Active");
116
- }
117
- },
118
- { accessorKey: "createdAt", header: "Created", cell: ({ row }) => h("span", { class: "text-sm text-muted-foreground" }, formatDate(row.original.createdAt)) }
119
- ];
120
- const userColumns = [
121
- { accessorKey: "id", header: "ID", cell: ({ row }) => h("span", { class: "font-mono text-sm" }, truncate(row.original.id)) },
122
- {
123
- accessorKey: "name",
124
- header: "User",
125
- cell: ({ row }) => h("div", { class: "min-w-0" }, [
126
- h("p", { class: "font-medium truncate" }, row.original.name || "Unnamed"),
127
- h("p", { class: "text-sm text-muted-foreground font-mono truncate" }, row.original.email)
128
- ])
129
- },
130
- {
131
- accessorKey: "emailVerified",
132
- header: "Verified",
133
- cell: ({ row }) => h(resolveComponent("UBadge"), { color: row.original.emailVerified ? "success" : "neutral", variant: "subtle", size: "sm" }, () => row.original.emailVerified ? "Yes" : "No")
134
- },
135
- { accessorKey: "createdAt", header: "Created", cell: ({ row }) => h("span", { class: "text-sm text-muted-foreground" }, formatDate(row.original.createdAt)) }
136
- ];
137
- const accountColumns = [
138
- { accessorKey: "id", header: "ID", cell: ({ row }) => h("span", { class: "font-mono text-sm" }, truncate(row.original.id)) },
139
- {
140
- accessorKey: "providerId",
141
- header: "Provider",
142
- cell: ({ row }) => {
143
- const provider = row.original.providerId;
144
- const iconMap = { github: "i-simple-icons-github", google: "i-simple-icons-google", discord: "i-simple-icons-discord", twitter: "i-simple-icons-x", facebook: "i-simple-icons-facebook" };
145
- return h("div", { class: "flex items-center gap-2" }, [
146
- h(resolveComponent("UIcon"), { name: iconMap[provider] || "i-lucide-key", class: "size-4" }),
147
- h("div", { class: "min-w-0" }, [
148
- h("p", { class: "capitalize font-medium" }, provider),
149
- h("p", { class: "text-sm text-muted-foreground font-mono truncate" }, truncate(row.original.accountId, 16))
150
- ])
151
- ]);
152
- }
153
- },
154
- { accessorKey: "userId", header: "User ID", cell: ({ row }) => h("span", { class: "font-mono text-sm" }, truncate(row.original.userId)) },
155
- { accessorKey: "createdAt", header: "Created", cell: ({ row }) => h("span", { class: "text-sm text-muted-foreground" }, formatDate(row.original.createdAt)) }
156
- ];
157
- function getSessionActions(row) {
158
- return [
159
- [{ label: "Copy ID", icon: "i-lucide-copy", click: () => copyToClipboard(row.id, "Session ID") }],
160
- [{ label: "Delete", icon: "i-lucide-trash-2", color: "error", click: () => {
161
- deleteConfirm.value = row.id;
162
- } }]
163
- ];
164
- }
165
- function getUserActions(row) {
166
- return [
167
- [{ label: "Copy ID", icon: "i-lucide-copy", click: () => copyToClipboard(row.id, "User ID") }],
168
- [{ label: "Copy Email", icon: "i-lucide-mail", click: () => copyToClipboard(row.email, "Email") }]
169
- ];
170
- }
171
- function getAccountActions(row) {
172
- return [[{ label: "Copy ID", icon: "i-lucide-copy", click: () => copyToClipboard(row.id, "Account ID") }]];
121
+ async function deleteConfirmedSession() {
122
+ if (deleteConfirm.value)
123
+ await deleteSession(deleteConfirm.value);
173
124
  }
174
125
  </script>
175
126
 
176
127
  <template>
177
- <div class="min-h-screen bg-background text-foreground">
178
- <!-- Header -->
179
- <header class="flex items-center justify-between border-b border-border px-4 py-3">
180
- <div class="flex items-center gap-3">
181
- <svg width="60" height="45" viewBox="0 0 60 45" fill="none" class="h-4 w-auto" xmlns="http://www.w3.org/2000/svg">
182
- <path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H15V15H30V30H15V45H0V30V15V0ZM45 30V15H30V0H45H60V15V30V45H45H30V30H45Z" class="fill-current" />
128
+ <div class="devtools-shell">
129
+ <header class="devtools-header">
130
+ <div class="brand">
131
+ <svg width="60" height="45" viewBox="0 0 60 45" fill="none" class="brand-mark" xmlns="http://www.w3.org/2000/svg">
132
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H15V15H30V30H15V45H0V30V15V0ZM45 30V15H30V0H45H60V15V30V45H45H30V30H45Z" fill="currentColor" />
183
133
  </svg>
184
- <span class="font-medium text-sm">Better Auth DevTools</span>
185
- </div>
186
- <div class="flex items-center">
187
- <a href="https://www.better-auth.com/docs" target="_blank" class="header-link border-r border-border">Docs</a>
188
- <a href="https://github.com/onmax/nuxt-better-auth" target="_blank" class="header-link">
189
- <UIcon name="i-simple-icons-github" class="size-4" />
190
- </a>
134
+ <span>Better Auth DevTools</span>
191
135
  </div>
136
+ <nav class="links">
137
+ <a href="https://www.better-auth.com/docs" target="_blank" rel="noreferrer">Docs</a>
138
+ <a href="https://github.com/onmax/nuxt-better-auth" target="_blank" rel="noreferrer">GitHub</a>
139
+ </nav>
192
140
  </header>
193
141
 
194
- <!-- Tabs -->
195
- <UTabs :items="tabs" class="w-full" :ui="{ list: 'border-b border-border rounded-none bg-transparent justify-start', trigger: 'rounded-none data-[state=active]:shadow-none data-[state=active]:bg-[var(--tab-active-bg)] data-[state=active]:text-foreground flex-none text-muted-foreground' }">
196
- <!-- Sessions Tab -->
197
- <template #sessions>
198
- <div class="p-4 space-y-4">
199
- <div class="flex items-center justify-between gap-4">
200
- <UInput v-model="sessionsSearchRaw" placeholder="Search by user ID or IP..." icon="i-lucide-search" class="max-w-xs" />
201
- <div class="flex items-center gap-2 text-sm text-muted-foreground whitespace-nowrap">
202
- <span>{{ sessionsData?.total ?? 0 }} sessions</span>
203
- <UButton variant="ghost" size="xs" icon="i-lucide-refresh-cw" @click="() => refreshSessions()" />
204
- </div>
205
- </div>
206
-
207
- <UAlert v-if="deleteConfirm" title="Delete session?" description="This will invalidate the session immediately." color="error" variant="soft" icon="i-lucide-alert-triangle" :actions="[{ label: 'Cancel', color: 'neutral', variant: 'outline', onClick: () => {
208
- deleteConfirm = null;
209
- } }, { label: 'Delete', color: 'error', onClick: () => deleteSession(deleteConfirm) }]" />
142
+ <nav class="tabs" aria-label="Devtools sections">
143
+ <button
144
+ v-for="tab in tabs"
145
+ :key="tab"
146
+ type="button"
147
+ :class="{ active: activeTab === tab }"
148
+ @click="activeTab = tab"
149
+ >
150
+ {{ tabLabels[tab] }}
151
+ </button>
152
+ </nav>
210
153
 
211
- <p v-if="sessionsData?.error" class="text-destructive text-sm">
212
- {{ sessionsData.error }}
213
- </p>
154
+ <p v-if="notice" class="notice">
155
+ {{ notice }}
156
+ </p>
214
157
 
215
- <UTable v-else-if="sessionsData?.sessions?.length" :data="sessionsData.sessions" :columns="sessionColumns" class="rounded-none border border-border">
216
- <template #actions="{ row }">
217
- <UDropdownMenu :items="getSessionActions(row.original)">
218
- <UButton variant="ghost" size="xs" icon="i-lucide-more-horizontal" />
219
- </UDropdownMenu>
220
- </template>
221
- </UTable>
222
-
223
- <p v-else class="text-muted-foreground text-sm py-8 text-center">
224
- No sessions found
225
- </p>
226
-
227
- <UPagination v-if="(sessionsData?.total ?? 0) > 20" v-model:page="sessionsPage" :total="sessionsData?.total ?? 0" :items-per-page="20" />
228
- </div>
229
- </template>
230
-
231
- <!-- Users Tab -->
232
- <template #users>
233
- <div class="p-4 space-y-4">
234
- <div class="flex items-center justify-between gap-4">
235
- <UInput v-model="usersSearchRaw" placeholder="Search by name or email..." icon="i-lucide-search" class="max-w-xs" />
236
- <div class="flex items-center gap-2 text-sm text-muted-foreground whitespace-nowrap">
237
- <span>{{ usersData?.total ?? 0 }} users</span>
238
- <UButton variant="ghost" size="xs" icon="i-lucide-refresh-cw" @click="() => refreshUsers()" />
239
- </div>
158
+ <main class="panel">
159
+ <section v-if="activeTab === 'sessions'" class="section">
160
+ <div class="toolbar">
161
+ <input v-model="sessionsSearch" type="search" placeholder="Search by user ID or IP...">
162
+ <div class="toolbar-meta">
163
+ <span>{{ sessionsData?.total ?? 0 }} sessions</span>
164
+ <button type="button" @click="() => refreshSessions()">
165
+ Refresh
166
+ </button>
240
167
  </div>
168
+ </div>
241
169
 
242
- <p v-if="usersData?.error" class="text-destructive text-sm">
243
- {{ usersData.error }}
244
- </p>
245
-
246
- <UTable v-else-if="usersData?.users?.length" :data="usersData.users" :columns="userColumns" class="rounded-none border border-border">
247
- <template #actions="{ row }">
248
- <UDropdownMenu :items="getUserActions(row.original)">
249
- <UButton variant="ghost" size="xs" icon="i-lucide-more-horizontal" />
250
- </UDropdownMenu>
251
- </template>
252
- </UTable>
170
+ <div v-if="deleteConfirm" class="confirm">
171
+ <span>Delete this session?</span>
172
+ <button type="button" @click="deleteConfirm = null">
173
+ Cancel
174
+ </button>
175
+ <button type="button" class="danger" @click="deleteConfirmedSession">
176
+ Delete
177
+ </button>
178
+ </div>
253
179
 
254
- <p v-else class="text-muted-foreground text-sm py-8 text-center">
255
- No users found
256
- </p>
180
+ <p v-if="sessionsData?.error" class="error">
181
+ {{ sessionsData.error }}
182
+ </p>
183
+ <table v-else-if="sessions.length">
184
+ <thead>
185
+ <tr>
186
+ <th>ID</th>
187
+ <th>User</th>
188
+ <th>User Agent</th>
189
+ <th>Status</th>
190
+ <th>Created</th>
191
+ <th />
192
+ </tr>
193
+ </thead>
194
+ <tbody>
195
+ <tr v-for="session in sessions" :key="session.id">
196
+ <td><code>{{ truncate(session.id) }}</code></td>
197
+ <td>
198
+ <code>{{ truncate(session.userId) }}</code>
199
+ <small>{{ session.ipAddress || "No IP" }}</small>
200
+ </td>
201
+ <td>{{ truncate(session.userAgent, 32) }}</td>
202
+ <td>
203
+ <span class="badge" :class="isExpired(session.expiresAt) ? 'bad' : 'good'">
204
+ {{ isExpired(session.expiresAt) ? "Expired" : "Active" }}
205
+ </span>
206
+ </td>
207
+ <td>{{ formatDate(session.createdAt) }}</td>
208
+ <td class="actions">
209
+ <button type="button" @click="copyToClipboard(session.id, 'Session ID')">
210
+ Copy
211
+ </button>
212
+ <button type="button" class="danger" @click="deleteConfirm = session.id">
213
+ Delete
214
+ </button>
215
+ </td>
216
+ </tr>
217
+ </tbody>
218
+ </table>
219
+ <p v-else class="empty">
220
+ No sessions found
221
+ </p>
257
222
 
258
- <UPagination v-if="(usersData?.total ?? 0) > 20" v-model:page="usersPage" :total="usersData?.total ?? 0" :items-per-page="20" />
223
+ <div v-if="(sessionsData?.total ?? 0) > pageSize" class="pagination">
224
+ <button type="button" :disabled="sessionsPage === 1" @click="pageBack(sessionsPage)">
225
+ Previous
226
+ </button>
227
+ <span>Page {{ sessionsPage }} of {{ totalPages(sessionsData?.total) }}</span>
228
+ <button type="button" :disabled="sessionsPage >= totalPages(sessionsData?.total)" @click="pageNext(sessionsPage, sessionsData?.total)">
229
+ Next
230
+ </button>
259
231
  </div>
260
- </template>
232
+ </section>
261
233
 
262
- <!-- Accounts Tab -->
263
- <template #accounts>
264
- <div class="p-4 space-y-4">
265
- <div class="flex items-center justify-between gap-4">
266
- <UInput v-model="accountsSearchRaw" placeholder="Search by provider..." icon="i-lucide-search" class="max-w-xs" />
267
- <div class="flex items-center gap-2 text-sm text-muted-foreground whitespace-nowrap">
268
- <span>{{ accountsData?.total ?? 0 }} accounts</span>
269
- <UButton variant="ghost" size="xs" icon="i-lucide-refresh-cw" @click="() => refreshAccounts()" />
270
- </div>
234
+ <section v-else-if="activeTab === 'users'" class="section">
235
+ <div class="toolbar">
236
+ <input v-model="usersSearch" type="search" placeholder="Search by name or email...">
237
+ <div class="toolbar-meta">
238
+ <span>{{ usersData?.total ?? 0 }} users</span>
239
+ <button type="button" @click="() => refreshUsers()">
240
+ Refresh
241
+ </button>
271
242
  </div>
243
+ </div>
272
244
 
273
- <p v-if="accountsData?.error" class="text-destructive text-sm">
274
- {{ accountsData.error }}
275
- </p>
276
-
277
- <UTable v-else-if="accountsData?.accounts?.length" :data="accountsData.accounts" :columns="accountColumns" class="rounded-none border border-border">
278
- <template #actions="{ row }">
279
- <UDropdownMenu :items="getAccountActions(row.original)">
280
- <UButton variant="ghost" size="xs" icon="i-lucide-more-horizontal" />
281
- </UDropdownMenu>
282
- </template>
283
- </UTable>
284
-
285
- <p v-else class="text-muted-foreground text-sm py-8 text-center">
286
- No accounts found
287
- </p>
245
+ <p v-if="usersData?.error" class="error">
246
+ {{ usersData.error }}
247
+ </p>
248
+ <table v-else-if="users.length">
249
+ <thead>
250
+ <tr>
251
+ <th>ID</th>
252
+ <th>User</th>
253
+ <th>Verified</th>
254
+ <th>Created</th>
255
+ <th />
256
+ </tr>
257
+ </thead>
258
+ <tbody>
259
+ <tr v-for="user in users" :key="user.id">
260
+ <td><code>{{ truncate(user.id) }}</code></td>
261
+ <td>
262
+ <strong>{{ user.name || "Unnamed" }}</strong>
263
+ <small>{{ user.email }}</small>
264
+ </td>
265
+ <td>
266
+ <span class="badge" :class="user.emailVerified ? 'good' : ''">
267
+ {{ user.emailVerified ? "Yes" : "No" }}
268
+ </span>
269
+ </td>
270
+ <td>{{ formatDate(user.createdAt) }}</td>
271
+ <td class="actions">
272
+ <button type="button" @click="copyToClipboard(user.id, 'User ID')">
273
+ Copy ID
274
+ </button>
275
+ <button type="button" @click="copyToClipboard(user.email, 'Email')">
276
+ Copy Email
277
+ </button>
278
+ </td>
279
+ </tr>
280
+ </tbody>
281
+ </table>
282
+ <p v-else class="empty">
283
+ No users found
284
+ </p>
288
285
 
289
- <UPagination v-if="(accountsData?.total ?? 0) > 20" v-model:page="accountsPage" :total="accountsData?.total ?? 0" :items-per-page="20" />
286
+ <div v-if="(usersData?.total ?? 0) > pageSize" class="pagination">
287
+ <button type="button" :disabled="usersPage === 1" @click="pageBack(usersPage)">
288
+ Previous
289
+ </button>
290
+ <span>Page {{ usersPage }} of {{ totalPages(usersData?.total) }}</span>
291
+ <button type="button" :disabled="usersPage >= totalPages(usersData?.total)" @click="pageNext(usersPage, usersData?.total)">
292
+ Next
293
+ </button>
290
294
  </div>
291
- </template>
295
+ </section>
292
296
 
293
- <!-- Config Tab -->
294
- <template #config>
295
- <div class="p-3 space-y-3">
296
- <div class="flex items-center justify-end">
297
- <UButton variant="ghost" size="xs" icon="i-lucide-copy" @click="copyToClipboard(generateConfigMarkdown(), 'Config')">
298
- Copy
299
- </UButton>
297
+ <section v-else-if="activeTab === 'accounts'" class="section">
298
+ <div class="toolbar">
299
+ <input v-model="accountsSearch" type="search" placeholder="Search by provider...">
300
+ <div class="toolbar-meta">
301
+ <span>{{ accountsData?.total ?? 0 }} accounts</span>
302
+ <button type="button" @click="() => refreshAccounts()">
303
+ Refresh
304
+ </button>
300
305
  </div>
306
+ </div>
301
307
 
302
- <p v-if="configData?.error" class="text-destructive text-sm">
303
- {{ configData.error }}
304
- </p>
305
-
306
- <template v-else-if="configData?.config?.server">
307
- <!-- Row 1: Endpoints + Session + Auth Methods -->
308
- <div class="grid gap-3 md:grid-cols-3">
309
- <div class="config-section">
310
- <div class="config-header">
311
- <UIcon name="i-lucide-globe" class="size-4" /><span>Endpoints</span>
312
- </div>
313
- <div class="config-row">
314
- <span class="config-label">Base URL</span><span class="font-mono">{{ configData.config.server.baseURL || "auto" }}</span>
315
- </div>
316
- <div class="config-row">
317
- <span class="config-label">Path</span><span class="font-mono">{{ configData.config.server.basePath }}</span>
318
- </div>
319
- </div>
320
- <div class="config-section">
321
- <div class="config-header">
322
- <UIcon name="i-lucide-clock" class="size-4" /><span>Session</span>
323
- </div>
324
- <div class="config-row">
325
- <span class="config-label">Expires</span><span class="font-mono">{{ configData.config.server.session?.expiresIn }}</span>
326
- </div>
327
- <div class="config-row">
328
- <span class="config-label">Update</span><span class="font-mono">{{ configData.config.server.session?.updateAge }}</span>
329
- </div>
330
- <div class="config-row">
331
- <span class="config-label">Cache</span><UBadge :color="configData.config.server.session?.cookieCache ? 'success' : 'neutral'" variant="subtle" size="sm">
332
- {{ configData.config.server.session?.cookieCache ? "On" : "Off" }}
333
- </UBadge>
334
- </div>
335
- </div>
336
- <div class="config-section">
337
- <div class="config-header">
338
- <UIcon name="i-lucide-key-round" class="size-4" /><span>Auth</span>
339
- </div>
340
- <div class="flex flex-wrap gap-1">
341
- <UBadge v-if="configData.config.server.emailAndPassword" variant="subtle" color="success" size="sm">
342
- Email
343
- </UBadge>
344
- <UBadge v-for="provider in configData.config.server.socialProviders" :key="provider" variant="subtle" color="neutral" size="sm" class="capitalize">
345
- {{ provider }}
346
- </UBadge>
347
- <span v-if="!configData.config.server.emailAndPassword && !configData.config.server.socialProviders?.length" class="text-muted-foreground text-sm">None</span>
348
- </div>
349
- </div>
350
- </div>
308
+ <p v-if="accountsData?.error" class="error">
309
+ {{ accountsData.error }}
310
+ </p>
311
+ <table v-else-if="accounts.length">
312
+ <thead>
313
+ <tr>
314
+ <th>Provider</th>
315
+ <th>Account</th>
316
+ <th>User ID</th>
317
+ <th>Created</th>
318
+ <th />
319
+ </tr>
320
+ </thead>
321
+ <tbody>
322
+ <tr v-for="account in accounts" :key="account.id">
323
+ <td class="capitalize">
324
+ {{ account.providerId }}
325
+ </td>
326
+ <td><code>{{ truncate(account.accountId, 18) }}</code></td>
327
+ <td><code>{{ truncate(account.userId) }}</code></td>
328
+ <td>{{ formatDate(account.createdAt) }}</td>
329
+ <td class="actions">
330
+ <button type="button" @click="copyToClipboard(account.id, 'Account ID')">
331
+ Copy
332
+ </button>
333
+ </td>
334
+ </tr>
335
+ </tbody>
336
+ </table>
337
+ <p v-else class="empty">
338
+ No accounts found
339
+ </p>
351
340
 
352
- <!-- Row 2: Security + Module + Plugins -->
353
- <div class="grid gap-3 md:grid-cols-3">
354
- <div class="config-section">
355
- <div class="config-header">
356
- <UIcon name="i-lucide-shield" class="size-4" /><span>Security</span>
357
- </div>
358
- <div class="config-row">
359
- <span class="config-label">Cookies</span><span class="font-mono">{{ configData.config.server.advanced?.useSecureCookies }}</span>
360
- </div>
361
- <div class="config-row">
362
- <span class="config-label">CSRF</span><UBadge :color="configData.config.server.advanced?.disableCSRFCheck ? 'error' : 'success'" variant="subtle" size="sm">
363
- {{ configData.config.server.advanced?.disableCSRFCheck ? "Off" : "On" }}
364
- </UBadge>
365
- </div>
366
- <div class="config-row">
367
- <span class="config-label">Rate Limit</span><UBadge :color="configData.config.server.rateLimit ? 'success' : 'neutral'" variant="subtle" size="sm">
368
- {{ configData.config.server.rateLimit ? "On" : "Off" }}
369
- </UBadge>
370
- </div>
371
- </div>
372
- <div class="config-section">
373
- <div class="config-header">
374
- <UIcon name="i-lucide-settings-2" class="size-4" /><span>Module</span>
375
- </div>
376
- <div class="config-row">
377
- <span class="config-label">DB</span><UBadge :color="configData.config.module?.databaseProvider === 'none' ? 'neutral' : 'success'" variant="subtle" size="sm">
378
- {{ configData.config.module?.databaseProvider === "nuxthub" ? "Hub" : "Off" }}
379
- </UBadge>
380
- </div>
381
- <div class="config-row">
382
- <span class="config-label">KV</span><UBadge :color="configData.config.module?.hubSecondaryStorage ? 'success' : 'neutral'" variant="subtle" size="sm">
383
- {{ configData.config.module?.hubSecondaryStorage ? configData.config.module.hubSecondaryStorage === "custom" ? "Custom" : "On" : "Off" }}
384
- </UBadge>
385
- </div>
386
- </div>
387
- <div class="config-section">
388
- <div class="config-header">
389
- <UIcon name="i-lucide-puzzle" class="size-4" /><span>Plugins</span>
390
- </div>
391
- <div class="flex flex-wrap gap-1">
392
- <UBadge v-for="plugin in configData.config.server.plugins" :key="plugin" variant="subtle" color="neutral" size="sm">
393
- {{ plugin }}
394
- </UBadge>
395
- <span v-if="!configData.config.server.plugins?.length" class="text-muted-foreground text-sm">None</span>
396
- </div>
397
- </div>
398
- </div>
341
+ <div v-if="(accountsData?.total ?? 0) > pageSize" class="pagination">
342
+ <button type="button" :disabled="accountsPage === 1" @click="pageBack(accountsPage)">
343
+ Previous
344
+ </button>
345
+ <span>Page {{ accountsPage }} of {{ totalPages(accountsData?.total) }}</span>
346
+ <button type="button" :disabled="accountsPage >= totalPages(accountsData?.total)" @click="pageNext(accountsPage, accountsData?.total)">
347
+ Next
348
+ </button>
349
+ </div>
350
+ </section>
399
351
 
400
- <!-- Trusted Origins (if any) -->
401
- <div v-if="configData.config.server.trustedOrigins?.length" class="config-section">
402
- <div class="config-header">
403
- <UIcon name="i-lucide-shield-check" class="size-4" /><span>Trusted Origins</span>
404
- </div>
405
- <div class="flex flex-wrap gap-1">
406
- <UBadge v-for="origin in configData.config.server.trustedOrigins" :key="origin" variant="subtle" color="neutral" size="sm" class="font-mono">
407
- {{ origin }}
408
- </UBadge>
409
- </div>
410
- </div>
411
- </template>
352
+ <section v-else class="section config-grid">
353
+ <div class="toolbar">
354
+ <span />
355
+ <button type="button" @click="copyToClipboard(generateConfigMarkdown(), 'Config')">
356
+ Copy config
357
+ </button>
412
358
  </div>
413
- </template>
414
- </UTabs>
359
+
360
+ <p v-if="configData?.error" class="error">
361
+ {{ configData.error }}
362
+ </p>
363
+ <template v-else>
364
+ <article>
365
+ <h2>Module Config</h2>
366
+ <pre>{{ json(configData?.config?.module) }}</pre>
367
+ </article>
368
+ <article>
369
+ <h2>Server Config</h2>
370
+ <pre>{{ json(configData?.config?.server) }}</pre>
371
+ </article>
372
+ </template>
373
+ </section>
374
+ </main>
415
375
  </div>
416
376
  </template>
417
377
 
418
378
  <style>
419
- :root{--background:#fff;--foreground:#0c0a09;--card:#fff;--card-foreground:#0c0a09;--muted-foreground:#78716c;--border:#e7e5e4;--destructive:#ef4444;--tab-active-bg:#eeedec}.dark{--background:#0c0a09;--foreground:#fafaf9;--card:#0c0a09;--card-foreground:#fafaf9;--muted-foreground:#a8a29e;--border:#292524;--destructive:#7f1d1d;--tab-active-bg:#292524}.bg-background{background-color:var(--background)}.text-foreground{color:var(--foreground)}.text-muted-foreground{color:var(--muted-foreground)}.text-destructive{color:var(--destructive)}.border-border{border-color:var(--border)}.header-link{align-items:center;color:var(--muted-foreground);display:flex;font-size:.75rem;padding:.5rem .75rem;position:relative;transition:color .2s}.header-link:hover{color:var(--foreground)}.header-link:after{background:var(--foreground);bottom:0;content:"";height:1px;left:.75rem;position:absolute;right:.75rem;transform:scaleX(0);transform-origin:left;transition:transform .2s ease-out}.header-link:hover:after{transform:scaleX(1)}.config-section{border:1px solid var(--border);padding:.5rem}.config-header{color:var(--muted-foreground);font-size:.8125rem;font-weight:500;gap:.375rem;margin-bottom:.375rem}.config-header,.config-row{align-items:center;display:flex}.config-row{font-size:.75rem;justify-content:space-between;padding:.125rem 0}.config-label{color:var(--muted-foreground)}
379
+ :root{--ba-bg:#fff;--ba-fg:#1f1f1f;--ba-muted:#707070;--ba-border:#e5e5e5;--ba-soft:#f7f7f7;--ba-hover:#efefef;--ba-good:#047857;--ba-bad:#b91c1c}.dark{--ba-bg:#111;--ba-fg:#f4f4f5;--ba-muted:#a1a1aa;--ba-border:#2d2d2d;--ba-soft:#191919;--ba-hover:#242424;--ba-good:#34d399;--ba-bad:#f87171}*{box-sizing:border-box}body{margin:0}button,input{font:inherit}.devtools-shell{background:var(--ba-bg);color:var(--ba-fg);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-size:13px;min-height:100vh}.devtools-header{border-bottom:1px solid var(--ba-border);gap:16px;justify-content:space-between;min-height:48px;padding:0 16px}.actions,.brand,.confirm,.devtools-header,.links,.pagination,.toolbar,.toolbar-meta{align-items:center;display:flex}.brand{font-weight:600;gap:10px}.brand-mark{height:auto;width:24px}.links a{color:var(--ba-muted);padding:8px 10px;text-decoration:none}.links a:hover{color:var(--ba-fg)}.tabs{border-bottom:1px solid var(--ba-border);display:flex;gap:4px;padding:8px 12px 0}.actions button,.confirm button,.pagination button,.tabs button,.toolbar button{background:transparent;border:1px solid var(--ba-border);color:var(--ba-fg);cursor:pointer;min-height:30px;padding:5px 10px}.tabs button{border-bottom:0}.tabs button.active,button:hover:not(:disabled){background:var(--ba-hover)}button:disabled{color:var(--ba-muted);cursor:default}button.danger{color:var(--ba-bad)}.panel{padding:16px}.section{display:grid;gap:14px}.toolbar{gap:12px;justify-content:space-between}.actions,.confirm,.pagination,.toolbar-meta{gap:8px}input{background:var(--ba-bg);border:1px solid var(--ba-border);color:var(--ba-fg);padding:7px 10px;width:min(340px,100%)}table{border:1px solid var(--ba-border);border-collapse:collapse;width:100%}td,th{border-bottom:1px solid var(--ba-border);padding:9px 10px;text-align:left;vertical-align:top}th{background:var(--ba-soft);font-weight:500}td small,th{color:var(--ba-muted)}td small{display:block;margin-top:3px}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,monospace}.badge{background:var(--ba-soft);border:1px solid var(--ba-border);color:var(--ba-muted);display:inline-flex;padding:2px 8px}.badge.good{color:var(--ba-good)}.badge.bad,.error{color:var(--ba-bad)}.confirm,.empty,.notice{background:var(--ba-soft);border:1px solid var(--ba-border);padding:10px 12px}.empty,.notice{color:var(--ba-muted)}.config-grid{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))}.config-grid .error,.config-grid .toolbar{grid-column:1/-1}article{background:var(--ba-soft);border:1px solid var(--ba-border)}h2{border-bottom:1px solid var(--ba-border);font-size:13px;padding:10px 12px}h2,pre{margin:0}pre{font-size:12px;line-height:1.5;overflow:auto;padding:12px}.capitalize{text-transform:capitalize}@media (max-width:720px){.devtools-header,.toolbar,.toolbar-meta{align-items:stretch;flex-direction:column}.actions,.tabs{flex-wrap:wrap}table{display:block;overflow-x:auto}}
420
380
  </style>
@@ -1,3 +1,4 @@
1
+ export { runWithSessionRefresh } from './app/composables/runWithSessionRefresh.js';
1
2
  export { useAction } from './app/composables/useAction.js';
2
3
  export { useAuthAsyncData } from './app/composables/useAuthAsyncData.js';
3
4
  export { useAuthClient } from './app/composables/useAuthClient.js';
@@ -1,3 +1,4 @@
1
+ export { runWithSessionRefresh } from "./app/composables/runWithSessionRefresh.js";
1
2
  export { useAction } from "./app/composables/useAction.js";
2
3
  export { useAuthAsyncData } from "./app/composables/useAuthAsyncData.js";
3
4
  export { useAuthClient } from "./app/composables/useAuthClient.js";
@@ -0,0 +1 @@
1
+ export declare function shouldSkipAuthRouteRules(path: string): boolean;
@@ -0,0 +1,24 @@
1
+ const internalRouteRuleAuthPaths = /* @__PURE__ */ new Set([
2
+ "/_ipx",
3
+ "/_nuxt",
4
+ "/__better-auth-devtools",
5
+ "/__nuxt_devtools__",
6
+ "/__nuxt_error",
7
+ "/__nuxt_vite_node__",
8
+ "/api/_better-auth",
9
+ "/api/_nuxt_icon",
10
+ "/api/auth"
11
+ ]);
12
+ const internalRouteRuleAuthPrefixes = [
13
+ "/_ipx/",
14
+ "/_nuxt/",
15
+ "/__nuxt_devtools__/",
16
+ "/__nuxt_vite_node__/",
17
+ "/api/_better-auth/",
18
+ "/api/_nuxt_icon/",
19
+ "/api/auth/"
20
+ ];
21
+ export function shouldSkipAuthRouteRules(path) {
22
+ const pathname = path.split(/[?#]/, 1)[0] || "/";
23
+ return internalRouteRuleAuthPaths.has(pathname) || internalRouteRuleAuthPrefixes.some((prefix) => pathname.startsWith(prefix));
24
+ }
@@ -1,12 +1,13 @@
1
1
  import { createError, defineEventHandler, getRequestURL } from "h3";
2
2
  import { getRouteRules } from "#imports";
3
+ import { shouldSkipAuthRouteRules } from "../../internal/auth-route-rules.js";
3
4
  import { matchesUser } from "../../utils/match-user.js";
4
5
  import { getUserSession, requireUserSession } from "../utils/session.js";
5
6
  export default defineEventHandler(async (event) => {
6
7
  const path = getRequestURL(event).pathname;
7
8
  if (!path.startsWith("/api/"))
8
9
  return;
9
- if (path.startsWith("/api/auth/"))
10
+ if (shouldSkipAuthRouteRules(path))
10
11
  return;
11
12
  const rules = getRouteRules(event);
12
13
  if (!rules.auth)
@@ -2,6 +2,7 @@ import type { H3Event } from 'h3';
2
2
  import type { AppSession, AuthSession, RequireSessionOptions } from '#nuxt-better-auth';
3
3
  export declare function getRequestSession(event: H3Event): Promise<AppSession | null>;
4
4
  export declare function getUserSession(event: H3Event): Promise<AppSession | null>;
5
+ export declare function refreshSessionCookieCache(event: H3Event): Promise<AppSession | null>;
5
6
  export declare function setSessionCookie(event: H3Event, token: string): Promise<void>;
6
7
  export declare function createSession(event: H3Event, userId: string): Promise<AuthSession>;
7
8
  export declare function requireUserSession(event: H3Event, options?: RequireSessionOptions): Promise<AppSession>;
@@ -1,4 +1,4 @@
1
- import { createError } from "h3";
1
+ import { createError, splitCookiesString } from "h3";
2
2
  import { matchesUser } from "../../utils/match-user.js";
3
3
  import { serverAuth } from "./auth.js";
4
4
  const requestSessionLoadKey = Symbol.for("nuxt-better-auth.requestSessionLoad");
@@ -23,6 +23,14 @@ function loadSession(event) {
23
23
  const auth = serverAuth(event);
24
24
  return auth.api.getSession({ headers: getRequestHeaders(event) });
25
25
  }
26
+ function loadFreshSession(event) {
27
+ const auth = serverAuth(event);
28
+ return auth.api.getSession({
29
+ headers: getRequestHeaders(event),
30
+ query: { disableCookieCache: true },
31
+ returnHeaders: true
32
+ });
33
+ }
26
34
  function getServerAuthContext(event) {
27
35
  const auth = serverAuth(event);
28
36
  return auth.$context;
@@ -108,6 +116,18 @@ function appendCookieHeader(event, header) {
108
116
  const responseHeaders = event.response?.headers;
109
117
  responseHeaders?.append("set-cookie", header);
110
118
  }
119
+ function getSetCookieHeaders(headers) {
120
+ const getSetCookie = headers.getSetCookie;
121
+ const cookies = getSetCookie?.call(headers);
122
+ if (cookies?.length)
123
+ return cookies.flatMap((cookie) => splitCookiesString(cookie));
124
+ const header = headers.get("set-cookie");
125
+ return header ? splitCookiesString(header) : [];
126
+ }
127
+ function appendSetCookieHeaders(event, headers) {
128
+ for (const header of getSetCookieHeaders(headers))
129
+ appendCookieHeader(event, header);
130
+ }
111
131
  function parseRequestCookies(cookieHeader) {
112
132
  const cookies = /* @__PURE__ */ new Map();
113
133
  if (!cookieHeader)
@@ -192,6 +212,25 @@ export async function getUserSession(event) {
192
212
  return inFlight;
193
213
  return loadSession(event);
194
214
  }
215
+ export async function refreshSessionCookieCache(event) {
216
+ const context = getRequestSessionContext(event);
217
+ const inFlight = context[requestSessionLoadKey];
218
+ if (inFlight)
219
+ await inFlight.catch(() => void 0);
220
+ delete context.requestSession;
221
+ const load = loadFreshSession(event).then(({ headers, response }) => {
222
+ appendSetCookieHeaders(event, headers);
223
+ context.requestSession = response;
224
+ return response;
225
+ });
226
+ context[requestSessionLoadKey] = load;
227
+ try {
228
+ return await load;
229
+ } finally {
230
+ if (context[requestSessionLoadKey] === load)
231
+ delete context[requestSessionLoadKey];
232
+ }
233
+ }
195
234
  export async function setSessionCookie(event, token) {
196
235
  const context = await getServerAuthContext(event);
197
236
  const sessionCookie = await serializeSignedCookie(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@onmax/nuxt-better-auth",
3
3
  "type": "module",
4
- "version": "0.0.2",
4
+ "version": "0.0.3",
5
5
  "packageManager": "pnpm@11.1.0",
6
6
  "description": "Nuxt module for Better Auth integration with NuxtHub, route protection, session management, and role-based access",
7
7
  "author": "onmax",
@@ -44,11 +44,13 @@
44
44
  ],
45
45
  "scripts": {
46
46
  "prepack": "nuxt-module-build build",
47
+ "build": "pnpm build:docs",
47
48
  "dev": "pnpm dev:prepare && nuxi dev playground",
48
49
  "dev:build": "nuxi build playground",
49
50
  "dev:prepare": "nuxt-module-build build --stub && nuxi prepare playground",
50
51
  "dev:docs": "nuxi dev docs",
51
52
  "build:docs": "nuxi build docs",
53
+ "deploy": "wrangler deploy",
52
54
  "release": "bumpp --push --no-push-all",
53
55
  "lint": "eslint .",
54
56
  "lint:fix": "eslint . --fix",
@@ -70,7 +72,6 @@
70
72
  "dependencies": {
71
73
  "@better-auth/cli": "1.5.0-beta.13",
72
74
  "@nuxt/kit": "^4.3.1",
73
- "@nuxt/ui": "^4.5.0",
74
75
  "defu": "^6.1.4",
75
76
  "h3": "^1.15.11",
76
77
  "jiti": "^2.6.1",
@@ -93,7 +94,7 @@
93
94
  "@types/better-sqlite3": "^7.6.13",
94
95
  "@types/node": "latest",
95
96
  "better-auth": "1.5.5",
96
- "better-call": "1.3.2",
97
+ "better-call": "1.3.5",
97
98
  "better-sqlite3": "^12.6.2",
98
99
  "bumpp": "^11.0.0",
99
100
  "changelogen": "^0.6.2",
@@ -113,6 +114,7 @@
113
114
  "vitest-package-exports": "^1.2.0",
114
115
  "vue": "3.5.29",
115
116
  "vue-tsc": "^3.2.7",
117
+ "wrangler": "4.72.0",
116
118
  "yaml": "^2.8.3"
117
119
  }
118
120
  }