xladmin 0.5.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,7 @@ You also need one router adapter:
34
34
  - `createAxiosAdminClient(...)`
35
35
  - `createFetchAdminClient(...)`
36
36
  - `createBrowserAdminRouter(...)`
37
+ - `AdminCurrentUser`
37
38
  - admin types, i18n helpers, and default theme
38
39
 
39
40
  ## Minimal Example
@@ -67,6 +68,49 @@ export function AdminApp() {
67
68
 
68
69
  For framework routing, use one of the adapter packages instead of the default browser router.
69
70
 
71
+ ## Current User And Logout
72
+
73
+ `Shell` shows a compact current-user panel pinned to the bottom of the sidebar when a user is available.
74
+ Long login values wrap inside the panel instead of overflowing the sidebar.
75
+ By default it calls:
76
+
77
+ - `client.getCurrentUser()` -> `GET /xladmin/me/`
78
+ - `client.logout()` -> `POST /xladmin/logout/`
79
+
80
+ After logout, `Shell` redirects to `/login`.
81
+
82
+ ```tsx
83
+ <Shell
84
+ client={client}
85
+ models={models}
86
+ blocks={blocks}
87
+ basePath="/admin"
88
+ loginPath="/login"
89
+ >
90
+ {content}
91
+ </Shell>
92
+ ```
93
+
94
+ If your application already has the user or a custom logout flow, pass them explicitly:
95
+
96
+ ```tsx
97
+ <Shell
98
+ client={client}
99
+ models={models}
100
+ blocks={blocks}
101
+ basePath="/admin"
102
+ currentUser={{login: auth.user.email}}
103
+ onLogout={async () => {
104
+ await auth.logout();
105
+ }}
106
+ loginPath="/sign-in"
107
+ >
108
+ {content}
109
+ </Shell>
110
+ ```
111
+
112
+ Set `currentUser={null}` to hide the sidebar user panel.
113
+
70
114
  ## Development
71
115
 
72
116
  ```bash
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AdminChoicesResponse, AdminDeletePreviewResponse, AdminDetailResponse, AdminListResponse, AdminModelMeta, AdminModelsResponse, AdminObjectActionResponse } from './types';
1
+ import type { AdminChoicesResponse, AdminDeletePreviewResponse, AdminDetailResponse, AdminCurrentUser, AdminListResponse, AdminModelMeta, AdminModelsResponse, AdminObjectActionResponse } from './types';
2
2
  export type AdminRequestOptions = {
3
3
  signal?: AbortSignal;
4
4
  };
@@ -11,6 +11,8 @@ export type AdminSelectionOptions = {
11
11
  selectionScope?: AdminSelectionScope;
12
12
  };
13
13
  export type AdminClient = {
14
+ getCurrentUser: () => Promise<AdminCurrentUser>;
15
+ logout: () => Promise<void>;
14
16
  getModels: () => Promise<AdminModelsResponse>;
15
17
  getModel: (slug: string) => Promise<AdminModelMeta>;
16
18
  getItems: (slug: string, params?: {
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
2
2
  import { type Theme } from '@mui/material/styles';
3
3
  import type { AdminClient } from '../client';
4
4
  import type { AdminRouter } from '../router';
5
- import type { AdminModelMeta, AdminModelsBlockMeta } from '../types';
5
+ import type { AdminCurrentUser, AdminModelMeta, AdminModelsBlockMeta } from '../types';
6
6
  type AdminShellProps = {
7
7
  client: AdminClient;
8
8
  models: AdminModelMeta[];
@@ -12,7 +12,10 @@ type AdminShellProps = {
12
12
  children: ReactNode;
13
13
  theme?: Theme;
14
14
  router?: AdminRouter;
15
+ currentUser?: AdminCurrentUser | string | null;
16
+ loginPath?: string | null;
17
+ onLogout?: () => void | Promise<void>;
15
18
  };
16
19
  export type ShellProps = AdminShellProps;
17
- export declare function Shell({ client, models, blocks, basePath, locale, children, theme, router }: ShellProps): import("react/jsx-runtime").JSX.Element;
20
+ export declare function Shell({ client, models, blocks, basePath, locale, children, theme, router, currentUser, loginPath, onLogout, }: ShellProps): import("react/jsx-runtime").JSX.Element;
18
21
  export {};
@@ -1,8 +1,11 @@
1
- import type { AdminModelMeta, AdminModelsBlockMeta } from '@xladmin-core/types';
1
+ import type { AdminCurrentUser, AdminModelMeta, AdminModelsBlockMeta } from '@xladmin-core/types';
2
2
  type SidebarProps = {
3
3
  models: AdminModelMeta[];
4
4
  blocks: AdminModelsBlockMeta[];
5
5
  basePath: string;
6
+ currentUser?: AdminCurrentUser | null;
7
+ isLoggingOut?: boolean;
8
+ onLogout?: () => void;
6
9
  };
7
10
  export declare const Sidebar: import("react").NamedExoticComponent<SidebarProps>;
8
11
  export {};
package/dist/index.js CHANGED
@@ -1,6 +1,12 @@
1
1
  // src/client.ts
2
2
  function createAdminClient(transport) {
3
3
  return {
4
+ async getCurrentUser() {
5
+ return await transportGet(transport, "/xladmin/me/");
6
+ },
7
+ async logout() {
8
+ await transportPost(transport, "/xladmin/logout/", {});
9
+ },
4
10
  async getModels() {
5
11
  return await transportGet(transport, "/xladmin/models/");
6
12
  },
@@ -148,7 +154,14 @@ function isWrappedTransportResponse(response) {
148
154
  }
149
155
  async function requestJson(fetchImpl, config, method, url, body, params, signal) {
150
156
  const response = await requestRaw(fetchImpl, config, method, url, body, params, signal);
151
- return await response.json();
157
+ if (response.status === 204) {
158
+ return void 0;
159
+ }
160
+ const text = await response.text();
161
+ if (!text.trim()) {
162
+ return void 0;
163
+ }
164
+ return JSON.parse(text);
152
165
  }
153
166
  async function requestRaw(fetchImpl, config, method, url, body, params, signal) {
154
167
  var _a;
@@ -5063,7 +5076,7 @@ function ObjectPage({ client, slug, id, router }) {
5063
5076
  }
5064
5077
 
5065
5078
  // src/components/Shell.tsx
5066
- import { useEffect as useEffect14, useMemo as useMemo11, useState as useState13 } from "react";
5079
+ import { useCallback as useCallback8, useEffect as useEffect14, useMemo as useMemo11, useState as useState13 } from "react";
5067
5080
  import { Box as Box17, CssBaseline, Drawer, GlobalStyles, Stack as Stack14, useMediaQuery as useMediaQuery4 } from "@mui/material";
5068
5081
  import { ThemeProvider } from "@mui/material/styles";
5069
5082
 
@@ -5234,7 +5247,8 @@ function Main({ children }) {
5234
5247
 
5235
5248
  // src/components/layout/Sidebar.tsx
5236
5249
  import { memo as memo8, useEffect as useEffect13, useRef as useRef7 } from "react";
5237
- import { Box as Box16, ListItemButton as ListItemButton3, Typography as Typography8 } from "@mui/material";
5250
+ import LogoutIcon from "@mui/icons-material/Logout";
5251
+ import { Box as Box16, IconButton as IconButton5, ListItemButton as ListItemButton3, Tooltip as Tooltip2, Typography as Typography8 } from "@mui/material";
5238
5252
  import { jsx as jsx27, jsxs as jsxs19 } from "react/jsx-runtime";
5239
5253
  function getOffsetTopWithinContainer(element, container) {
5240
5254
  let offsetTop = element.offsetTop;
@@ -5245,7 +5259,14 @@ function getOffsetTopWithinContainer(element, container) {
5245
5259
  }
5246
5260
  return offsetTop;
5247
5261
  }
5248
- var Sidebar = memo8(function Sidebar2({ models, blocks, basePath }) {
5262
+ var Sidebar = memo8(function Sidebar2({
5263
+ models,
5264
+ blocks,
5265
+ basePath,
5266
+ currentUser,
5267
+ isLoggingOut = false,
5268
+ onLogout
5269
+ }) {
5249
5270
  var _a;
5250
5271
  const t = useAdminTranslation();
5251
5272
  const { pathname } = useAdminLocation();
@@ -5328,59 +5349,124 @@ var Sidebar = memo8(function Sidebar2({ models, blocks, basePath }) {
5328
5349
  }
5329
5350
  };
5330
5351
  }, [activeModelSlug, blocks]);
5331
- return /* @__PURE__ */ jsx27(Box16, { sx: { height: "100%", overflow: "hidden" }, children: /* @__PURE__ */ jsx27(
5352
+ return /* @__PURE__ */ jsxs19(Box16, { sx: { height: "100%", overflow: "hidden", display: "flex", flexDirection: "column", minHeight: 0 }, children: [
5353
+ /* @__PURE__ */ jsx27(
5354
+ Box16,
5355
+ {
5356
+ ref: scrollContainerRef,
5357
+ sx: {
5358
+ flex: 1,
5359
+ minHeight: 0,
5360
+ overflowY: "scroll",
5361
+ overflowX: "hidden",
5362
+ scrollbarGutter: "stable",
5363
+ direction: "rtl",
5364
+ ml: 0,
5365
+ pl: 0
5366
+ },
5367
+ children: /* @__PURE__ */ jsxs19(Box16, { sx: { direction: "ltr", pl: 1 }, children: [
5368
+ /* @__PURE__ */ jsx27(
5369
+ NavLink,
5370
+ {
5371
+ href: basePath,
5372
+ style: { textDecoration: "none", display: "block" },
5373
+ onClick: () => startPendingNavigation(basePath, "overview"),
5374
+ children: /* @__PURE__ */ jsx27(
5375
+ ListItemButton3,
5376
+ {
5377
+ selected: isOverviewActive,
5378
+ sx: {
5379
+ mb: 2,
5380
+ borderRadius: "8px",
5381
+ backgroundColor: isOverviewActive ? "rgba(255, 255, 255, 0.18)" : "rgba(255, 255, 255, 0.035)",
5382
+ boxShadow: isOverviewActive ? "inset 0 0 0 1px rgba(255, 255, 255, 0.08)" : "none",
5383
+ "&:hover": {
5384
+ backgroundColor: isOverviewActive ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.055)"
5385
+ }
5386
+ },
5387
+ children: /* @__PURE__ */ jsx27(Typography8, { variant: "subtitle1", sx: { fontWeight: 700 }, children: t("overview") })
5388
+ }
5389
+ )
5390
+ }
5391
+ ),
5392
+ /* @__PURE__ */ jsx27(
5393
+ ModelsBlocks,
5394
+ {
5395
+ models,
5396
+ blocks,
5397
+ basePath,
5398
+ variant: "sidebar",
5399
+ activeModelSlug,
5400
+ onModelNavigate: (href) => startPendingNavigation(href, "model")
5401
+ }
5402
+ )
5403
+ ] })
5404
+ }
5405
+ ),
5406
+ /* @__PURE__ */ jsx27(SidebarCurrentUser, { currentUser, isLoggingOut, onLogout })
5407
+ ] });
5408
+ });
5409
+ function SidebarCurrentUser({ currentUser, isLoggingOut, onLogout }) {
5410
+ if (!currentUser) {
5411
+ return null;
5412
+ }
5413
+ return /* @__PURE__ */ jsxs19(
5332
5414
  Box16,
5333
5415
  {
5334
- ref: scrollContainerRef,
5335
5416
  sx: {
5336
- height: "100%",
5337
- overflowY: "scroll",
5338
- overflowX: "hidden",
5339
- scrollbarGutter: "stable",
5340
- direction: "rtl",
5341
- ml: 0,
5342
- pl: 0
5417
+ flexShrink: 0,
5418
+ ml: 1,
5419
+ mt: 1.25,
5420
+ borderRadius: "8px",
5421
+ backgroundColor: "rgba(255, 255, 255, 0.02)",
5422
+ boxShadow: "inset 0 0 0 1px rgba(255, 255, 255, 0.03)",
5423
+ display: "flex",
5424
+ alignItems: "center",
5425
+ minHeight: 44,
5426
+ px: 1,
5427
+ gap: 1
5343
5428
  },
5344
- children: /* @__PURE__ */ jsxs19(Box16, { sx: { direction: "ltr", pl: 1 }, children: [
5429
+ children: [
5345
5430
  /* @__PURE__ */ jsx27(
5346
- NavLink,
5431
+ Typography8,
5347
5432
  {
5348
- href: basePath,
5349
- style: { textDecoration: "none", display: "block" },
5350
- onClick: () => startPendingNavigation(basePath, "overview"),
5351
- children: /* @__PURE__ */ jsx27(
5352
- ListItemButton3,
5353
- {
5354
- selected: isOverviewActive,
5355
- sx: {
5356
- mb: 2,
5357
- borderRadius: "8px",
5358
- backgroundColor: isOverviewActive ? "rgba(255, 255, 255, 0.18)" : "rgba(255, 255, 255, 0.035)",
5359
- boxShadow: isOverviewActive ? "inset 0 0 0 1px rgba(255, 255, 255, 0.08)" : "none",
5360
- "&:hover": {
5361
- backgroundColor: isOverviewActive ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.055)"
5362
- }
5363
- },
5364
- children: /* @__PURE__ */ jsx27(Typography8, { variant: "subtitle1", sx: { fontWeight: 700 }, children: t("overview") })
5365
- }
5366
- )
5433
+ title: currentUser.login,
5434
+ sx: {
5435
+ flex: 1,
5436
+ minWidth: 0,
5437
+ overflowWrap: "anywhere",
5438
+ whiteSpace: "normal",
5439
+ wordBreak: "break-word",
5440
+ fontSize: 13,
5441
+ fontWeight: 650,
5442
+ lineHeight: 1.25
5443
+ },
5444
+ children: currentUser.login
5367
5445
  }
5368
5446
  ),
5369
- /* @__PURE__ */ jsx27(
5370
- ModelsBlocks,
5447
+ onLogout ? /* @__PURE__ */ jsx27(Tooltip2, { title: "Logout", children: /* @__PURE__ */ jsx27("span", { children: /* @__PURE__ */ jsx27(
5448
+ IconButton5,
5371
5449
  {
5372
- models,
5373
- blocks,
5374
- basePath,
5375
- variant: "sidebar",
5376
- activeModelSlug,
5377
- onModelNavigate: (href) => startPendingNavigation(href, "model")
5450
+ "aria-label": "Logout",
5451
+ size: "small",
5452
+ disabled: isLoggingOut,
5453
+ onClick: onLogout,
5454
+ sx: {
5455
+ width: 32,
5456
+ height: 32,
5457
+ color: "text.secondary",
5458
+ "&:hover": {
5459
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
5460
+ color: "text.primary"
5461
+ }
5462
+ },
5463
+ children: /* @__PURE__ */ jsx27(LogoutIcon, { sx: { fontSize: 18 } })
5378
5464
  }
5379
- )
5380
- ] })
5465
+ ) }) }) : null
5466
+ ]
5381
5467
  }
5382
- ) });
5383
- });
5468
+ );
5469
+ }
5384
5470
 
5385
5471
  // src/components/Shell.tsx
5386
5472
  import { jsx as jsx28, jsxs as jsxs20 } from "react/jsx-runtime";
@@ -5388,8 +5474,19 @@ function normalizeAdminPath(path) {
5388
5474
  const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
5389
5475
  return normalizedPath.replace(/^\/(ru|en)(?=\/|$)/, "") || "/";
5390
5476
  }
5391
- function Shell({ client, models, blocks, basePath, locale, children, theme, router }) {
5392
- void client;
5477
+ function Shell({
5478
+ client,
5479
+ models,
5480
+ blocks,
5481
+ basePath,
5482
+ locale,
5483
+ children,
5484
+ theme,
5485
+ router,
5486
+ currentUser,
5487
+ loginPath = "/login",
5488
+ onLogout
5489
+ }) {
5393
5490
  const activeTheme = theme != null ? theme : defaultAdminTheme;
5394
5491
  const resolvedRouter = useAdminRouter(router);
5395
5492
  const location = useAdminLocation(resolvedRouter);
@@ -5398,6 +5495,12 @@ function Shell({ client, models, blocks, basePath, locale, children, theme, rout
5398
5495
  const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState13(false);
5399
5496
  const [pendingPath, setPendingPath] = useState13(null);
5400
5497
  const [pendingView, setPendingView] = useState13(null);
5498
+ const [loadedCurrentUser, setLoadedCurrentUser] = useState13(null);
5499
+ const [isLoggingOut, setIsLoggingOut] = useState13(false);
5500
+ const sidebarCurrentUser = useMemo11(
5501
+ () => normalizeCurrentUser(currentUser === void 0 ? loadedCurrentUser : currentUser),
5502
+ [currentUser, loadedCurrentUser]
5503
+ );
5401
5504
  useEffect14(() => {
5402
5505
  if (isDesktopSidebar) {
5403
5506
  setIsMobileSidebarOpen(false);
@@ -5406,6 +5509,40 @@ function Shell({ client, models, blocks, basePath, locale, children, theme, rout
5406
5509
  useEffect14(() => {
5407
5510
  setIsMobileSidebarOpen(false);
5408
5511
  }, [pathname]);
5512
+ useEffect14(() => {
5513
+ if (currentUser !== void 0) {
5514
+ return;
5515
+ }
5516
+ let isMounted = true;
5517
+ client.getCurrentUser().then((response) => {
5518
+ if (!isMounted) return;
5519
+ setLoadedCurrentUser(response);
5520
+ }).catch(() => {
5521
+ if (!isMounted) return;
5522
+ setLoadedCurrentUser(null);
5523
+ });
5524
+ return () => {
5525
+ isMounted = false;
5526
+ };
5527
+ }, [client, currentUser]);
5528
+ const handleLogout = useCallback8(async () => {
5529
+ if (isLoggingOut) {
5530
+ return;
5531
+ }
5532
+ setIsLoggingOut(true);
5533
+ try {
5534
+ if (onLogout) {
5535
+ await onLogout();
5536
+ } else {
5537
+ await client.logout();
5538
+ }
5539
+ if (loginPath) {
5540
+ resolvedRouter.replace(loginPath);
5541
+ }
5542
+ } finally {
5543
+ setIsLoggingOut(false);
5544
+ }
5545
+ }, [client, isLoggingOut, loginPath, onLogout, resolvedRouter]);
5409
5546
  const shellContextValue = useMemo11(() => ({
5410
5547
  isMobile: !isDesktopSidebar,
5411
5548
  openMobileSidebar: () => setIsMobileSidebarOpen(true),
@@ -5500,7 +5637,17 @@ function Shell({ client, models, blocks, basePath, locale, children, theme, rout
5500
5637
  pl: 0,
5501
5638
  pr: 1
5502
5639
  },
5503
- children: /* @__PURE__ */ jsx28(Sidebar, { models, blocks, basePath })
5640
+ children: /* @__PURE__ */ jsx28(
5641
+ Sidebar,
5642
+ {
5643
+ models,
5644
+ blocks,
5645
+ basePath,
5646
+ currentUser: sidebarCurrentUser,
5647
+ isLoggingOut,
5648
+ onLogout: handleLogout
5649
+ }
5650
+ )
5504
5651
  }
5505
5652
  ),
5506
5653
  /* @__PURE__ */ jsx28(
@@ -5544,11 +5691,31 @@ function Shell({ client, models, blocks, basePath, locale, children, theme, rout
5544
5691
  }
5545
5692
  }
5546
5693
  },
5547
- children: /* @__PURE__ */ jsx28(Box17, { sx: { height: "100%", minHeight: 0, pr: 1.5 }, children: /* @__PURE__ */ jsx28(Sidebar, { models, blocks, basePath }) })
5694
+ children: /* @__PURE__ */ jsx28(Box17, { sx: { height: "100%", minHeight: 0, pr: 1.5 }, children: /* @__PURE__ */ jsx28(
5695
+ Sidebar,
5696
+ {
5697
+ models,
5698
+ blocks,
5699
+ basePath,
5700
+ currentUser: sidebarCurrentUser,
5701
+ isLoggingOut,
5702
+ onLogout: handleLogout
5703
+ }
5704
+ ) })
5548
5705
  }
5549
5706
  )
5550
5707
  ] }) }) }) }) }) });
5551
5708
  }
5709
+ function normalizeCurrentUser(user) {
5710
+ if (typeof user === "string") {
5711
+ const login = user.trim();
5712
+ return login ? { login } : null;
5713
+ }
5714
+ if (!user || !user.login.trim()) {
5715
+ return null;
5716
+ }
5717
+ return user;
5718
+ }
5552
5719
  export {
5553
5720
  AdminLocaleProvider,
5554
5721
  AdminRouterProvider,
@@ -135,6 +135,12 @@ export type AdminModelsResponse = {
135
135
  items: AdminModelMeta[];
136
136
  blocks: AdminModelsBlockMeta[];
137
137
  };
138
+ export type AdminCurrentUser = {
139
+ id?: string | number | null;
140
+ login: string;
141
+ email?: string | null;
142
+ name?: string | null;
143
+ };
138
144
  export type AdminChoicesResponse = {
139
145
  items: Array<{
140
146
  id: string | number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xladmin",
3
- "version": "0.5.0",
3
+ "version": "0.9.0",
4
4
  "description": "Framework-agnostic React + MUI admin frontend for xladmin.",
5
5
  "license": "MIT",
6
6
  "type": "module",