@onmax/nuxt-better-auth 0.0.2-alpha.9 → 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.
Files changed (74) hide show
  1. package/README.md +57 -17
  2. package/dist/module.d.mts +28 -1
  3. package/dist/module.json +3 -3
  4. package/dist/module.mjs +1258 -351
  5. package/dist/runtime/app/components/BetterAuthState.d.vue.ts +4 -4
  6. package/dist/runtime/app/components/BetterAuthState.vue +1 -0
  7. package/dist/runtime/app/components/BetterAuthState.vue.d.ts +4 -4
  8. package/dist/runtime/app/composables/runWithSessionRefresh.d.ts +1 -0
  9. package/dist/runtime/app/composables/runWithSessionRefresh.js +12 -0
  10. package/dist/runtime/app/composables/useAction.d.ts +2 -0
  11. package/dist/runtime/app/composables/useAction.js +6 -0
  12. package/dist/runtime/app/composables/useAuthAsyncData.d.ts +6 -0
  13. package/dist/runtime/app/composables/useAuthAsyncData.js +16 -0
  14. package/dist/runtime/app/composables/useAuthClient.d.ts +9 -0
  15. package/dist/runtime/app/composables/useAuthClient.js +34 -0
  16. package/dist/runtime/app/composables/useAuthClientAction.d.ts +3 -0
  17. package/dist/runtime/app/composables/useAuthClientAction.js +15 -0
  18. package/dist/runtime/app/composables/useAuthRequestFetch.d.ts +11 -0
  19. package/dist/runtime/app/composables/useAuthRequestFetch.js +4 -0
  20. package/dist/runtime/app/composables/useSignIn.d.ts +16 -0
  21. package/dist/runtime/app/composables/useSignIn.js +8 -0
  22. package/dist/runtime/app/composables/useSignUp.d.ts +5 -0
  23. package/dist/runtime/app/composables/useSignUp.js +8 -0
  24. package/dist/runtime/app/composables/useUserSession.d.ts +6 -5
  25. package/dist/runtime/app/composables/useUserSession.js +189 -100
  26. package/dist/runtime/app/composables/useUserSessionState.d.ts +3 -0
  27. package/dist/runtime/app/composables/useUserSessionState.js +4 -0
  28. package/dist/runtime/app/internal/auth-action-error.d.ts +3 -0
  29. package/dist/runtime/app/internal/auth-action-error.js +33 -0
  30. package/dist/runtime/app/internal/auth-action-handles.d.ts +21 -0
  31. package/dist/runtime/app/internal/auth-action-handles.js +105 -0
  32. package/dist/runtime/app/internal/redirect-helpers.d.ts +4 -0
  33. package/dist/runtime/app/internal/redirect-helpers.js +37 -0
  34. package/dist/runtime/app/internal/session-fetch.d.ts +12 -0
  35. package/dist/runtime/app/internal/session-fetch.js +56 -0
  36. package/dist/runtime/app/internal/utils.d.ts +1 -0
  37. package/dist/runtime/app/internal/utils.js +3 -0
  38. package/dist/runtime/app/internal/vue-safe-auth-proxy.d.ts +3 -0
  39. package/dist/runtime/app/internal/vue-safe-auth-proxy.js +68 -0
  40. package/dist/runtime/app/internal/wrap-auth-method.d.ts +18 -0
  41. package/dist/runtime/app/internal/wrap-auth-method.js +69 -0
  42. package/dist/runtime/app/middleware/auth.global.js +80 -15
  43. package/dist/runtime/app/pages/__better-auth-devtools.vue +293 -339
  44. package/dist/runtime/composables.d.ts +12 -0
  45. package/dist/runtime/composables.js +10 -0
  46. package/dist/runtime/config.d.ts +69 -15
  47. package/dist/runtime/config.js +9 -2
  48. package/dist/runtime/internal/auth-route-rules.d.ts +1 -0
  49. package/dist/runtime/internal/auth-route-rules.js +24 -0
  50. package/dist/runtime/server/api/_better-auth/_schema.d.ts +1 -5
  51. package/dist/runtime/server/api/_better-auth/_schema.js +2 -1
  52. package/dist/runtime/server/api/_better-auth/accounts.get.d.ts +2 -2
  53. package/dist/runtime/server/api/_better-auth/accounts.get.js +3 -2
  54. package/dist/runtime/server/api/_better-auth/config.get.d.ts +9 -3
  55. package/dist/runtime/server/api/_better-auth/config.get.js +18 -6
  56. package/dist/runtime/server/api/_better-auth/sessions.delete.js +3 -2
  57. package/dist/runtime/server/api/_better-auth/sessions.get.d.ts +5 -3
  58. package/dist/runtime/server/api/_better-auth/sessions.get.js +3 -2
  59. package/dist/runtime/server/api/_better-auth/users.get.d.ts +2 -2
  60. package/dist/runtime/server/api/_better-auth/users.get.js +3 -2
  61. package/dist/runtime/server/api/auth/[...all].js +1 -1
  62. package/dist/runtime/server/middleware/route-access.js +4 -2
  63. package/dist/runtime/server/tsconfig.json +9 -1
  64. package/dist/runtime/server/utils/auth.d.ts +16 -8
  65. package/dist/runtime/server/utils/auth.js +229 -23
  66. package/dist/runtime/server/utils/custom-secondary-storage.d.ts +6 -0
  67. package/dist/runtime/server/utils/custom-secondary-storage.js +8 -0
  68. package/dist/runtime/server/utils/session.d.ts +7 -8
  69. package/dist/runtime/server/utils/session.js +256 -5
  70. package/dist/runtime/server/virtual-modules.d.ts +27 -0
  71. package/dist/runtime/types/augment.d.ts +18 -4
  72. package/dist/runtime/types.d.ts +12 -13
  73. package/dist/types.d.mts +1 -1
  74. package/package.json +53 -47
@@ -1,426 +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">Login</span><span class="font-mono">{{ configData.config.module?.redirects?.login }}</span>
378
- </div>
379
- <div class="config-row">
380
- <span class="config-label">Guest</span><span class="font-mono">{{ configData.config.module?.redirects?.guest }}</span>
381
- </div>
382
- <div class="config-row">
383
- <span class="config-label">DB</span><UBadge :color="configData.config.module?.useDatabase ? 'success' : 'neutral'" variant="subtle" size="sm">
384
- {{ configData.config.module?.useDatabase ? "Hub" : "Off" }}
385
- </UBadge>
386
- </div>
387
- <div class="config-row">
388
- <span class="config-label">KV</span><UBadge :color="configData.config.module?.secondaryStorage ? 'success' : 'neutral'" variant="subtle" size="sm">
389
- {{ configData.config.module?.secondaryStorage ? "On" : "Off" }}
390
- </UBadge>
391
- </div>
392
- </div>
393
- <div class="config-section">
394
- <div class="config-header">
395
- <UIcon name="i-lucide-puzzle" class="size-4" /><span>Plugins</span>
396
- </div>
397
- <div class="flex flex-wrap gap-1">
398
- <UBadge v-for="plugin in configData.config.server.plugins" :key="plugin" variant="subtle" color="neutral" size="sm">
399
- {{ plugin }}
400
- </UBadge>
401
- <span v-if="!configData.config.server.plugins?.length" class="text-muted-foreground text-sm">None</span>
402
- </div>
403
- </div>
404
- </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>
405
351
 
406
- <!-- Trusted Origins (if any) -->
407
- <div v-if="configData.config.server.trustedOrigins?.length" class="config-section">
408
- <div class="config-header">
409
- <UIcon name="i-lucide-shield-check" class="size-4" /><span>Trusted Origins</span>
410
- </div>
411
- <div class="flex flex-wrap gap-1">
412
- <UBadge v-for="origin in configData.config.server.trustedOrigins" :key="origin" variant="subtle" color="neutral" size="sm" class="font-mono">
413
- {{ origin }}
414
- </UBadge>
415
- </div>
416
- </div>
417
- </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>
418
358
  </div>
419
- </template>
420
- </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>
421
375
  </div>
422
376
  </template>
423
377
 
424
378
  <style>
425
- :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}}
426
380
  </style>