@iyulab/modern-app 0.4.0 → 0.6.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/CHANGELOG.md +21 -0
- package/README.md +56 -1
- package/dist/App.d.ts +4 -0
- package/dist/App.js +23 -3
- package/dist/components/SidebarButton.d.ts +2 -1
- package/dist/components/SidebarGroup.d.ts +2 -1
- package/dist/components/SidebarLink.d.ts +2 -1
- package/dist/components/SidebarSection.d.ts +2 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -2
- package/dist/layouts/SidebarLayout.js +34 -33
- package/dist/layouts/SidebarLayout.types.d.ts +9 -1
- package/dist/layouts/SidebarPermission.d.ts +15 -0
- package/dist/layouts/filterSidebarItems.d.ts +11 -0
- package/dist/layouts/filterSidebarItems.js +19 -0
- package/dist/types/AppConfigs.d.ts +47 -1
- package/dist/types/AuthConfig.d.ts +34 -0
- package/package.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-07-03
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **`AppConfig.auth`** — 부팅 인증 게이트. 지정하면 앱 셸(레이아웃·라우터)을 만들기 전에 `me()` 로 세션을 판정하여, 인증 시 셸을 로드하고 미인증 시 `renderLogin` 으로 로그인 UI 를 띄운다. 로그인 성공 시 `onSuccess()` 를 호출하면 앱이 (재)로드되어 셸이 나타난다. `onAuthenticated(user)` 로 셸 구성 직전 훅(권한 set 등)을 제공한다.
|
|
7
|
+
- 프레임워크는 인증 **오케스트레이션**(판정→분기→재로드)만 소유. 세션 조회/로그인 HTTP·사용자/권한 형태·세션-중 401 은 앱/`@iyulab/enterprise`(`createAuthClient`/`createODataService`)가 소유한다. 소비앱이 `app.load()` 앞단에 손으로 짜던 부팅 게이트를 표준화.
|
|
8
|
+
- **`app.user`** getter — 인증 게이트 사용 시 인증된 현재 사용자(미인증/미사용이면 `undefined`).
|
|
9
|
+
- **사이드바 네이티브 권한 메뉴 필터** — 모든 메뉴 항목(link/section/group/button/html)에 `requirePermission?`/`requireAnyPermission?` 지원(공통 `SidebarPermissionGuard`). `SidebarLayoutConfig.hasPermission` 판정 함수를 주면 만족하지 않는 항목을 숨기고, 항목이 모두 걸러진 section/group 은 통째로 숨긴다. 소비앱이 손으로 짜던 `filterMenu` 를 표준화. 순수 헬퍼 `filterSidebarItems(items, hasPermission)` 도 export.
|
|
10
|
+
|
|
11
|
+
### Notes
|
|
12
|
+
- `auth`/`hasPermission` 미지정 시 동작은 완전히 하위호환(게이트·필터 없이 기존대로 셸 로드).
|
|
13
|
+
|
|
14
|
+
## [0.5.0] - 2026-07-02
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `AppConfig.enter` — global route guard, forwarded to the underlying `@iyulab/router` `Router`. Previously `@iyulab/router` already supported this via `RouterConfig.enter`, but `App.load()` never passed it through, so the global guard path was unreachable from modern-app. Return a `string` to redirect, `false` to cancel (403), `true`/nothing to proceed. See [docs/routing.md#authentication--guards](./docs/routing.md#authentication--guards).
|
|
18
|
+
- `AppConfig.initialLoad` / `AppConfig.useIntercept` — forwarded to `Router` alongside `enter` (previously also unreachable; `initialLoad: false` is needed for guard unit tests that drive navigation explicitly).
|
|
19
|
+
- Vitest + happy-dom test infrastructure (`npm test`) — first automated test suite for this package, covering `AppConfig.enter` redirect/cancel behavior.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- `package.json` `scripts`: `"test"` now runs `vitest run` (was `vite`, which only started the dev server). The old behavior is available as `"start"`.
|
|
23
|
+
|
|
3
24
|
## [0.4.0] - 2026-07-02
|
|
4
25
|
|
|
5
26
|
### Added
|
package/README.md
CHANGED
|
@@ -109,12 +109,67 @@ import { translate } from 'lit-i18n';
|
|
|
109
109
|
html`<p>${translate('common::greeting')}</p>`;
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
+
### 부팅 인증 게이트 (`auth`)
|
|
113
|
+
|
|
114
|
+
셸을 만들기 전에 세션을 판정한다. 소비앱이 `app.load()` 앞단에 손으로 짜던 "me 조회 → 미인증이면 로그인, 인증이면 앱 로드" 게이트를 표준화한다. 세션 조회/로그인 HTTP 는 `@iyulab/enterprise` 의 `createAuthClient` 가, 세션-중 401 은 `createODataService` 의 `onUnauthorized` 가 담당한다(프레임워크는 오케스트레이션만 소유).
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
import { createAuthClient, setPermissions } from '@iyulab/enterprise';
|
|
118
|
+
|
|
119
|
+
const auth = createAuthClient<User, Cred>({ meUrl: '/api/auth/me', loginUrl: '/api/auth/login', logoutUrl: '/api/auth/logout' });
|
|
120
|
+
|
|
121
|
+
await app.load({
|
|
122
|
+
layout: { type: 'sidebar', /* ... */ },
|
|
123
|
+
auth: {
|
|
124
|
+
me: () => auth.fetchMe(), // null → 미인증 → renderLogin
|
|
125
|
+
renderLogin: ({ root, onSuccess }) => renderLoginPage(root, auth, onSuccess),
|
|
126
|
+
onAuthenticated: (user) => setPermissions((user as User).Permissions),
|
|
127
|
+
},
|
|
128
|
+
routes: [ /* ... */ ],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
app.user; // 인증된 현재 사용자(미인증/미사용 시 undefined)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- `me()` 가 값을 반환하면 셸 로드, `null`/`undefined` 면 `renderLogin({ root, onSuccess })`.
|
|
135
|
+
- 로그인 성공 시 `onSuccess()` 를 호출하면 앱이 재로드되어 셸이 나타나고 로그인 UI 는 정리된다.
|
|
136
|
+
- `auth` 미지정 시 완전히 하위호환(게이트 없이 기존대로 로드).
|
|
137
|
+
|
|
138
|
+
### 권한 기반 메뉴 필터
|
|
139
|
+
|
|
140
|
+
모든 사이드바 메뉴 항목에 `requirePermission`/`requireAnyPermission` 를 달고, 레이아웃에 `hasPermission` 판정 함수를 주면 권한 없는 항목이 숨겨진다. 항목이 모두 걸러진 section/group 은 통째로 숨는다. 소비앱이 손으로 짜던 `filterMenu` 를 대체한다.
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import { hasPermission } from '@iyulab/enterprise';
|
|
144
|
+
|
|
145
|
+
await app.load({
|
|
146
|
+
layout: {
|
|
147
|
+
type: 'sidebar',
|
|
148
|
+
hasPermission, // enterprise 권한 store 판정
|
|
149
|
+
main: [
|
|
150
|
+
{ type: 'link', icon: 'house', label: '홈', href: '/' },
|
|
151
|
+
{ type: 'link', icon: 'gear', label: '설정', href: '/settings', requirePermission: 'admin.maintenance' },
|
|
152
|
+
{
|
|
153
|
+
type: 'section', title: '주문',
|
|
154
|
+
items: [
|
|
155
|
+
{ type: 'link', label: '주문 목록', href: '/orders', requireAnyPermission: ['orders.read', 'orders.write'] },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
auth: { /* ... */ },
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- `hasPermission` 미지정 시 필터링하지 않는다(모든 항목 표시 — 하위호환).
|
|
165
|
+
- 순수 헬퍼 `filterSidebarItems(items, hasPermission)` 를 직접 재사용할 수도 있다.
|
|
166
|
+
|
|
112
167
|
## Documentation
|
|
113
168
|
|
|
114
169
|
| Guide | Description |
|
|
115
170
|
|-------|-------------|
|
|
116
171
|
| [getting-started.md](./docs/getting-started.md) | Bootstrap, architecture, entry point setup |
|
|
117
|
-
| [routing.md](./docs/routing.md) | Route config, URL params, async routes, progress |
|
|
172
|
+
| [routing.md](./docs/routing.md) | Route config, URL params, async routes, progress, auth guards |
|
|
118
173
|
| [layout.md](./docs/layout.md) | Sidebar layout, all menu item types, responsive behaviour |
|
|
119
174
|
| [theme.md](./docs/theme.md) | Theme init, runtime switching, CSS tokens |
|
|
120
175
|
| [notifications.md](./docs/notifications.md) | Toast methods and options |
|
package/dist/App.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ declare class App {
|
|
|
12
12
|
private _layout?;
|
|
13
13
|
private _router?;
|
|
14
14
|
private _screen?;
|
|
15
|
+
private _user?;
|
|
16
|
+
private _loginTeardown?;
|
|
15
17
|
private constructor();
|
|
16
18
|
/** 싱글톤 인스턴스 반환 */
|
|
17
19
|
static get instance(): App;
|
|
@@ -21,6 +23,8 @@ declare class App {
|
|
|
21
23
|
get router(): Router | undefined;
|
|
22
24
|
/** 화면 크기 반환 */
|
|
23
25
|
get screen(): ScreenSize | undefined;
|
|
26
|
+
/** 인증 게이트(`auth`) 사용 시 인증된 현재 사용자. 미인증/미사용이면 undefined. */
|
|
27
|
+
get user(): unknown;
|
|
24
28
|
/** 스타일 테마 관리 유틸리티 객체 반환 */
|
|
25
29
|
get theme(): typeof Theme;
|
|
26
30
|
/** 다국어 로컬라이저(i18next) 반환 */
|
package/dist/App.js
CHANGED
|
@@ -18,6 +18,9 @@ var o = class o {
|
|
|
18
18
|
get screen() {
|
|
19
19
|
return this._screen?.get();
|
|
20
20
|
}
|
|
21
|
+
get user() {
|
|
22
|
+
return this._user;
|
|
23
|
+
}
|
|
21
24
|
get theme() {
|
|
22
25
|
return i;
|
|
23
26
|
}
|
|
@@ -25,7 +28,21 @@ var o = class o {
|
|
|
25
28
|
return t;
|
|
26
29
|
}
|
|
27
30
|
async load(a) {
|
|
28
|
-
if (this.unload(), this._config = a,
|
|
31
|
+
if (this.unload(), this._config = a, a.auth) {
|
|
32
|
+
let e = await a.auth.me();
|
|
33
|
+
if (e == null) {
|
|
34
|
+
let e = a.root || document.body, t = a.auth.renderLogin({
|
|
35
|
+
root: e,
|
|
36
|
+
onSuccess: () => {
|
|
37
|
+
this.load(a);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
t && (this._loginTeardown = t);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this._user = e, await a.auth.onAuthenticated?.(e);
|
|
44
|
+
}
|
|
45
|
+
if (await i.init(a.theme), a.iconBasepath && r(a.iconBasepath), a.i18n) {
|
|
29
46
|
for (let e of a.i18n.plugins || []) t.use(e);
|
|
30
47
|
await t.init(a.i18n);
|
|
31
48
|
}
|
|
@@ -39,11 +56,14 @@ var o = class o {
|
|
|
39
56
|
root: this._layout,
|
|
40
57
|
basepath: a.basepath,
|
|
41
58
|
routes: a.routes,
|
|
42
|
-
fallback: a.fallback
|
|
59
|
+
fallback: a.fallback,
|
|
60
|
+
enter: a.enter,
|
|
61
|
+
initialLoad: a.initialLoad,
|
|
62
|
+
useIntercept: a.useIntercept
|
|
43
63
|
});
|
|
44
64
|
}
|
|
45
65
|
unload() {
|
|
46
|
-
this._screen &&= (this._screen.destroy(), void 0), this._layout &&= (this._layout.remove(), void 0), this._router &&= (this._router.destroy(), void 0), this._config &&= void 0;
|
|
66
|
+
this._loginTeardown &&= (this._loginTeardown(), void 0), this._user = void 0, this._screen &&= (this._screen.destroy(), void 0), this._layout &&= (this._layout.remove(), void 0), this._router &&= (this._router.destroy(), void 0), this._config &&= void 0;
|
|
47
67
|
}
|
|
48
68
|
navigate(e) {
|
|
49
69
|
this._router?.go(e);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { DirectiveResult } from 'lit/directive.js';
|
|
2
2
|
import { StyledElement, StyleMap } from '../internals/StyledElement.js';
|
|
3
|
+
import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
|
|
3
4
|
/** 버튼 항목 부분 */
|
|
4
5
|
type ElementParts = 'host' | 'base' | 'icon' | 'label';
|
|
5
6
|
/** 버튼 항목 구성 */
|
|
6
|
-
export interface SidebarButtonConfig {
|
|
7
|
+
export interface SidebarButtonConfig extends SidebarPermissionGuard {
|
|
7
8
|
type: 'button';
|
|
8
9
|
icon?: string;
|
|
9
10
|
label?: string | DirectiveResult;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { DirectiveResult } from 'lit/directive.js';
|
|
2
2
|
import { StyledElement, StyleMap } from '../internals/StyledElement.js';
|
|
3
3
|
import { SidebarLinkConfig } from './SidebarLink.js';
|
|
4
|
+
import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
|
|
4
5
|
type ElementParts = 'host' | 'header' | 'icon' | 'label' | 'caret' | 'items';
|
|
5
6
|
/** 그룹: 하위 링크들 묶음 */
|
|
6
|
-
export interface SidebarGroupConfig {
|
|
7
|
+
export interface SidebarGroupConfig extends SidebarPermissionGuard {
|
|
7
8
|
type: 'group';
|
|
8
9
|
/** 기본 접힘 상태 */
|
|
9
10
|
collapsed?: boolean;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DirectiveResult } from 'lit/directive.js';
|
|
2
2
|
import { StyledElement, StyleMap } from '../internals/StyledElement.js';
|
|
3
|
+
import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
|
|
3
4
|
type ElementParts = 'host' | 'base' | 'icon' | 'label';
|
|
4
5
|
/** 링크 항목 디폴트 타입 */
|
|
5
|
-
export interface SidebarLinkConfig {
|
|
6
|
+
export interface SidebarLinkConfig extends SidebarPermissionGuard {
|
|
6
7
|
type: 'link';
|
|
7
8
|
icon?: string;
|
|
8
9
|
label: string | DirectiveResult;
|
|
@@ -2,9 +2,10 @@ import { DirectiveResult } from 'lit/directive.js';
|
|
|
2
2
|
import { StyledElement, StyleMap } from '../internals/StyledElement.js';
|
|
3
3
|
import { SidebarLinkConfig } from './SidebarLink.js';
|
|
4
4
|
import { SidebarGroupConfig } from './SidebarGroup.js';
|
|
5
|
+
import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
|
|
5
6
|
type ElementParts = 'host' | 'header' | 'title' | 'subtitle' | 'items';
|
|
6
7
|
/** 섹션 내에는 그룹 또는 링크들만 허용 */
|
|
7
|
-
export interface SidebarSectionConfig {
|
|
8
|
+
export interface SidebarSectionConfig extends SidebarPermissionGuard {
|
|
8
9
|
type: 'section';
|
|
9
10
|
title: string | DirectiveResult;
|
|
10
11
|
subTitle?: string | DirectiveResult;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { app } from './App.js';
|
|
2
2
|
export type * from './types/AppConfigs';
|
|
3
3
|
export type * from './types/AppOptions';
|
|
4
|
+
export type * from './types/AuthConfig';
|
|
5
|
+
export type { SidebarPermissionGuard } from './layouts/SidebarPermission';
|
|
6
|
+
export { filterSidebarItems } from './layouts/filterSidebarItems.js';
|
|
4
7
|
export { app };
|
|
5
8
|
export default app;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { app as e } from "./App.js";
|
|
2
|
+
import { filterSidebarItems as t } from "./layouts/filterSidebarItems.js";
|
|
2
3
|
//#region src/index.ts
|
|
3
|
-
var
|
|
4
|
+
var n = e;
|
|
4
5
|
//#endregion
|
|
5
|
-
export { e as app,
|
|
6
|
+
export { e as app, n as default, t as filterSidebarItems };
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { app as e } from "../App.js";
|
|
2
|
-
import t from "
|
|
3
|
-
import n from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/
|
|
4
|
-
import
|
|
2
|
+
import { filterSidebarItems as t } from "./filterSidebarItems.js";
|
|
3
|
+
import n from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorateMetadata.js";
|
|
4
|
+
import r from "../_virtual/_@oxc-project_runtime@0.138.0/helpers/esm/decorate.js";
|
|
5
|
+
import { StyledElement as i } from "../internals/StyledElement.js";
|
|
5
6
|
import "../components/SidebarSection.js";
|
|
6
7
|
import "../components/SidebarLink.js";
|
|
7
8
|
import "../components/SidebarGroup.js";
|
|
8
9
|
import "../components/SidebarButton.js";
|
|
9
|
-
import { styles as
|
|
10
|
-
import { html as
|
|
11
|
-
import { customElement as
|
|
12
|
-
import { unsafeHTML as
|
|
13
|
-
import { repeat as
|
|
10
|
+
import { styles as a } from "./SidebarLayout.styles.js";
|
|
11
|
+
import { html as o, nothing as s } from "lit";
|
|
12
|
+
import { customElement as c, property as l, query as u, state as d } from "lit/decorators.js";
|
|
13
|
+
import { unsafeHTML as f } from "lit/directives/unsafe-html.js";
|
|
14
|
+
import { repeat as p } from "lit/directives/repeat.js";
|
|
14
15
|
import "@iyulab/components/dist/components/icon/UIcon.js";
|
|
15
16
|
import "@iyulab/components/dist/components/button/UButton.js";
|
|
16
|
-
import { UProgressBar as
|
|
17
|
+
import { UProgressBar as m } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
|
|
17
18
|
//#region src/layouts/SidebarLayout.ts
|
|
18
|
-
function
|
|
19
|
+
function h(e) {
|
|
19
20
|
let t = e.tagName;
|
|
20
21
|
if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || e.isContentEditable) return !0;
|
|
21
22
|
let n = e.getAttribute("role");
|
|
22
23
|
return n === "textbox" || n === "searchbox" || n === "combobox" || n === "spinbutton";
|
|
23
24
|
}
|
|
24
|
-
var
|
|
25
|
+
var g = class extends i {
|
|
25
26
|
constructor(...t) {
|
|
26
27
|
super(...t), this.state = "default", this.context = null, this.isMatchedLink = (e) => !this.context || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.context.path, window.location.origin)), this.handleBrandLogoClick = (t) => () => {
|
|
27
28
|
e.navigate(t ?? "");
|
|
@@ -44,7 +45,7 @@ var h = class extends r {
|
|
|
44
45
|
}, 300);
|
|
45
46
|
}, this._handleMainKeydown = (e) => {
|
|
46
47
|
let t = e.composedPath()[0];
|
|
47
|
-
if (t instanceof HTMLElement &&
|
|
48
|
+
if (t instanceof HTMLElement && h(t)) return;
|
|
48
49
|
let n = this.shadowRoot?.querySelector(".main");
|
|
49
50
|
if (!n) return;
|
|
50
51
|
let r = n.clientHeight;
|
|
@@ -77,7 +78,7 @@ var h = class extends r {
|
|
|
77
78
|
};
|
|
78
79
|
}
|
|
79
80
|
static {
|
|
80
|
-
this.styles = [super.styles,
|
|
81
|
+
this.styles = [super.styles, a];
|
|
81
82
|
}
|
|
82
83
|
connectedCallback() {
|
|
83
84
|
super.connectedCallback(), window.addEventListener("route-begin", this.handleRouteBegin), window.addEventListener("route-done", this.handleRouteDone), window.addEventListener("route-progress", this.handleRouteProgress), window.addEventListener("route-error", this.handleRouteError), window.addEventListener("screen-resize", this.handleScreenResize);
|
|
@@ -89,7 +90,7 @@ var h = class extends r {
|
|
|
89
90
|
super.willUpdate(e), e.has("config") && (this.styles = this.config?.styles);
|
|
90
91
|
}
|
|
91
92
|
render() {
|
|
92
|
-
return this.config ?
|
|
93
|
+
return this.config ? o`
|
|
93
94
|
<!-- Mobile Header -->
|
|
94
95
|
<div class="mobile-header" part="mobile-header" ?hidden="${!this.state.startsWith("mobile")}">
|
|
95
96
|
${this.renderLogo()}
|
|
@@ -124,12 +125,12 @@ var h = class extends r {
|
|
|
124
125
|
|
|
125
126
|
<!-- Sidebar Navigation Menu -->
|
|
126
127
|
<nav class="sidebar-main" part="sidebar-main" scrollable>
|
|
127
|
-
${
|
|
128
|
+
${p(t(this.config.main ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
|
|
128
129
|
</nav>
|
|
129
130
|
|
|
130
131
|
<!-- Sidebar Footer -->
|
|
131
132
|
<div class="sidebar-footer" part="sidebar-footer">
|
|
132
|
-
${
|
|
133
|
+
${p(t(this.config.footer ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
|
|
133
134
|
</div>
|
|
134
135
|
</aside>
|
|
135
136
|
|
|
@@ -144,14 +145,14 @@ var h = class extends r {
|
|
|
144
145
|
<div class="backdrop" ?hidden="${this.state !== "modal"}"
|
|
145
146
|
@click="${this.handleBackdropClick}"
|
|
146
147
|
></div>
|
|
147
|
-
` :
|
|
148
|
+
` : s;
|
|
148
149
|
}
|
|
149
150
|
renderItem(e) {
|
|
150
|
-
if (!e) return
|
|
151
|
+
if (!e) return s;
|
|
151
152
|
if (e.type === "html") {
|
|
152
153
|
let t = e.render(this.state);
|
|
153
|
-
return typeof t == "string" ?
|
|
154
|
-
} else if (e.type === "button") return
|
|
154
|
+
return typeof t == "string" ? f(t) : o`${t}`;
|
|
155
|
+
} else if (e.type === "button") return o`
|
|
155
156
|
<u-sidebar-button
|
|
156
157
|
?compact=${this.state === "slim"}
|
|
157
158
|
.icon="${e.icon}"
|
|
@@ -162,7 +163,7 @@ var h = class extends r {
|
|
|
162
163
|
`;
|
|
163
164
|
else if (e.type === "link") {
|
|
164
165
|
let t = this.isMatchedLink(e.pattern || e.href);
|
|
165
|
-
return
|
|
166
|
+
return o`
|
|
166
167
|
<u-sidebar-link
|
|
167
168
|
?compact=${this.state === "slim"}
|
|
168
169
|
?selected=${t}
|
|
@@ -173,18 +174,18 @@ var h = class extends r {
|
|
|
173
174
|
.styles="${e.styles}"
|
|
174
175
|
></u-sidebar-link>
|
|
175
176
|
`;
|
|
176
|
-
} else if (e.type === "section") return
|
|
177
|
+
} else if (e.type === "section") return o`
|
|
177
178
|
<u-sidebar-section
|
|
178
179
|
?compact=${this.state === "slim"}
|
|
179
180
|
.mainTitle="${e.title}"
|
|
180
181
|
.subTitle="${e.subTitle}"
|
|
181
182
|
.styles="${e.styles}">
|
|
182
|
-
${
|
|
183
|
+
${p(e.items, (e, t) => t, (e) => this.renderItem(e))}
|
|
183
184
|
</u-sidebar-section>
|
|
184
185
|
`;
|
|
185
186
|
else if (e.type === "group") {
|
|
186
187
|
let t = e.items.some((e) => this.isMatchedLink(e.pattern || e.href));
|
|
187
|
-
return
|
|
188
|
+
return o`
|
|
188
189
|
<u-sidebar-group
|
|
189
190
|
?compact=${this.state === "slim"}
|
|
190
191
|
?selected=${t}
|
|
@@ -192,14 +193,14 @@ var h = class extends r {
|
|
|
192
193
|
.icon="${e.icon}"
|
|
193
194
|
.label="${e.label}"
|
|
194
195
|
.styles="${e.styles}">
|
|
195
|
-
${
|
|
196
|
+
${p(e.items, (e, t) => t, (e) => this.renderItem(e))}
|
|
196
197
|
</u-sidebar-group>
|
|
197
198
|
`;
|
|
198
|
-
} else return
|
|
199
|
+
} else return s;
|
|
199
200
|
}
|
|
200
201
|
renderLogo() {
|
|
201
202
|
let e = this.config?.logo;
|
|
202
|
-
if (!e || typeof e == "string") return
|
|
203
|
+
if (!e || typeof e == "string") return o`
|
|
203
204
|
<u-icon class="logo"
|
|
204
205
|
.name="${e}"
|
|
205
206
|
@click=${this.handleBrandLogoClick()}
|
|
@@ -207,13 +208,13 @@ var h = class extends r {
|
|
|
207
208
|
`;
|
|
208
209
|
if (typeof e == "function") {
|
|
209
210
|
let t = e(this.state);
|
|
210
|
-
return
|
|
211
|
+
return o`
|
|
211
212
|
<span class="logo" @click=${this.handleBrandLogoClick()}>
|
|
212
|
-
${typeof t == "string" ?
|
|
213
|
+
${typeof t == "string" ? f(t) : t}
|
|
213
214
|
</span>
|
|
214
215
|
`;
|
|
215
216
|
}
|
|
216
|
-
return
|
|
217
|
+
return o`
|
|
217
218
|
<img class="logo"
|
|
218
219
|
src="${e.src}"
|
|
219
220
|
alt="${e.alt ?? ""}"
|
|
@@ -222,9 +223,9 @@ var h = class extends r {
|
|
|
222
223
|
`;
|
|
223
224
|
}
|
|
224
225
|
};
|
|
225
|
-
|
|
226
|
+
r([l({
|
|
226
227
|
type: String,
|
|
227
228
|
reflect: !0
|
|
228
|
-
}),
|
|
229
|
+
}), n("design:type", Object)], g.prototype, "state", void 0), r([l({ type: Object }), n("design:type", Object)], g.prototype, "config", void 0), r([u("u-progress-bar"), n("design:type", m === void 0 ? Object : m)], g.prototype, "progressBarEl", void 0), r([d(), n("design:type", Object)], g.prototype, "context", void 0), g = r([c("u-sidebar-layout")], g);
|
|
229
230
|
//#endregion
|
|
230
|
-
export {
|
|
231
|
+
export { g as SidebarLayout };
|
|
@@ -4,12 +4,14 @@ import { SidebarLinkConfig } from '../components/SidebarLink';
|
|
|
4
4
|
import { SidebarSectionConfig } from '../components/SidebarSection';
|
|
5
5
|
import { SidebarGroupConfig } from '../components/SidebarGroup';
|
|
6
6
|
import { SidebarButtonConfig } from '../components/SidebarButton';
|
|
7
|
+
import { SidebarPermissionGuard } from './SidebarPermission';
|
|
8
|
+
export type { SidebarPermissionGuard } from './SidebarPermission';
|
|
7
9
|
/** 사이드바 레이아웃 컴포넌트의 요소(part) 타입 */
|
|
8
10
|
export type SidebarParts = 'host' | 'mobile-header' | 'sidebar' | 'sidebar-header' | 'sidebar-main' | 'sidebar-footer' | 'main' | 'progress';
|
|
9
11
|
/** 사이드바 상태 타입 */
|
|
10
12
|
export type SidebarState = 'default' | 'slim' | 'modal' | 'mobile' | 'mobile-open';
|
|
11
13
|
/** 사이드바 안에 HTML 또는 엘리먼트를 직접 렌더링하는 설정 */
|
|
12
|
-
export interface SidebarHtmlConfig {
|
|
14
|
+
export interface SidebarHtmlConfig extends SidebarPermissionGuard {
|
|
13
15
|
type: 'html';
|
|
14
16
|
render: (state: SidebarState) => TemplateResult<1> | HTMLElement | string;
|
|
15
17
|
}
|
|
@@ -36,6 +38,12 @@ export interface SidebarLayoutConfig {
|
|
|
36
38
|
main?: SidebarItem[];
|
|
37
39
|
/** 하단(footer)에 고정해서 렌더할 항목들 */
|
|
38
40
|
footer?: SidebarItem[];
|
|
41
|
+
/**
|
|
42
|
+
* 메뉴 항목 권한 필터 판정. 지정하면 `requirePermission`/`requireAnyPermission` 를 만족하지
|
|
43
|
+
* 않는 항목을 숨기고, 항목이 모두 걸러진 section/group 은 통째로 숨긴다.
|
|
44
|
+
* 미지정 시 필터링하지 않는다(모든 항목 표시). 보통 `@iyulab/enterprise` 의 `hasPermission` 을 넘긴다.
|
|
45
|
+
*/
|
|
46
|
+
hasPermission?: (code: string) => boolean;
|
|
39
47
|
/** 사이드바 스타일 맵 */
|
|
40
48
|
styles?: StyleMap<SidebarParts>;
|
|
41
49
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 사이드바 메뉴 항목의 권한 가드. 모든 메뉴 config(link/section/group/button/html)가 확장한다.
|
|
3
|
+
*
|
|
4
|
+
* 판정 자체는 `SidebarLayoutConfig.hasPermission` 으로 **주입**한다 — 라이브러리는 권한 코드를
|
|
5
|
+
* 불투명 문자열로만 다루고 의미(도메인)는 앱이 소유한다. `@iyulab/enterprise` 의 `hasPermission`
|
|
6
|
+
* 을 그대로 넘겨 쓰는 것이 일반적이다.
|
|
7
|
+
*
|
|
8
|
+
* 의존성 없는 leaf 모듈 — 각 메뉴 config 파일이 순환 없이 import 한다.
|
|
9
|
+
*/
|
|
10
|
+
export interface SidebarPermissionGuard {
|
|
11
|
+
/** 이 권한 코드를 보유해야 항목이 표시된다. */
|
|
12
|
+
requirePermission?: string;
|
|
13
|
+
/** 이 코드 중 하나라도 보유하면 표시된다(빈 배열은 제약 없음). */
|
|
14
|
+
requireAnyPermission?: string[];
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SidebarItem } from './SidebarLayout.types';
|
|
2
|
+
/**
|
|
3
|
+
* 권한에 따라 사이드바 메뉴 트리를 필터링한다. 소비앱이 손으로 짜던 `filterMenu` 를 표준화.
|
|
4
|
+
*
|
|
5
|
+
* - 각 항목의 `requirePermission`/`requireAnyPermission` 를 `hasPermission` 으로 검사해 탈락 항목 제거.
|
|
6
|
+
* - `section`/`group` 은 자식(`items`)을 재귀 필터하고, **자식이 모두 걸러지면 컨테이너째 제거**한다.
|
|
7
|
+
* - `hasPermission` 이 없으면(undefined) 원본을 그대로 반환한다(필터링 없음).
|
|
8
|
+
*
|
|
9
|
+
* 원본 배열/항목을 변형하지 않고 새 배열/얕은 복제본을 반환한다(순수).
|
|
10
|
+
*/
|
|
11
|
+
export declare function filterSidebarItems(items: readonly SidebarItem[], hasPermission?: (code: string) => boolean): SidebarItem[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/layouts/filterSidebarItems.ts
|
|
2
|
+
function e(e, t) {
|
|
3
|
+
return !(e.requirePermission && !t(e.requirePermission) || e.requireAnyPermission && e.requireAnyPermission.length > 0 && !e.requireAnyPermission.some((e) => t(e)));
|
|
4
|
+
}
|
|
5
|
+
function t(n, r) {
|
|
6
|
+
if (!r) return n;
|
|
7
|
+
let i = r, a = [];
|
|
8
|
+
for (let r of n) if (r && e(r, i)) if (r.type === "section" || r.type === "group") {
|
|
9
|
+
let e = t(r.items, i);
|
|
10
|
+
if (e.length === 0) continue;
|
|
11
|
+
a.push({
|
|
12
|
+
...r,
|
|
13
|
+
items: e
|
|
14
|
+
});
|
|
15
|
+
} else a.push(r);
|
|
16
|
+
return a;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { t as filterSidebarItems };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { InitOptions, Module, Newable, NewableModule } from 'i18next';
|
|
2
|
-
import { RouteConfig, FallbackRouteConfig } from '@iyulab/router';
|
|
2
|
+
import { RouteConfig, FallbackRouteConfig, RouteContext } from '@iyulab/router';
|
|
3
3
|
import { ThemeInitOptions } from '@iyulab/components/dist/utilities/Theme.js';
|
|
4
4
|
import { SidebarLayoutConfig } from '../layouts/SidebarLayout.types';
|
|
5
|
+
import { AuthGateConfig } from './AuthConfig';
|
|
5
6
|
/**
|
|
6
7
|
* i18next의 초기화 옵션과 플러그인 배열을 포함한 다국어 설정
|
|
7
8
|
*/
|
|
@@ -45,6 +46,33 @@ export interface AppConfig {
|
|
|
45
46
|
* 라우팅 실패 시 대체 컨텐츠 설정
|
|
46
47
|
*/
|
|
47
48
|
fallback?: FallbackRouteConfig;
|
|
49
|
+
/**
|
|
50
|
+
* 모든 라우트 전환 전에 호출되는 전역 인증/권한 가드입니다.
|
|
51
|
+
* - `string` 반환: 해당 경로로 redirect
|
|
52
|
+
* - `false` 반환: 네비게이션 취소(403)
|
|
53
|
+
* - `true`/무반환: 통과
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* app.load({
|
|
57
|
+
* enter: (ctx) => isAuthenticated() || `/login?returnTo=${encodeURIComponent(ctx.pathname)}`,
|
|
58
|
+
* routes: [
|
|
59
|
+
* { path: '/login', render: () => html`<login-page></login-page>` },
|
|
60
|
+
* // ...
|
|
61
|
+
* ],
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
enter?: (ctx: RouteContext) => Promise<string | boolean> | string | boolean;
|
|
66
|
+
/**
|
|
67
|
+
* 초기 로드 시 현재 URL로 라우팅을 자동으로 수행할지 여부를 설정합니다.
|
|
68
|
+
* @default true
|
|
69
|
+
*/
|
|
70
|
+
initialLoad?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* `a` 태그 클릭 시 클라이언트 라우팅을 수행할지 여부를 설정합니다.
|
|
73
|
+
* @default true
|
|
74
|
+
*/
|
|
75
|
+
useIntercept?: boolean;
|
|
48
76
|
/**
|
|
49
77
|
* 애플리케이션이 렌더링될 루트 HTML 요소
|
|
50
78
|
* @default document.body
|
|
@@ -65,4 +93,22 @@ export interface AppConfig {
|
|
|
65
93
|
* @see 설정에 대한 자세한 내용은 {@link https://www.i18next.com/overview/configuration-options} 참조하십시오.
|
|
66
94
|
*/
|
|
67
95
|
i18n?: I18nInitOptions;
|
|
96
|
+
/**
|
|
97
|
+
* 부팅 인증 게이트(선택). 지정하면 앱 셸을 만들기 전에 `me()` 로 세션을 판정하여
|
|
98
|
+
* 인증 시 셸 로드, 미인증 시 `renderLogin` 으로 로그인 UI 를 띄운다.
|
|
99
|
+
* @see AuthGateConfig
|
|
100
|
+
* @example
|
|
101
|
+
* ```typescript
|
|
102
|
+
* app.load({
|
|
103
|
+
* layout: { type: 'sidebar', ... },
|
|
104
|
+
* auth: {
|
|
105
|
+
* me: () => authClient.fetchMe(), // null → 미인증
|
|
106
|
+
* renderLogin: ({ root, onSuccess }) => renderLoginPage(root, onSuccess),
|
|
107
|
+
* onAuthenticated: (user) => setPermissions((user as User).Permissions),
|
|
108
|
+
* },
|
|
109
|
+
* routes: [ ... ],
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
auth?: AuthGateConfig;
|
|
68
114
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 부팅 인증 게이트 설정.
|
|
3
|
+
*
|
|
4
|
+
* `app.load({ auth })` 에 넘기면, 앱 셸(레이아웃·라우터)을 만들기 전에 세션을 판정한다:
|
|
5
|
+
* `me()` 가 값을 반환하면 인증된 것으로 보고 셸을 로드하고, `null`/`undefined` 면
|
|
6
|
+
* 미인증으로 보고 `renderLogin` 으로 로그인 UI 를 띄운다. 로그인 성공 시 `onSuccess()` 를
|
|
7
|
+
* 호출하면 앱이 (재)로드되어 셸이 나타난다.
|
|
8
|
+
*
|
|
9
|
+
* 프레임워크는 인증의 **오케스트레이션**(판정 → 분기 → 재로드)만 소유한다. 세션 조회/로그인
|
|
10
|
+
* 자체(HTTP)와 사용자·권한 형태, 세션-중 401 처리는 앱/`@iyulab/enterprise` 가 소유한다.
|
|
11
|
+
*/
|
|
12
|
+
export interface AuthGateContext {
|
|
13
|
+
/** 로그인 UI 를 그릴 루트 요소(= `app.load` 의 `root`, 기본 `document.body`). */
|
|
14
|
+
root: Element;
|
|
15
|
+
/** 로그인 성공 시 호출한다. 앱이 (재)로드되어 셸을 띄운다(이때 `me()` 는 사용자를 반환해야 한다). */
|
|
16
|
+
onSuccess: () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface AuthGateConfig {
|
|
19
|
+
/**
|
|
20
|
+
* 현재 세션 조회. 값을 반환하면 인증, `null`/`undefined` 면 미인증으로 본다.
|
|
21
|
+
* 동기/비동기 모두 허용한다.
|
|
22
|
+
*/
|
|
23
|
+
me: () => Promise<unknown | null | undefined> | unknown | null | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* 미인증 시 `context.root` 에 로그인 UI 를 그린다. 성공하면 `context.onSuccess()` 를 호출해야 한다.
|
|
26
|
+
* 정리 함수를 반환하면 앱 로드/`unload` 시 호출되어 로그인 UI 를 제거한다.
|
|
27
|
+
*/
|
|
28
|
+
renderLogin: (context: AuthGateContext) => (() => void) | void;
|
|
29
|
+
/**
|
|
30
|
+
* 인증 성공 후, 앱 셸을 만들기 직전에 호출된다(선택).
|
|
31
|
+
* 사용자별 라우트/메뉴 필터 등 셸 구성 전 처리를 여기서 한다.
|
|
32
|
+
*/
|
|
33
|
+
onAuthenticated?: (user: unknown) => void | Promise<void>;
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iyulab/modern-app",
|
|
3
3
|
"description": "web-framework by iyulab based on lit-element",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iyulab",
|
|
7
7
|
"web-framework",
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"start": "vite",
|
|
38
40
|
"build": "vite build"
|
|
39
41
|
},
|
|
40
42
|
"dependencies": {
|
|
@@ -45,8 +47,10 @@
|
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
47
49
|
"@types/node": "^25.8.0",
|
|
50
|
+
"happy-dom": "^20.10.2",
|
|
48
51
|
"typescript": "^5.9.3",
|
|
49
52
|
"vite": "^8.0.13",
|
|
50
|
-
"vite-plugin-dts": "^5.0.0"
|
|
53
|
+
"vite-plugin-dts": "^5.0.0",
|
|
54
|
+
"vitest": "^4.1.8"
|
|
51
55
|
}
|
|
52
56
|
}
|