@iyulab/modern-app 0.44.3 → 0.46.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 +35 -0
- package/README.md +4 -7
- package/dist/App.js +12 -5
- package/dist/components/EmptyState.d.ts +10 -3
- package/dist/components/EmptyState.js +23 -14
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -2
- package/dist/internals/locale.d.ts +3 -0
- package/dist/internals/locale.js +6 -0
- package/dist/react.d.ts +3 -1
- package/dist/types/AppConfigs.d.ts +3 -7
- package/dist/types/AuthConfig.d.ts +32 -14
- package/package.json +3 -3
- package/skills/modern-app/references/api.md +23 -9
- package/skills/modern-app/references/components/empty-state.md +26 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.46.0] - 2026-10-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`u-empty-state variant="no-access"`** — a screen this user may not see (a route `enter` guard returned
|
|
8
|
+
`false`). It is the fourth fact next to `no-data`, `no-results` and `error`, with its own next step (asking for
|
|
9
|
+
access) and its own default wording (English and Korean built in, `noAccessTitle`/`noAccessDescription` in
|
|
10
|
+
`modernAppLocale`). Unlike `error` it is not announced as an alert — a 403 is not an outage.
|
|
11
|
+
- **The router's error classes and route types are exported from this package** — `RouteError`,
|
|
12
|
+
`AccessDeniedError`, `NotFoundError`, `ContentLoadError`, `ContentRenderError`, `OutletMissingError` and the
|
|
13
|
+
`RouteConfig`/`RouteContext`/`FallbackRouteConfig` types. A `fallback.render(ctx)` can tell a blocked screen from a
|
|
14
|
+
missing one (`ctx.error instanceof AccessDeniedError`) without declaring `@iyulab/router` itself.
|
|
15
|
+
- `EmptyStateVariant` type.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- The React `IntrinsicElements` entry for `<u-empty-state>` (from `@iyulab/modern-app/react`) accepted only
|
|
20
|
+
`no-data` and `no-results`, so `variant="error"` did not type-check in JSX. It now uses the element's own type.
|
|
21
|
+
|
|
22
|
+
## [0.45.0] - 2026-10-08
|
|
23
|
+
|
|
24
|
+
### Changed (breaking)
|
|
25
|
+
|
|
26
|
+
- **The boot auth gate reads `me()`'s answer by `status`.** `me()` returns an `AuthSession` —
|
|
27
|
+
`{ status: 'authenticated', user }`, `{ status: 'anonymous' }` or `{ status: 'unknown', error }` — the same shape
|
|
28
|
+
`@iyulab/enterprise`'s `createAuthClient().fetchMe()` returns, so the two connect as `me: () => auth.fetchMe()`.
|
|
29
|
+
Before, any value meant "signed in": a session client that answers "signed out" with an object (`fetchMe()` gives
|
|
30
|
+
`{ status: 'anonymous' }` on a 401) opened the shell to a user who had not signed in, with that object as
|
|
31
|
+
`app.user` — and `me`'s `unknown` return type let it type-check.
|
|
32
|
+
`anonymous` renders `renderLogin`; `unknown` (or `me()` throwing) renders `renderUnavailable` with the answer's
|
|
33
|
+
`error`. An answer whose `status` is not one of the three makes `app.load()` reject with a `TypeError` instead of
|
|
34
|
+
being guessed to mean signed in.
|
|
35
|
+
**Migrating:** return `{ status: 'authenticated', user }` where you returned the user and `{ status: 'anonymous' }`
|
|
36
|
+
where you returned `null`; with `@iyulab/enterprise`, drop the adapter and pass `auth.fetchMe` through.
|
|
37
|
+
|
|
3
38
|
## [0.44.3] - 2026-10-08
|
|
4
39
|
|
|
5
40
|
### Fixed
|
package/README.md
CHANGED
|
@@ -184,11 +184,7 @@ const auth = createAuthClient<User, Cred>({ meUrl: '/api/auth/me', loginUrl: '/a
|
|
|
184
184
|
await app.load({
|
|
185
185
|
layout: { type: 'sidebar', /* ... */ },
|
|
186
186
|
auth: {
|
|
187
|
-
me:
|
|
188
|
-
const s = await auth.fetchMe();
|
|
189
|
-
if (s.status === 'unknown') throw s.error; // 모름(서버 다운·오프라인) → renderUnavailable
|
|
190
|
-
return s.status === 'authenticated' ? s.user : null; // null → 미인증 → renderLogin
|
|
191
|
-
},
|
|
187
|
+
me: () => auth.fetchMe(), // SessionState: authenticated · anonymous → renderLogin · unknown → renderUnavailable
|
|
192
188
|
renderLogin: ({ root, onSuccess }) => renderLoginPage(root, auth, onSuccess),
|
|
193
189
|
renderUnavailable: ({ root, retry }) => renderOfflinePage(root, retry),
|
|
194
190
|
onAuthenticated: (user) => setPermissions((user as User).Permissions),
|
|
@@ -199,8 +195,9 @@ await app.load({
|
|
|
199
195
|
app.user; // 인증된 현재 사용자(미인증/미사용 시 undefined)
|
|
200
196
|
```
|
|
201
197
|
|
|
202
|
-
- `me()`
|
|
203
|
-
- `me()` 가
|
|
198
|
+
- `me()` 는 `AuthSession` — `{ status: 'authenticated', user }` · `{ status: 'anonymous' }` · `{ status: 'unknown', error }` — 을 돌려준다. `authenticated` 면 셸을 로드하고 `app.user` 는 `user` 다. `anonymous` 면 `renderLogin({ root, onSuccess })`.
|
|
199
|
+
- `unknown`(또는 `me()` 가 **던짐**)이면 세션을 «모름» 으로 보고 로그인 UI 를 그리지 않는다 — `renderUnavailable({ root, error, retry })` 를 그리고, 없으면 `app.load()` 가 그 오류로 실패한다. 서버가 잠깐 503 을 낸 것을 «미인증» 으로 다루면 로그인된 사용자가 로그인 화면으로 간다.
|
|
200
|
+
- `status` 가 셋 중 하나가 아닌 답(사용자 객체 · `null`)은 `app.load()` 가 `TypeError` 로 거절한다 — 인증으로 추측하지 않는다.
|
|
204
201
|
- 로그인 성공 시 `onSuccess()` 를 호출하면 앱이 재로드되어 셸이 나타나고 로그인 UI 는 정리된다.
|
|
205
202
|
- `auth` 미지정 시 완전히 하위호환(게이트 없이 기존대로 로드).
|
|
206
203
|
|
package/dist/App.js
CHANGED
|
@@ -52,17 +52,23 @@ var u = class s {
|
|
|
52
52
|
}, n;
|
|
53
53
|
try {
|
|
54
54
|
n = await a.auth.me();
|
|
55
|
-
} catch (
|
|
56
|
-
|
|
55
|
+
} catch (e) {
|
|
56
|
+
n = {
|
|
57
|
+
status: "unknown",
|
|
58
|
+
error: e
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (n?.status === "unknown") {
|
|
62
|
+
if (!a.auth.renderUnavailable) throw n.error;
|
|
57
63
|
let r = a.auth.renderUnavailable({
|
|
58
64
|
root: e,
|
|
59
|
-
error: n,
|
|
65
|
+
error: n.error,
|
|
60
66
|
retry: t
|
|
61
67
|
});
|
|
62
68
|
r && (this._gateTeardown = r);
|
|
63
69
|
return;
|
|
64
70
|
}
|
|
65
|
-
if (n
|
|
71
|
+
if (n?.status === "anonymous") {
|
|
66
72
|
let n = a.auth.renderLogin({
|
|
67
73
|
root: e,
|
|
68
74
|
onSuccess: t
|
|
@@ -70,7 +76,8 @@ var u = class s {
|
|
|
70
76
|
n && (this._gateTeardown = n);
|
|
71
77
|
return;
|
|
72
78
|
}
|
|
73
|
-
|
|
79
|
+
if (n?.status !== "authenticated") throw TypeError("auth.me() must return { status: 'authenticated', user } | { status: 'anonymous' } | { status: 'unknown', error }");
|
|
80
|
+
this._user = n.user, await a.auth.onAuthenticated?.(n.user);
|
|
74
81
|
}
|
|
75
82
|
let o = a.root || document.body;
|
|
76
83
|
this._layout = await this.createLayout(o, a.layout), this._screen = new e({
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { StyledElement } from '../internals/StyledElement.js';
|
|
2
2
|
type ElementParts = 'host' | 'icon' | 'message' | 'title' | 'description' | 'actions';
|
|
3
|
+
/** 빈 상태가 전하는 사실 — 다음 행동이 갈린다(`EmptyState.variant` 참조). */
|
|
4
|
+
export type EmptyStateVariant = 'no-data' | 'no-results' | 'error' | 'no-access';
|
|
3
5
|
/**
|
|
4
6
|
* 빈 상태 — 목록·검색 결과가 비었을 때.
|
|
5
7
|
*
|
|
@@ -19,6 +21,9 @@ type ElementParts = 'host' | 'icon' | 'message' | 'title' | 'description' | 'act
|
|
|
19
21
|
* <u-empty-state variant="error" .description=${error.message}>
|
|
20
22
|
* <u-button slot="actions" @click=${retry}>Try again</u-button>
|
|
21
23
|
* </u-empty-state>
|
|
24
|
+
*
|
|
25
|
+
* <!-- 라우트 가드가 막았다(403) — 장애가 아니라 권한의 사실이다. -->
|
|
26
|
+
* <u-empty-state variant="no-access"></u-empty-state>
|
|
22
27
|
* ```
|
|
23
28
|
*
|
|
24
29
|
* ⚠**기본 문구는 영어다** — 이 패키지는 범용 층이라 특정 언어를 기본값으로 가질 수 없다.
|
|
@@ -32,10 +37,12 @@ type ElementParts = 'host' | 'icon' | 'message' | 'title' | 'description' | 'act
|
|
|
32
37
|
export declare class EmptyState extends StyledElement<ElementParts> {
|
|
33
38
|
static styles: import('lit').CSSResultGroup[];
|
|
34
39
|
/**
|
|
35
|
-
* `no-data` = 아직 없음 / `no-results` = 조건에 맞는 것이 없음 / `error` = 불러오지
|
|
36
|
-
*
|
|
40
|
+
* `no-data` = 아직 없음 / `no-results` = 조건에 맞는 것이 없음 / `error` = 불러오지 못함 /
|
|
41
|
+
* `no-access` = 있지만 이 사용자에게 보여 줄 수 없음(라우트 가드의 거부 · 403).
|
|
42
|
+
* 넷은 다음 행동이 다르다(만들기 · 조건 바꾸기 · 다시 시도 · 권한 요청) — `error` 의 제목은 보조기기에 알린다(`role="alert"`).
|
|
43
|
+
* `no-access` 는 알리지 않는다 — 장애가 아니라 화면의 내용이고, 라우트 완료 때 본문으로 가는 포커스가 그것을 읽게 한다.
|
|
37
44
|
*/
|
|
38
|
-
variant:
|
|
45
|
+
variant: EmptyStateVariant;
|
|
39
46
|
/** 제목. 비우면 variant 기본 문구. */
|
|
40
47
|
title: string;
|
|
41
48
|
/** 보조 설명. 비우면 variant 기본 문구. */
|
|
@@ -16,19 +16,28 @@ var d = class extends n {
|
|
|
16
16
|
}
|
|
17
17
|
get defaults() {
|
|
18
18
|
let e = r(this.locale || void 0);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
19
|
+
switch (this.variant) {
|
|
20
|
+
case "error": return {
|
|
21
|
+
icon: "⚠️",
|
|
22
|
+
title: e.errorTitle,
|
|
23
|
+
description: e.errorDescription
|
|
24
|
+
};
|
|
25
|
+
case "no-access": return {
|
|
26
|
+
icon: "🔒",
|
|
27
|
+
title: e.noAccessTitle,
|
|
28
|
+
description: e.noAccessDescription
|
|
29
|
+
};
|
|
30
|
+
case "no-results": return {
|
|
31
|
+
icon: "🔍",
|
|
32
|
+
title: e.noResultsTitle,
|
|
33
|
+
description: e.noResultsDescription
|
|
34
|
+
};
|
|
35
|
+
default: return {
|
|
36
|
+
icon: "📄",
|
|
37
|
+
title: e.noDataTitle,
|
|
38
|
+
description: e.noDataDescription
|
|
39
|
+
};
|
|
40
|
+
}
|
|
32
41
|
}
|
|
33
42
|
render() {
|
|
34
43
|
let e = this.defaults, t = this.description || e.description;
|
|
@@ -53,6 +62,6 @@ var d = class extends n {
|
|
|
53
62
|
t([l({
|
|
54
63
|
type: String,
|
|
55
64
|
reflect: !0
|
|
56
|
-
}), e("design:type",
|
|
65
|
+
}), e("design:type", Object)], d.prototype, "variant", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "title", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "description", void 0), t([l({ type: String }), e("design:type", Object)], d.prototype, "locale", void 0), t([u(), e("design:type", Object)], d.prototype, "hasActions", void 0), d = t([c("u-empty-state")], d);
|
|
57
66
|
//#endregion
|
|
58
67
|
export { d as EmptyState };
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export type * from './types/AppConfigs';
|
|
|
3
3
|
export type * from './types/AppOptions';
|
|
4
4
|
export type * from './types/AuthConfig';
|
|
5
5
|
export type { SidebarPermissionGuard } from './layouts/SidebarPermission';
|
|
6
|
+
export type { RouteConfig, RouteContext, FallbackRouteConfig } from '@iyulab/router';
|
|
7
|
+
export { RouteError, NotFoundError, AccessDeniedError, ContentLoadError, ContentRenderError, OutletMissingError, } from '@iyulab/router';
|
|
6
8
|
export type { SidebarIconsConfig, SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarMenuConfig, SidebarMenuItemConfig, SidebarHtmlConfig, SidebarState, } from './layouts/SidebarLayout.types';
|
|
7
9
|
export type { SidebarLayoutEventMap } from './layouts/SidebarLayout.js';
|
|
8
10
|
export { filterSidebarItems } from './layouts/filterSidebarItems.js';
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,8 @@ import { ActionBar as g } from "./components/ActionBar.js";
|
|
|
13
13
|
import { MasterDetailLayout as _ } from "./components/MasterDetailLayout.js";
|
|
14
14
|
import { Wizard as v } from "./components/Wizard.js";
|
|
15
15
|
import { translate as y } from "./translate.js";
|
|
16
|
+
import { AccessDeniedError as b, ContentLoadError as x, ContentRenderError as S, NotFoundError as C, OutletMissingError as w, RouteError as T } from "@iyulab/router";
|
|
16
17
|
//#region src/index.ts
|
|
17
|
-
var
|
|
18
|
+
var E = t;
|
|
18
19
|
//#endregion
|
|
19
|
-
export { g as ActionBar, p as EmptyState, l as GroupBox, d as InfoField, u as InfoSection, _ as MasterDetailLayout, c as PageHeader, e as ScreenObserver, h as Timeline, m as TimelineItem, v as Wizard, t as app,
|
|
20
|
+
export { b as AccessDeniedError, g as ActionBar, x as ContentLoadError, S as ContentRenderError, p as EmptyState, l as GroupBox, d as InfoField, u as InfoSection, _ as MasterDetailLayout, C as NotFoundError, w as OutletMissingError, c as PageHeader, T as RouteError, e as ScreenObserver, h as Timeline, m as TimelineItem, v as Wizard, t as app, E as default, n as filterSidebarItems, r as getDefaultLocale, i as getLocaleStrings, f as isBlank, a as modernAppLocale, o as registerLocale, s as setDefaultLocale, y as translate };
|
|
@@ -10,6 +10,9 @@ export interface ModernAppLocaleStrings {
|
|
|
10
10
|
/** Empty state — the list could not be loaded (`variant="error"`). */
|
|
11
11
|
errorTitle: string;
|
|
12
12
|
errorDescription: string;
|
|
13
|
+
/** Empty state — the screen exists but this user may not see it (`variant="no-access"` — a route guard said no). */
|
|
14
|
+
noAccessTitle: string;
|
|
15
|
+
noAccessDescription: string;
|
|
13
16
|
/** Accessible label for an overlay-mode close button (master-detail layout, sidebar layout overlay). */
|
|
14
17
|
detailClose: string;
|
|
15
18
|
/** Sidebar layout — accessible label for the mobile-header menu toggle button. */
|
package/dist/internals/locale.js
CHANGED
|
@@ -8,6 +8,8 @@ var t = {
|
|
|
8
8
|
noResultsDescription: "Try changing your search terms or filters.",
|
|
9
9
|
errorTitle: "Couldn’t load this list",
|
|
10
10
|
errorDescription: "Something went wrong while loading. Try again.",
|
|
11
|
+
noAccessTitle: "You don’t have access",
|
|
12
|
+
noAccessDescription: "Ask an administrator for access if you need it.",
|
|
11
13
|
detailClose: "Close",
|
|
12
14
|
toggleMobileMenu: "Toggle menu",
|
|
13
15
|
toggleSidebar: "Toggle sidebar",
|
|
@@ -26,6 +28,8 @@ n.register("en", t), n.register("ko", {
|
|
|
26
28
|
noResultsDescription: "검색어나 필터를 바꿔 보세요.",
|
|
27
29
|
errorTitle: "목록을 불러오지 못했습니다",
|
|
28
30
|
errorDescription: "불러오는 중 문제가 생겼습니다. 다시 시도해 주세요.",
|
|
31
|
+
noAccessTitle: "접근 권한이 없습니다",
|
|
32
|
+
noAccessDescription: "필요하면 관리자에게 권한을 요청하세요.",
|
|
29
33
|
detailClose: "닫기",
|
|
30
34
|
toggleMobileMenu: "메뉴 열고 닫기",
|
|
31
35
|
toggleSidebar: "사이드바 열고 닫기",
|
|
@@ -62,6 +66,8 @@ function l(e) {
|
|
|
62
66
|
noResultsDescription: t("noResultsDescription"),
|
|
63
67
|
errorTitle: t("errorTitle"),
|
|
64
68
|
errorDescription: t("errorDescription"),
|
|
69
|
+
noAccessTitle: t("noAccessTitle"),
|
|
70
|
+
noAccessDescription: t("noAccessDescription"),
|
|
65
71
|
detailClose: t("detailClose"),
|
|
66
72
|
toggleMobileMenu: t("toggleMobileMenu"),
|
|
67
73
|
toggleSidebar: t("toggleSidebar"),
|
package/dist/react.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { EventName } from '@lit/react';
|
|
|
3
3
|
import { SidebarLayout as SidebarLayoutElement, SidebarLayoutEventMap } from './layouts/SidebarLayout.js';
|
|
4
4
|
import { Wizard as WizardElement, WizardEventMap } from './components/Wizard.js';
|
|
5
5
|
import { GroupBoxLevel } from './components/GroupBox.js';
|
|
6
|
+
import { EmptyStateVariant } from './components/EmptyState.js';
|
|
6
7
|
import { TimelineItemColor } from './components/TimelineItem.js';
|
|
7
8
|
import { InfoFieldFormat, InfoFieldSize, InfoFieldTrend, InfoFieldTone } from './components/InfoField.js';
|
|
8
9
|
export { ScreenObserver } from './internals/ScreenObserver.js';
|
|
@@ -37,7 +38,8 @@ declare module 'react' {
|
|
|
37
38
|
sticky?: boolean;
|
|
38
39
|
};
|
|
39
40
|
'u-empty-state': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
|
|
40
|
-
|
|
41
|
+
/** 요소의 유니온을 그대로 쓴다 — 손으로 옮긴 유니온이 `'error'` 를 빠뜨린 채 남아 있었다. */
|
|
42
|
+
variant?: EmptyStateVariant;
|
|
41
43
|
title?: string;
|
|
42
44
|
description?: string;
|
|
43
45
|
locale?: string;
|
|
@@ -101,8 +101,8 @@ export interface AppConfig {
|
|
|
101
101
|
*/
|
|
102
102
|
i18n?: I18nInitOptions;
|
|
103
103
|
/**
|
|
104
|
-
* 부팅 인증 게이트(선택). 지정하면 앱 셸을 만들기 전에 `me()`
|
|
105
|
-
*
|
|
104
|
+
* 부팅 인증 게이트(선택). 지정하면 앱 셸을 만들기 전에 `me()` 의 답(`AuthSession`)으로 세션을 판정하여
|
|
105
|
+
* `authenticated` 면 셸 로드, `anonymous` 면 `renderLogin` 으로 로그인 UI, `unknown`(또는 `me()` 가 던짐)이면
|
|
106
106
|
* `renderUnavailable` 을 그린다.
|
|
107
107
|
* @see AuthGateConfig
|
|
108
108
|
* @example
|
|
@@ -110,11 +110,7 @@ export interface AppConfig {
|
|
|
110
110
|
* app.load({
|
|
111
111
|
* layout: { type: 'sidebar', ... },
|
|
112
112
|
* auth: {
|
|
113
|
-
* me:
|
|
114
|
-
* const s = await authClient.fetchMe();
|
|
115
|
-
* if (s.status === 'unknown') throw s.error; // 모름 — 로그인이 아니라 renderUnavailable
|
|
116
|
-
* return s.status === 'authenticated' ? s.user : null; // null → 미인증
|
|
117
|
-
* },
|
|
113
|
+
* me: () => authClient.fetchMe(), // @iyulab/enterprise createAuthClient — SessionState 그대로
|
|
118
114
|
* renderLogin: ({ root, onSuccess }) => renderLoginPage(root, onSuccess),
|
|
119
115
|
* renderUnavailable: ({ root, retry }) => renderOfflinePage(root, retry),
|
|
120
116
|
* onAuthenticated: (user) => setPermissions((user as User).Permissions),
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 부팅 인증 게이트 설정.
|
|
3
3
|
*
|
|
4
|
-
* `app.load({ auth })` 에 넘기면, 앱 셸(레이아웃·라우터)을 만들기 전에 세션을
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* `app.load({ auth })` 에 넘기면, 앱 셸(레이아웃·라우터)을 만들기 전에 세션을 판정한다. `me()` 는
|
|
5
|
+
* 세 답 중 하나(`AuthSession`)를 돌려준다:
|
|
6
|
+
* - `authenticated` — 셸을 로드한다(`user` 가 `app.user` 가 된다).
|
|
7
|
+
* - `anonymous` — 셸 대신 `renderLogin` 으로 로그인 UI 를 띄운다. 로그인 성공 시 `onSuccess()` 를
|
|
8
|
+
* 호출하면 앱이 (재)로드되어 셸이 나타난다.
|
|
9
|
+
* - `unknown` — 세션을 확인하지 못했다(서버 다운·오프라인). 미인증이 아니므로 로그인 UI 를 그리지
|
|
10
|
+
* 않는다 — `renderUnavailable` 이 있으면 그것을, 없으면 `load` 가 그 오류로 실패한다. `me()` 가
|
|
11
|
+
* **던지는** 것도 같은 답이다.
|
|
12
|
+
*
|
|
13
|
+
* `@iyulab/enterprise` 의 `createAuthClient().fetchMe()` 가 돌려주는 `SessionState` 가 이 형태 그대로라
|
|
14
|
+
* `me: () => auth.fetchMe()` 로 잇는다.
|
|
10
15
|
*
|
|
11
16
|
* 프레임워크는 인증의 **오케스트레이션**(판정 → 분기 → 재로드)만 소유한다. 세션 조회/로그인
|
|
12
17
|
* 자체(HTTP)와 사용자·권한 형태, 세션-중 401 처리는 앱/`@iyulab/enterprise` 가 소유한다.
|
|
@@ -14,33 +19,46 @@
|
|
|
14
19
|
export interface AuthGateContext {
|
|
15
20
|
/** 로그인 UI 를 그릴 루트 요소(= `app.load` 의 `root`, 기본 `document.body`). */
|
|
16
21
|
root: Element;
|
|
17
|
-
/** 로그인 성공 시 호출한다. 앱이 (재)로드되어 셸을 띄운다(이때 `me()` 는
|
|
22
|
+
/** 로그인 성공 시 호출한다. 앱이 (재)로드되어 셸을 띄운다(이때 `me()` 는 `authenticated` 를 돌려줘야 한다). */
|
|
18
23
|
onSuccess: () => void;
|
|
19
24
|
}
|
|
20
25
|
/** `renderUnavailable` 이 받는 맥락 — 세션을 확인하지 못했을 때. */
|
|
21
26
|
export interface AuthGateUnavailableContext {
|
|
22
27
|
/** 안내 UI 를 그릴 루트 요소(= `app.load` 의 `root`, 기본 `document.body`). */
|
|
23
28
|
root: Element;
|
|
24
|
-
/** `me()` 가 던진 값. */
|
|
29
|
+
/** `unknown` 답의 `error`, 또는 `me()` 가 던진 값. */
|
|
25
30
|
error: unknown;
|
|
26
31
|
/** 다시 확인한다 — 앱이 (재)로드되어 `me()` 를 다시 부른다(재시도 버튼·`online` 이벤트에서). */
|
|
27
32
|
retry: () => void;
|
|
28
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* 세션 조회의 답 — `status` 로 가른다. «미인증»과 «모름»은 다른 답이다: 서버가 잠깐 503 을 낸 것을
|
|
36
|
+
* 미인증으로 다루면 로그인된 사용자가 로그인 화면으로 간다.
|
|
37
|
+
*/
|
|
38
|
+
export type AuthSession<TUser = unknown> = {
|
|
39
|
+
status: 'authenticated';
|
|
40
|
+
user: TUser;
|
|
41
|
+
} | {
|
|
42
|
+
status: 'anonymous';
|
|
43
|
+
} | {
|
|
44
|
+
status: 'unknown';
|
|
45
|
+
error: unknown;
|
|
46
|
+
};
|
|
29
47
|
export interface AuthGateConfig {
|
|
30
48
|
/**
|
|
31
|
-
* 현재 세션
|
|
32
|
-
*
|
|
33
|
-
*
|
|
49
|
+
* 현재 세션 조회 — `AuthSession` 을 돌려준다(동기/비동기 모두 허용). 던지면 `unknown` 으로 본다.
|
|
50
|
+
* `status` 가 셋 중 하나가 아닌 값을 돌려주면 `load` 가 `TypeError` 로 실패한다 — 사용자 객체를 그대로
|
|
51
|
+
* 돌려주는 옛 형태를 «인증됨»으로 추측하지 않는다.
|
|
34
52
|
*/
|
|
35
|
-
me: () =>
|
|
53
|
+
me: () => AuthSession | Promise<AuthSession>;
|
|
36
54
|
/**
|
|
37
55
|
* 미인증 시 `context.root` 에 로그인 UI 를 그린다. 성공하면 `context.onSuccess()` 를 호출해야 한다.
|
|
38
56
|
* 정리 함수를 반환하면 앱 로드/`unload` 시 호출되어 로그인 UI 를 제거한다.
|
|
39
57
|
*/
|
|
40
58
|
renderLogin: (context: AuthGateContext) => (() => void) | void;
|
|
41
59
|
/**
|
|
42
|
-
* `me()` 가
|
|
43
|
-
* 확인한다. 정리 함수를 반환하면 앱 로드/`unload` 시 호출된다. 없으면 `load` 가
|
|
60
|
+
* 세션을 모를 때(`unknown` · `me()` 가 던짐) `context.root` 에 안내 UI 를 그린다(선택). `context.retry()` 로
|
|
61
|
+
* 다시 확인한다. 정리 함수를 반환하면 앱 로드/`unload` 시 호출된다. 없으면 `load` 가 그 오류로 실패한다.
|
|
44
62
|
*/
|
|
45
63
|
renderUnavailable?: (context: AuthGateUnavailableContext) => (() => void) | void;
|
|
46
64
|
/**
|
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.46.0",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iyulab",
|
|
7
7
|
"web-framework",
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"@iyulab/components": ">=2.8.0",
|
|
82
82
|
"@lit/react": "^1.0.8",
|
|
83
83
|
"@types/node": "^26.1.1",
|
|
84
|
-
"@types/react": "^19.
|
|
85
|
-
"@types/react-dom": "^19.
|
|
84
|
+
"@types/react": "^19.3.0",
|
|
85
|
+
"@types/react-dom": "^19.3.0",
|
|
86
86
|
"@vitest/browser-playwright": "^5.0.0",
|
|
87
87
|
"glob": "^13.0.6",
|
|
88
88
|
"happy-dom": "^20.10.6",
|
|
@@ -190,8 +190,11 @@ interface FallbackRouteConfig {
|
|
|
190
190
|
}
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
`RouteError`
|
|
194
|
-
|
|
193
|
+
`RouteError` (`code`/`original`/`timestamp` alongside the inherited `message`) is always populated
|
|
194
|
+
when the fallback fires. It and its subclasses (`AccessDeniedError` — an `enter` guard returned `false`,
|
|
195
|
+
code 403 · `NotFoundError` · `ContentLoadError` · `ContentRenderError` · `OutletMissingError`) are
|
|
196
|
+
exported from `@iyulab/modern-app` — tell a blocked screen from a missing one with
|
|
197
|
+
`ctx.error instanceof AccessDeniedError` and render it as `<u-empty-state variant="no-access">`.
|
|
195
198
|
|
|
196
199
|
---
|
|
197
200
|
|
|
@@ -227,13 +230,23 @@ The framework owns only the orchestration (check → branch → reload) — sess
|
|
|
227
230
|
`@iyulab/enterprise`'s `createAuthClient`/`createODataService`).
|
|
228
231
|
|
|
229
232
|
```typescript
|
|
233
|
+
/**
|
|
234
|
+
* A session lookup's answer. "Signed out" and "could not tell" are different answers — treating a
|
|
235
|
+
* brief 503 as signed out sends a signed-in user to the login screen. `@iyulab/enterprise`'s
|
|
236
|
+
* `createAuthClient().fetchMe()` returns exactly this shape: `me: () => auth.fetchMe()`.
|
|
237
|
+
*/
|
|
238
|
+
type AuthSession<TUser = unknown> =
|
|
239
|
+
| { status: 'authenticated'; user: TUser } // shell loads; app.user = user
|
|
240
|
+
| { status: 'anonymous' } // renderLogin
|
|
241
|
+
| { status: 'unknown'; error: unknown }; // renderUnavailable
|
|
242
|
+
|
|
230
243
|
interface AuthGateConfig {
|
|
231
244
|
/**
|
|
232
|
-
* Resolve the current session
|
|
233
|
-
*
|
|
234
|
-
*
|
|
245
|
+
* Resolve the current session — sync or async. Throwing counts as `unknown`. An answer whose
|
|
246
|
+
* `status` is not one of the three makes `app.load()` reject with a `TypeError`: a user object
|
|
247
|
+
* or `null` is never guessed to mean "signed in".
|
|
235
248
|
*/
|
|
236
|
-
me: () =>
|
|
249
|
+
me: () => AuthSession | Promise<AuthSession>;
|
|
237
250
|
|
|
238
251
|
/**
|
|
239
252
|
* Renders login UI into `context.root` when unauthenticated. Call `context.onSuccess()` on
|
|
@@ -242,10 +255,11 @@ interface AuthGateConfig {
|
|
|
242
255
|
renderLogin: (context: AuthGateContext) => (() => void) | void;
|
|
243
256
|
|
|
244
257
|
/**
|
|
245
|
-
* Renders a "can't reach the server" view into `context.root` when `
|
|
258
|
+
* Renders a "can't reach the server" view into `context.root` when the session is `unknown`
|
|
259
|
+
* (or `me()` throws). Call
|
|
246
260
|
* `context.retry()` to check again (a retry button, the `online` event). Return a cleanup
|
|
247
261
|
* function to have it called on app load/`unload`. Without it, `app.load()` rejects with
|
|
248
|
-
*
|
|
262
|
+
* that error.
|
|
249
263
|
*/
|
|
250
264
|
renderUnavailable?: (context: AuthGateUnavailableContext) => (() => void) | void;
|
|
251
265
|
|
|
@@ -263,7 +277,7 @@ interface AuthGateContext {
|
|
|
263
277
|
interface AuthGateUnavailableContext {
|
|
264
278
|
/** Root element to render into (same as `AppConfig.root`, default `document.body`). */
|
|
265
279
|
root: Element;
|
|
266
|
-
/**
|
|
280
|
+
/** The `unknown` answer's `error`, or what `me()` threw. */
|
|
267
281
|
error: unknown;
|
|
268
282
|
/** Check again — the app (re)loads and calls `me()` again. */
|
|
269
283
|
retry: () => void;
|
|
@@ -8,6 +8,8 @@ Shown when a list or a search comes back with nothing.
|
|
|
8
8
|
*nothing has been created yet* and the next action is **create**. The second means *nothing
|
|
9
9
|
matches these conditions* and the next action is **change the filter**. Show the same wording
|
|
10
10
|
for both and a user with a filter still applied reads it as *"my data disappeared"*.
|
|
11
|
+
The same holds for a screen this user may not see: it is neither empty nor broken, and its next
|
|
12
|
+
action is **asking for access**.
|
|
11
13
|
|
|
12
14
|
```html
|
|
13
15
|
<u-empty-state variant="no-data" title="No orders yet">
|
|
@@ -20,6 +22,9 @@ for both and a user with a filter still applied reads it as *"my data disappeare
|
|
|
20
22
|
<u-empty-state variant="error" description="Request failed (503)">
|
|
21
23
|
<u-button slot="actions">Try again</u-button>
|
|
22
24
|
</u-empty-state>
|
|
25
|
+
|
|
26
|
+
<!-- A route guard said no (403) — not an outage, so it is not announced as an alert. -->
|
|
27
|
+
<u-empty-state variant="no-access"></u-empty-state>
|
|
23
28
|
```
|
|
24
29
|
|
|
25
30
|
## Slots
|
|
@@ -33,7 +38,7 @@ for both and a user with a filter still applied reads it as *"my data disappeare
|
|
|
33
38
|
|
|
34
39
|
| Property | Type | Default | Reflect | Description |
|
|
35
40
|
|----------|------|---------|---------|-------------|
|
|
36
|
-
| `variant` | `'no-data'\|'no-results'\|'error'` | `'no-data'` | ✓ | Which fact is being shown; changes the default wording. `error` (could not load) announces its title |
|
|
41
|
+
| `variant` | `'no-data'\|'no-results'\|'error'\|'no-access'` | `'no-data'` | ✓ | Which fact is being shown; changes the default wording. `error` (could not load) announces its title; `no-access` (this user may not see it) does not |
|
|
37
42
|
| `title` | `string` | `''` | | Override the default title |
|
|
38
43
|
| `description` | `string` | `''` | | Override the default description |
|
|
39
44
|
| `locale` | `string` | `''` | | Locale tag override for built-in strings |
|
|
@@ -63,3 +68,23 @@ for both and a user with a filter still applied reads it as *"my data disappeare
|
|
|
63
68
|
⚠ Default wording is **English**, with Korean built in — choose the language with `Locale.set()` of
|
|
64
69
|
`@iyulab/components`, register others with `modernAppLocale.register(lang, …)`, or pass
|
|
65
70
|
`title`/`description` per screen.
|
|
71
|
+
|
|
72
|
+
## Blocked routes
|
|
73
|
+
|
|
74
|
+
A route `enter` guard that returns `false` raises `AccessDeniedError` (code 403) into the app
|
|
75
|
+
`fallback`. The error classes are exported from `@iyulab/modern-app`, so the fallback can tell a
|
|
76
|
+
blocked screen from a missing one without depending on the router directly:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { app, AccessDeniedError } from '@iyulab/modern-app';
|
|
80
|
+
import { html } from 'lit';
|
|
81
|
+
|
|
82
|
+
await app.load({
|
|
83
|
+
// …
|
|
84
|
+
fallback: {
|
|
85
|
+
render: (ctx) => ctx.error instanceof AccessDeniedError
|
|
86
|
+
? html`<u-empty-state variant="no-access"></u-empty-state>`
|
|
87
|
+
: html`<u-empty-state variant="error" .description=${ctx.error.message}></u-empty-state>`,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
```
|