@strapi-community/plugin-better-auth-dashboard 1.0.0-alpha.9 → 1.0.0-beta.1

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/README.md CHANGED
@@ -64,6 +64,23 @@ export const auth = betterAuth({
64
64
  });
65
65
  ```
66
66
 
67
+ To enable the "Send verification email" and "Send password reset" actions in the user detail drawer, configure the dashboard plugin itself in `config/plugins.js`/`.ts`:
68
+
69
+ ```typescript title="config/plugins.ts"
70
+ export default {
71
+ "better-auth-dashboard": {
72
+ config: {
73
+ // Absolute URL of your public-facing client app. Used as the
74
+ // callback destination for email verification / password reset
75
+ // links sent from the dashboard. Unset by default — the two
76
+ // actions above stay disabled until this is configured, rather
77
+ // than guessing at a URL (e.g. the admin panel's own origin).
78
+ email_callback_url: process.env.BETTER_AUTH_DASHBOARD_CALLBACK_URL,
79
+ },
80
+ },
81
+ };
82
+ ```
83
+
67
84
  ### Start Strapi
68
85
 
69
86
  ```bash
@@ -80,6 +97,54 @@ The dashboard is now available in the Strapi admin panel.
80
97
  | **Users** | Searchable user table with create, edit, delete, ban, and session revoke per user |
81
98
  | **Organizations** | Organization list with member management (requires Better Auth `organization` plugin) |
82
99
 
100
+ ## Extending the dashboard
101
+
102
+ Other Strapi admin plugins can add sections and lazy-loaded screens through the dashboard plugin's admin API. Call the API during your plugin's `bootstrap` lifecycle:
103
+
104
+ ```typescript
105
+ export default {
106
+ bootstrap(app) {
107
+ const plugin = app.getPlugin("better-auth-dashboard");
108
+ if (!plugin) return;
109
+
110
+ const dashboard = plugin.apis;
111
+
112
+ dashboard.addDashSection({
113
+ id: "my-plugin",
114
+ intlLabel: {
115
+ id: "my-plugin.dashboard.section",
116
+ defaultMessage: "My plugin",
117
+ },
118
+ priority: 20,
119
+ });
120
+
121
+ dashboard.addDashLink("my-plugin", {
122
+ id: "my-plugin.audit-log",
123
+ intlLabel: {
124
+ id: "my-plugin.dashboard.audit-log",
125
+ defaultMessage: "Audit log",
126
+ },
127
+ to: "/audit-log",
128
+ Component: () => import("./pages/AuditLog"),
129
+ permissions: [],
130
+ isAvailable: async () => checkAuditLogAvailability(),
131
+ });
132
+ },
133
+ };
134
+ ```
135
+
136
+ Available APIs:
137
+
138
+ | API | Description |
139
+ | --- | --- |
140
+ | `addDashSection(section)` | Creates a navigation section. `priority` controls section order. |
141
+ | `addDashLink(section, link)` | Registers a lazy route and adds its link to a section. Supports Strapi admin `permissions` and sync or async `isAvailable`. |
142
+ | `getDashLinks()` | Returns the live section and link registry. Treat the result as read-only. |
143
+
144
+ `isAvailable` defaults to `true`. Its result is cached for five minutes, combined with permission checks, and applied to both navigation and direct route access. Errors are treated as unavailable.
145
+
146
+ See the [Dashboard documentation](https://strapi-community.github.io/plugin-better-auth/docs/better-auth/dashboard#dashboard-screen-api) for the complete API reference and extension guidance.
147
+
83
148
  ## Requirements
84
149
 
85
150
  - `@strapi-community/plugin-better-auth` installed and configured
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ const jsxRuntime = require("react/jsx-runtime");
3
+ const designSystem = require("@strapi/design-system");
4
+ const styled = require("styled-components");
5
+ const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
6
+ const styled__default = /* @__PURE__ */ _interopDefault(styled);
7
+ const PALETTE = [
8
+ { bg: "#EAF5FF", fg: "#0C75AF" },
9
+ { bg: "#F0F0FF", fg: "#4945FF" },
10
+ { bg: "#EAFBE7", fg: "#328048" },
11
+ { bg: "#FFF3D3", fg: "#8E6A00" },
12
+ { bg: "#FCECEA", fg: "#D02B20" },
13
+ { bg: "#F6ECFC", fg: "#8312D1" },
14
+ { bg: "#E8F8F5", fg: "#1A7F64" }
15
+ ];
16
+ const Circle = styled__default.default.div`
17
+ width: ${(p) => p.$size}px;
18
+ height: ${(p) => p.$size}px;
19
+ border-radius: 50%;
20
+ background: ${(p) => p.$bg};
21
+ color: ${(p) => p.$fg};
22
+ display: inline-flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ font-size: ${(p) => Math.round(p.$size * 0.38)}px;
26
+ font-weight: 700;
27
+ flex-shrink: 0;
28
+ letter-spacing: -0.01em;
29
+ user-select: none;
30
+ `;
31
+ function getColor(seed) {
32
+ const code = (seed.charCodeAt(0) || 0) + (seed.charCodeAt(1) || 0);
33
+ return PALETTE[code % PALETTE.length];
34
+ }
35
+ function getInitials(name) {
36
+ const parts = name.trim().split(/\s+/);
37
+ if (parts.length === 0 || !parts[0]) return "?";
38
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
39
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
40
+ }
41
+ function Avatar({
42
+ name,
43
+ src,
44
+ size = 32
45
+ }) {
46
+ if (src) {
47
+ return /* @__PURE__ */ jsxRuntime.jsx(
48
+ designSystem.Box,
49
+ {
50
+ tag: "img",
51
+ src,
52
+ alt: "",
53
+ style: {
54
+ width: size,
55
+ height: size,
56
+ borderRadius: "50%",
57
+ objectFit: "cover",
58
+ flexShrink: 0,
59
+ display: "block"
60
+ }
61
+ }
62
+ );
63
+ }
64
+ const color = getColor(name || "?");
65
+ return /* @__PURE__ */ jsxRuntime.jsx(Circle, { $bg: color.bg, $fg: color.fg, $size: size, children: getInitials(name || "?") });
66
+ }
67
+ exports.Avatar = Avatar;
@@ -0,0 +1,66 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import { Box } from "@strapi/design-system";
3
+ import styled from "styled-components";
4
+ const PALETTE = [
5
+ { bg: "#EAF5FF", fg: "#0C75AF" },
6
+ { bg: "#F0F0FF", fg: "#4945FF" },
7
+ { bg: "#EAFBE7", fg: "#328048" },
8
+ { bg: "#FFF3D3", fg: "#8E6A00" },
9
+ { bg: "#FCECEA", fg: "#D02B20" },
10
+ { bg: "#F6ECFC", fg: "#8312D1" },
11
+ { bg: "#E8F8F5", fg: "#1A7F64" }
12
+ ];
13
+ const Circle = styled.div`
14
+ width: ${(p) => p.$size}px;
15
+ height: ${(p) => p.$size}px;
16
+ border-radius: 50%;
17
+ background: ${(p) => p.$bg};
18
+ color: ${(p) => p.$fg};
19
+ display: inline-flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ font-size: ${(p) => Math.round(p.$size * 0.38)}px;
23
+ font-weight: 700;
24
+ flex-shrink: 0;
25
+ letter-spacing: -0.01em;
26
+ user-select: none;
27
+ `;
28
+ function getColor(seed) {
29
+ const code = (seed.charCodeAt(0) || 0) + (seed.charCodeAt(1) || 0);
30
+ return PALETTE[code % PALETTE.length];
31
+ }
32
+ function getInitials(name) {
33
+ const parts = name.trim().split(/\s+/);
34
+ if (parts.length === 0 || !parts[0]) return "?";
35
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
36
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
37
+ }
38
+ function Avatar({
39
+ name,
40
+ src,
41
+ size = 32
42
+ }) {
43
+ if (src) {
44
+ return /* @__PURE__ */ jsx(
45
+ Box,
46
+ {
47
+ tag: "img",
48
+ src,
49
+ alt: "",
50
+ style: {
51
+ width: size,
52
+ height: size,
53
+ borderRadius: "50%",
54
+ objectFit: "cover",
55
+ flexShrink: 0,
56
+ display: "block"
57
+ }
58
+ }
59
+ );
60
+ }
61
+ const color = getColor(name || "?");
62
+ return /* @__PURE__ */ jsx(Circle, { $bg: color.bg, $fg: color.fg, $size: size, children: getInitials(name || "?") });
63
+ }
64
+ export {
65
+ Avatar as A
66
+ };
@@ -0,0 +1,357 @@
1
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
2
+ import { useId, useState, useEffect, useMemo } from "react";
3
+ import { useQueryClient, QueryClient, QueryClientProvider } from "react-query";
4
+ import { NavLink, Outlet, useMatch, Navigate } from "react-router-dom";
5
+ import { g as getDashLinks, P as PLUGIN_ID, a as getDashLinkAvailabilityQueryKey, r as resolveDashLinkAvailability, D as DASH_LINK_AVAILABILITY_STALE_TIME } from "./index-D9CiI8Yr.mjs";
6
+ import { Typography, ScrollArea, Box, Flex, Badge, SubNav as SubNav$1, Divider } from "@strapi/design-system";
7
+ import { useAuth, Layouts, Page, BackButton } from "@strapi/strapi/admin";
8
+ import { useIntl } from "react-intl";
9
+ import "@strapi/icons";
10
+ import styled from "styled-components";
11
+ const HEIGHT_TOP_NAVIGATION = "6.4rem";
12
+ const HEIGHT_TOP_NAVIGATION_MEDIUM = "5.6rem";
13
+ const WIDTH_SIDE_NAVIGATION = "23.2rem";
14
+ const MainSubNav = styled(SubNav$1)`
15
+ width: 100%;
16
+ height: 100%;
17
+ overflow: hidden;
18
+ background-color: ${({ theme }) => theme.colors.neutral0};
19
+ display: flex;
20
+ flex-direction: column;
21
+ border-right: 0;
22
+ box-shadow: none;
23
+ position: relative;
24
+
25
+ ${({ theme }) => theme.breakpoints.medium} {
26
+ width: ${WIDTH_SIDE_NAVIGATION};
27
+ position: sticky;
28
+ top: 0;
29
+ border-right: 1px solid ${({ theme }) => theme.colors.neutral150};
30
+ overscroll-behavior: contain;
31
+ }
32
+ `;
33
+ const Main = ({ children, ...props }) => /* @__PURE__ */ jsx(MainSubNav, { ...props, children });
34
+ const StyledLink = styled(NavLink)`
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: space-between;
38
+ text-decoration: none;
39
+ height: 32px;
40
+ color: ${({ theme }) => theme.colors.neutral800};
41
+
42
+ &.active > div {
43
+ background-color: ${({ theme }) => theme.colors.primary100};
44
+ color: ${({ theme }) => theme.colors.primary700};
45
+ font-weight: 500;
46
+ }
47
+
48
+ &:hover.active > div {
49
+ background-color: ${({ theme }) => theme.colors.primary100};
50
+ color: ${({ theme }) => theme.colors.primary700};
51
+ font-weight: 500;
52
+ }
53
+
54
+ &:hover > div {
55
+ background-color: ${({ theme }) => theme.colors.neutral100};
56
+ }
57
+
58
+ &:focus-visible {
59
+ outline-offset: -2px;
60
+ }
61
+ `;
62
+ const Link = (props) => {
63
+ const { to, label, endAction, handleClick, ...rest } = props;
64
+ return /* @__PURE__ */ jsx(StyledLink, { to, ...rest, onClick: handleClick, children: /* @__PURE__ */ jsx(
65
+ Box,
66
+ {
67
+ width: "100%",
68
+ paddingLeft: 3,
69
+ paddingRight: 3,
70
+ paddingTop: { initial: 1, large: 0 },
71
+ paddingBottom: { initial: 1, large: 0 },
72
+ borderRadius: 1,
73
+ children: /* @__PURE__ */ jsxs(
74
+ Flex,
75
+ {
76
+ justifyContent: "space-between",
77
+ width: "100%",
78
+ gap: { initial: 2, large: 1 },
79
+ children: [
80
+ /* @__PURE__ */ jsx(
81
+ Typography,
82
+ {
83
+ tag: "div",
84
+ lineHeight: "32px",
85
+ width: { initial: "80dvw", medium: "100%" },
86
+ overflow: "hidden",
87
+ style: { textOverflow: "ellipsis", whiteSpace: "nowrap" },
88
+ children: label
89
+ }
90
+ ),
91
+ /* @__PURE__ */ jsx(Flex, { gap: 2, children: endAction })
92
+ ]
93
+ }
94
+ )
95
+ }
96
+ ) });
97
+ };
98
+ const StyledHeader = styled(Flex)`
99
+ flex: 0 0 ${HEIGHT_TOP_NAVIGATION};
100
+ height: ${HEIGHT_TOP_NAVIGATION};
101
+
102
+ ${({ theme }) => theme.breakpoints.medium} {
103
+ flex: 0 0 ${HEIGHT_TOP_NAVIGATION_MEDIUM};
104
+ height: ${HEIGHT_TOP_NAVIGATION_MEDIUM};
105
+ }
106
+ `;
107
+ const Header = ({ label }) => {
108
+ return /* @__PURE__ */ jsx(
109
+ StyledHeader,
110
+ {
111
+ justifyContent: "space-between",
112
+ paddingLeft: 5,
113
+ paddingRight: 5,
114
+ children: /* @__PURE__ */ jsx(Typography, { variant: "beta", tag: "h2", children: label })
115
+ }
116
+ );
117
+ };
118
+ const Sections = ({
119
+ children,
120
+ ...props
121
+ }) => {
122
+ return /* @__PURE__ */ jsx(
123
+ Box,
124
+ {
125
+ paddingTop: { initial: 5, large: 4 },
126
+ paddingBottom: { initial: 5, large: 4 },
127
+ paddingLeft: { initial: 3, large: 0 },
128
+ paddingRight: { initial: 3, large: 0 },
129
+ maxWidth: { initial: "100%", medium: WIDTH_SIDE_NAVIGATION },
130
+ children: /* @__PURE__ */ jsx(Flex, { tag: "ul", gap: 6, direction: "column", alignItems: "stretch", ...props, children: children.map((child, index) => {
131
+ return /* @__PURE__ */ jsx("li", { children: child }, index);
132
+ }) })
133
+ }
134
+ );
135
+ };
136
+ const Section = ({
137
+ label,
138
+ children,
139
+ badgeLabel
140
+ }) => {
141
+ const listId = useId();
142
+ return /* @__PURE__ */ jsxs(Flex, { direction: "column", alignItems: "stretch", gap: 2, children: [
143
+ /* @__PURE__ */ jsx(
144
+ Box,
145
+ {
146
+ paddingLeft: {
147
+ initial: 3,
148
+ large: 5
149
+ },
150
+ paddingRight: {
151
+ initial: 3,
152
+ large: 5
153
+ },
154
+ children: /* @__PURE__ */ jsxs(Flex, { position: "relative", justifyContent: "space-between", gap: 2, children: [
155
+ /* @__PURE__ */ jsx(Flex, { children: /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Typography, { variant: "sigma", textColor: "neutral600", children: label }) }) }),
156
+ /* @__PURE__ */ jsx(Flex, { gap: 1, children: badgeLabel && /* @__PURE__ */ jsx(Badge, { backgroundColor: "neutral150", textColor: "neutral600", children: badgeLabel }) })
157
+ ] })
158
+ }
159
+ ),
160
+ /* @__PURE__ */ jsx(
161
+ Flex,
162
+ {
163
+ tag: "ol",
164
+ id: listId,
165
+ direction: "column",
166
+ gap: { initial: 2, large: "2px" },
167
+ alignItems: "stretch",
168
+ marginLeft: {
169
+ initial: 0,
170
+ large: 2
171
+ },
172
+ marginRight: {
173
+ initial: 0,
174
+ large: 2
175
+ },
176
+ children: children.map((child, index) => {
177
+ return /* @__PURE__ */ jsx("li", { children: child }, index);
178
+ })
179
+ }
180
+ )
181
+ ] });
182
+ };
183
+ styled.button`
184
+ cursor: pointer;
185
+ width: 100%;
186
+ border: none;
187
+ padding: 0;
188
+ background: transparent;
189
+ display: flex;
190
+ align-items: center;
191
+ border-radius: ${({ theme }) => theme.borderRadius};
192
+ padding-left: ${({ theme }) => theme.spaces[3]};
193
+ padding-right: ${({ theme }) => theme.spaces[3]};
194
+ padding-top: ${({ theme }) => theme.spaces[2]};
195
+ padding-bottom: ${({ theme }) => theme.spaces[2]};
196
+
197
+ &:hover {
198
+ background-color: ${({ theme }) => theme.colors.neutral100};
199
+ }
200
+ `;
201
+ styled.li`
202
+ ${StyledLink} > div {
203
+ padding-left: 36px;
204
+ }
205
+ `;
206
+ styled(Box)`
207
+ width: 100%;
208
+
209
+ ${MainSubNav} {
210
+ background-color: transparent;
211
+ border-right: none;
212
+ }
213
+
214
+ ${({ theme }) => theme.breakpoints.medium} {
215
+ overflow: hidden;
216
+
217
+ ${MainSubNav} {
218
+ top: 0;
219
+ }
220
+ }
221
+ `;
222
+ const Content = ({ children }) => {
223
+ return /* @__PURE__ */ jsx(ScrollArea, { children });
224
+ };
225
+ const SubNav = {
226
+ Main,
227
+ Content,
228
+ Header,
229
+ Link,
230
+ Sections,
231
+ Section
232
+ };
233
+ const Nav = ({ isFullPage = false }) => {
234
+ const { formatMessage } = useIntl();
235
+ const queryClient = useQueryClient();
236
+ const checkUserHasPermissions = useAuth(
237
+ "DashboardNav",
238
+ (state) => state.checkUserHasPermissions
239
+ );
240
+ const dashLinks = getDashLinks();
241
+ const [sections, setSections] = useState([]);
242
+ useEffect(() => {
243
+ let isCurrent = true;
244
+ const loadAuthorizedSections = async () => {
245
+ const authorizedSections = await Promise.all(
246
+ Object.values(dashLinks).map(async (section) => ({
247
+ ...section,
248
+ links: (await Promise.all(
249
+ section.links.map(async (link) => {
250
+ const permissions = link.permissions ?? [];
251
+ const [hasPermission, isAvailable] = await Promise.all([
252
+ permissions.length === 0 ? true : checkUserHasPermissions(permissions).then(
253
+ (authorizedPermissions) => authorizedPermissions.length > 0
254
+ ),
255
+ queryClient.fetchQuery(
256
+ getDashLinkAvailabilityQueryKey(link),
257
+ () => resolveDashLinkAvailability(link),
258
+ { staleTime: DASH_LINK_AVAILABILITY_STALE_TIME }
259
+ )
260
+ ]);
261
+ return hasPermission && isAvailable ? link : null;
262
+ })
263
+ )).filter((link) => link !== null)
264
+ }))
265
+ );
266
+ if (isCurrent) {
267
+ setSections(
268
+ authorizedSections.filter((section) => section.links.length > 0).sort(
269
+ (a, b) => (a.priority ?? 0) - (b.priority ?? 0) || a.id.localeCompare(b.id)
270
+ )
271
+ );
272
+ }
273
+ };
274
+ loadAuthorizedSections();
275
+ return () => {
276
+ isCurrent = false;
277
+ };
278
+ }, [checkUserHasPermissions, dashLinks, queryClient]);
279
+ return /* @__PURE__ */ jsxs(SubNav.Main, { children: [
280
+ !isFullPage && /* @__PURE__ */ jsxs(Fragment, { children: [
281
+ /* @__PURE__ */ jsx(SubNav.Header, { label: "Authentication" }),
282
+ /* @__PURE__ */ jsx(Divider, {})
283
+ ] }),
284
+ /* @__PURE__ */ jsxs(SubNav.Content, { children: [
285
+ isFullPage && /* @__PURE__ */ jsxs(Fragment, { children: [
286
+ /* @__PURE__ */ jsx(SubNav.Header, { label: "Authentication" }),
287
+ /* @__PURE__ */ jsx(Divider, {})
288
+ ] }),
289
+ /* @__PURE__ */ jsx(SubNav.Sections, { "data-testid": "main-nav", children: sections.map((section) => /* @__PURE__ */ jsx(
290
+ SubNav.Section,
291
+ {
292
+ label: formatMessage(section.intlLabel),
293
+ children: section.links.map((link) => {
294
+ return /* @__PURE__ */ jsx(
295
+ SubNav.Link,
296
+ {
297
+ to: `/plugins/${PLUGIN_ID}/${link.to.replace(/^\/+/, "")}`,
298
+ label: formatMessage(link.intlLabel),
299
+ "data-testid": `nav-${link.id}`,
300
+ children: formatMessage(link.intlLabel)
301
+ },
302
+ link.id
303
+ );
304
+ })
305
+ },
306
+ section.id
307
+ )) })
308
+ ] })
309
+ ] });
310
+ };
311
+ const RESPONSIVE_DEFAULT_SPACING = {
312
+ initial: 4,
313
+ medium: 6,
314
+ large: 10
315
+ };
316
+ const App = () => {
317
+ return /* @__PURE__ */ jsx(
318
+ "div",
319
+ {
320
+ "data-testid": "dashboard-root",
321
+ style: { display: "flex", flex: "1 1 auto", minHeight: "100vh" },
322
+ children: /* @__PURE__ */ jsxs(Layouts.Root, { sideNav: /* @__PURE__ */ jsx(Nav, {}), children: [
323
+ /* @__PURE__ */ jsx(Page.Title, { children: "Authentication" }),
324
+ /* @__PURE__ */ jsx(
325
+ Box,
326
+ {
327
+ display: { initial: "block", medium: "none" },
328
+ paddingLeft: RESPONSIVE_DEFAULT_SPACING,
329
+ paddingRight: RESPONSIVE_DEFAULT_SPACING,
330
+ paddingTop: RESPONSIVE_DEFAULT_SPACING,
331
+ children: /* @__PURE__ */ jsx(BackButton, { fallback: "/plugins/better-auth-dashboard" })
332
+ }
333
+ ),
334
+ /* @__PURE__ */ jsx(Outlet, {})
335
+ ] })
336
+ }
337
+ );
338
+ };
339
+ function Root() {
340
+ const isDashboardIndex = useMatch({
341
+ path: `/plugins/${PLUGIN_ID}`,
342
+ end: true
343
+ });
344
+ const queryClient = useMemo(
345
+ () => new QueryClient({
346
+ defaultOptions: {
347
+ queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 3e4 }
348
+ }
349
+ }),
350
+ []
351
+ );
352
+ return /* @__PURE__ */ jsx(QueryClientProvider, { client: queryClient, children: isDashboardIndex ? /* @__PURE__ */ jsx(Navigate, { to: "overview", replace: true }) : /* @__PURE__ */ jsx(App, {}) });
353
+ }
354
+ export {
355
+ Root,
356
+ Root as default
357
+ };