@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 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: async () => {
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()` 가 값을 반환하면 셸 로드, `null`/`undefined` 면 `renderLogin({ root, onSuccess })`.
203
- - `me()` 가 **던지면** 세션을 «모름» 으로 보고 로그인 UI 를 그리지 않는다 — `renderUnavailable({ root, error, retry })` 를 그리고, 없으면 `app.load()` 가 그 오류로 실패한다. 서버가 잠깐 503 을 낸 것을 «미인증» 으로 돌려주면 로그인된 사용자가 로그인 화면으로 간다.
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 (n) {
56
- if (!a.auth.renderUnavailable) throw n;
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 == null) {
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
- this._user = n, await a.auth.onAuthenticated?.(n);
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
- * 셋은 다음 행동이 다르다(만들기 · 조건 바꾸기 · 다시 시도) — `error` 의 제목은 보조기기에 알린다(`role="alert"`).
40
+ * `no-data` = 아직 없음 / `no-results` = 조건에 맞는 것이 없음 / `error` = 불러오지 못함 /
41
+ * `no-access` = 있지만 이 사용자에게 보여 줄 수 없음(라우트 가드의 거부 · 403).
42
+ * 넷은 다음 행동이 다르다(만들기 · 조건 바꾸기 · 다시 시도 · 권한 요청) — `error` 의 제목은 보조기기에 알린다(`role="alert"`).
43
+ * `no-access` 는 알리지 않는다 — 장애가 아니라 화면의 내용이고, 라우트 완료 때 본문으로 가는 포커스가 그것을 읽게 한다.
37
44
  */
38
- variant: 'no-data' | 'no-results' | 'error';
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
- return this.variant === "error" ? {
20
- icon: "⚠️",
21
- title: e.errorTitle,
22
- description: e.errorDescription
23
- } : this.variant === "no-results" ? {
24
- icon: "🔍",
25
- title: e.noResultsTitle,
26
- description: e.noResultsDescription
27
- } : {
28
- icon: "📄",
29
- title: e.noDataTitle,
30
- description: e.noDataDescription
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", String)], 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);
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 b = t;
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, b 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 };
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. */
@@ -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
- variant?: 'no-data' | 'no-results';
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
- * 인증 시 셸 로드, 미인증 시 `renderLogin` 으로 로그인 UI 를 띄운다. `me()` 가 던지면(세션 모름)
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: async () => { // @iyulab/enterprise createAuthClient
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
- * `me()` 가 값을 반환하면 인증된 것으로 보고 셸을 로드하고, `null`/`undefined` 면
6
- * 미인증으로 보고 `renderLogin` 으로 로그인 UI 를 띄운다. 로그인 성공 시 `onSuccess()` 를
7
- * 호출하면 앱이 (재)로드되어 셸이 나타난다. `me()` 가 **던지면** 세션을 «모름»(서버 다운·오프라인)
8
- * 으로 보고 로그인 UI 를 그리지 않는다 — `renderUnavailable` 이 있으면 그것을, 없으면 `load` 가 그
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
- * 현재 세션 조회. 값을 반환하면 인증, `null`/`undefined` 면 미인증으로 본다. **세션을 확인하지
32
- * 못했으면 던진다**(서버 다운·오프라인) — 그것을 `null` 로 돌려주면 로그인된 사용자가 로그인 화면으로 간다.
33
- * 동기/비동기 모두 허용한다.
49
+ * 현재 세션 조회 — `AuthSession` 을 돌려준다(동기/비동기 모두 허용). 던지면 `unknown` 으로 본다.
50
+ * `status` 가 셋 중 하나가 아닌 값을 돌려주면 `load` 가 `TypeError` 로 실패한다 — 사용자 객체를 그대로
51
+ * 돌려주는 옛 형태를 «인증됨»으로 추측하지 않는다.
34
52
  */
35
- me: () => Promise<unknown | null | undefined> | unknown | null | undefined;
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()` 가 던졌을 때(세션 모름) `context.root` 에 안내 UI 를 그린다(선택). `context.retry()` 로 다시
43
- * 확인한다. 정리 함수를 반환하면 앱 로드/`unload` 시 호출된다. 없으면 `load` 가 `me()` 의 오류로 실패한다.
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.44.3",
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.2.14",
85
- "@types/react-dom": "^19.2.3",
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` is importable from `@iyulab/router` (`code`/`original`/`timestamp` alongside the
194
- inherited `message`) — always populated when the fallback fires.
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. Return a value for authenticated, `null`/`undefined` for not.
233
- * **Throw when the session could not be checked** (server down, offline) — returning `null`
234
- * there would send a signed-in user to the login screen.
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: () => Promise<unknown | null | undefined> | unknown | null | undefined;
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 `me()` throws. Call
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
- * `me()`'s error.
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
- /** What `me()` threw. */
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
+ ```