@k2b/cloud 0.21.1 → 0.23.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/package.json +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/define-app.ts +81 -7
- package/src/_internal/page-responses.ts +19 -3
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/client/controller.ts +17 -3
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/live-routes.ts +2 -2
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +161 -0
- package/src/api/pwa.ts +179 -0
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +826 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +150 -30
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pwa-devices.ts +574 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/extensions/info-blocks.ts +18 -47
- package/src/shared/markdown/index.ts +5 -14
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +9 -34
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +69 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/effects.css +6 -30
- package/src/styles/global.css +11 -0
- package/src/styles/tokens.css +5 -1
- package/src/styles/utilities-feedback.css +15 -5
- package/src/styles/utilities-layout.css +4 -0
- package/src/styles/utilities-markdown-editor.css +20 -2
- package/src/styles/utilities-markdown-table.css +35 -17
- package/scripts/build-pdf-renderer.ts +0 -39
|
@@ -26,7 +26,7 @@ export type AppLaunchpadLegalLink = {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
export type AppLaunchpadContext = {
|
|
29
|
-
profile?: { name: string; theme: CloudTheme };
|
|
29
|
+
profile?: { name: string; theme: CloudTheme; appHref?: string };
|
|
30
30
|
apps: AppLaunchpadApp[];
|
|
31
31
|
legalLinks: AppLaunchpadLegalLink[];
|
|
32
32
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Button, dialogCore, MarkdownView, PanelDialog, panelDialogOptions, useLocale } from "@k2b/ui";
|
|
1
|
+
import { Button, dialogCore, IconButton, MarkdownView, NoticeCard, PanelDialog, panelDialogOptions, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createSignal, For, onMount, Show } from "solid-js";
|
|
3
3
|
import {
|
|
4
4
|
ANNOUNCEMENTS_COOKIE,
|
|
@@ -21,22 +21,6 @@ const writeCookieState = (state: AnnouncementCookieState) => {
|
|
|
21
21
|
document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(state)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const toneClass = (tone: AnnouncementDisplayEntry["tone"]) => {
|
|
25
|
-
if (tone === "success")
|
|
26
|
-
return "border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-100";
|
|
27
|
-
if (tone === "warning")
|
|
28
|
-
return "border-amber-200 bg-amber-50 text-amber-950 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-100";
|
|
29
|
-
if (tone === "danger") return "border-red-200 bg-red-50 text-red-950 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-100";
|
|
30
|
-
return "border-blue-200 bg-blue-50 text-blue-950 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100";
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const toneIcon = (tone: AnnouncementDisplayEntry["tone"]) => {
|
|
34
|
-
if (tone === "success") return "ti ti-circle-check";
|
|
35
|
-
if (tone === "warning") return "ti ti-alert-triangle";
|
|
36
|
-
if (tone === "danger") return "ti ti-alert-circle";
|
|
37
|
-
return "ti ti-info-circle";
|
|
38
|
-
};
|
|
39
|
-
|
|
40
24
|
export default function GlobalAnnouncements(props: Props) {
|
|
41
25
|
const locale = useLocale();
|
|
42
26
|
const t = () => platformMessages.resolve([locale()]).t;
|
|
@@ -113,27 +97,18 @@ export default function GlobalAnnouncements(props: Props) {
|
|
|
113
97
|
<div class="flex shrink-0 flex-col gap-1">
|
|
114
98
|
<For each={banners()}>
|
|
115
99
|
{(banner) => (
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
>
|
|
119
|
-
<i class={`${toneIcon(banner.tone)} mt-0.5 shrink-0`} />
|
|
120
|
-
<div class="min-h-0 min-w-0 flex-1">
|
|
121
|
-
<p class="font-semibold">{banner.title}</p>
|
|
100
|
+
<div class="cloud-announcement">
|
|
101
|
+
<NoticeCard tone={banner.tone} title={banner.title}>
|
|
122
102
|
<MarkdownView
|
|
123
103
|
trustedHtml={banner.bodyHtml}
|
|
124
104
|
headingScale="compact"
|
|
125
|
-
class="
|
|
105
|
+
class="max-h-36 overflow-y-auto overscroll-contain pr-1 [&_p]:my-0"
|
|
126
106
|
/>
|
|
127
|
-
</
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
onClick={() => dismissBanner(banner.version)}
|
|
133
|
-
>
|
|
134
|
-
<i class="ti ti-x" />
|
|
135
|
-
</button>
|
|
136
|
-
</section>
|
|
107
|
+
</NoticeCard>
|
|
108
|
+
<IconButton size="sm" label={t().dismissBanner} onClick={() => dismissBanner(banner.version)}>
|
|
109
|
+
<i class="ti ti-x" aria-hidden="true" />
|
|
110
|
+
</IconButton>
|
|
111
|
+
</div>
|
|
137
112
|
)}
|
|
138
113
|
</For>
|
|
139
114
|
</div>
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { JSX } from "solid-js/jsx-runtime";
|
|
|
4
4
|
import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
|
|
5
5
|
import { navigationSearchItems } from "../browser/navigation-search";
|
|
6
6
|
import { resolveNavMatch } from "../contracts/app"; // ==========================
|
|
7
|
+
import { isPwaShellAvailable } from "../contracts/pwa";
|
|
7
8
|
import { defaultRailPreferences } from "../contracts/rail-preferences";
|
|
8
9
|
import { hasRole, type User } from "../contracts/shared";
|
|
9
10
|
import { getLocale } from "../server/locale";
|
|
@@ -90,9 +91,7 @@ function ProfileWarnings({ user, locale }: { user: User; locale: string }) {
|
|
|
90
91
|
if (missing.length === 0) return null;
|
|
91
92
|
return (
|
|
92
93
|
<a href="/me" class="block shrink-0 no-underline">
|
|
93
|
-
<NoticeCard tone="warning"
|
|
94
|
-
<i class="ti ti-user-exclamation" /> <span>{t.profileIncomplete({ fields: missing.join(", ") })}</span>
|
|
95
|
-
</NoticeCard>
|
|
94
|
+
<NoticeCard tone="warning">{t.profileIncomplete({ fields: missing.join(", ") })}</NoticeCard>
|
|
96
95
|
</a>
|
|
97
96
|
);
|
|
98
97
|
}
|
|
@@ -100,13 +99,12 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
|
|
|
100
99
|
const t = platformMessages.resolve([locale]).t;
|
|
101
100
|
const now = Date.now();
|
|
102
101
|
const warnThreshold = now + WARN_DAYS * 24 * 60 * 60 * 1000;
|
|
103
|
-
const warnings: {
|
|
102
|
+
const warnings: { message: string; expired: boolean }[] = [];
|
|
104
103
|
if (user.accountExpires) {
|
|
105
104
|
const expires = new Date(user.accountExpires).getTime();
|
|
106
|
-
if (expires < now) warnings.push({
|
|
105
|
+
if (expires < now) warnings.push({ message: t.accountExpired, expired: true });
|
|
107
106
|
else if (expires < warnThreshold)
|
|
108
107
|
warnings.push({
|
|
109
|
-
icon: "ti-calendar-event",
|
|
110
108
|
message:
|
|
111
109
|
user.profile === "guest"
|
|
112
110
|
? t.guestAccountExpires({ date: dates.formatDate(user.accountExpires, dateConfig) })
|
|
@@ -116,10 +114,9 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
|
|
|
116
114
|
}
|
|
117
115
|
if (user.ipa?.passwordExpires) {
|
|
118
116
|
const expires = new Date(user.ipa.passwordExpires).getTime();
|
|
119
|
-
if (expires < now) warnings.push({
|
|
117
|
+
if (expires < now) warnings.push({ message: t.passwordExpired, expired: true });
|
|
120
118
|
else if (expires < warnThreshold)
|
|
121
119
|
warnings.push({
|
|
122
|
-
icon: "ti-key",
|
|
123
120
|
message: t.passwordExpires({ date: dates.formatDate(user.ipa.passwordExpires, dateConfig) }),
|
|
124
121
|
expired: false,
|
|
125
122
|
});
|
|
@@ -129,10 +126,7 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
|
|
|
129
126
|
<div class="flex shrink-0 flex-col gap-1">
|
|
130
127
|
{" "}
|
|
131
128
|
{warnings.map((w) => (
|
|
132
|
-
<NoticeCard tone={w.expired ? "danger" : "warning"}
|
|
133
|
-
{" "}
|
|
134
|
-
<i class={`ti ${w.icon}`} /> <span>{w.message}</span>{" "}
|
|
135
|
-
</NoticeCard>
|
|
129
|
+
<NoticeCard tone={w.expired ? "danger" : "warning"}>{w.message}</NoticeCard>
|
|
136
130
|
))}{" "}
|
|
137
131
|
</div>
|
|
138
132
|
);
|
|
@@ -205,6 +199,8 @@ export default function Layout(props: LayoutProps) {
|
|
|
205
199
|
if (!page.title) page.title = pageTitle;
|
|
206
200
|
const breadcrumbs: Breadcrumb[] = !title ? [{ title: appName }] : typeof title === "string" ? [{ title }] : title;
|
|
207
201
|
const showRail = !!user;
|
|
202
|
+
// The profile menu offers the mobile app only while the installation runs it.
|
|
203
|
+
const appHref = user && isPwaShellAvailable(runtime.apps) ? "/me/app" : undefined;
|
|
208
204
|
const profileName = user?.displayName || user?.uid || "?";
|
|
209
205
|
const profileAvatarSrc =
|
|
210
206
|
user?.avatarHash && user.id
|
|
@@ -253,6 +249,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
253
249
|
{showRail && (
|
|
254
250
|
<LayoutRail
|
|
255
251
|
accent={currentApp?.appearance?.accent}
|
|
252
|
+
appHref={appHref}
|
|
256
253
|
appsLabel={t.apps}
|
|
257
254
|
homeLabel={t.home}
|
|
258
255
|
launchpadApps={launchpadApps}
|
|
@@ -270,6 +267,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
270
267
|
)}
|
|
271
268
|
<div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col">
|
|
272
269
|
<LayoutHeader
|
|
270
|
+
appHref={appHref}
|
|
273
271
|
appLabel={currentApp?.name ?? (pathname.startsWith("/admin") ? t.admin : undefined)}
|
|
274
272
|
accent={currentApp?.appearance?.accent}
|
|
275
273
|
authenticated={showRail}
|
package/src/ssr/LayoutHeader.tsx
CHANGED
|
@@ -12,6 +12,8 @@ import ProfilePreferences from "./ProfilePreferences.island";
|
|
|
12
12
|
|
|
13
13
|
type LayoutHeaderProps = {
|
|
14
14
|
accent?: string;
|
|
15
|
+
/** Set while the installation offers the mobile app. */
|
|
16
|
+
appHref?: string;
|
|
15
17
|
appLabel?: string;
|
|
16
18
|
authenticated: boolean;
|
|
17
19
|
breadcrumbs: LayoutBreadcrumb[];
|
|
@@ -64,7 +66,11 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
|
64
66
|
/>
|
|
65
67
|
{(props.authenticated || props.appLabel) && (
|
|
66
68
|
<AppLaunchpad
|
|
67
|
-
profile={
|
|
69
|
+
profile={
|
|
70
|
+
props.authenticated
|
|
71
|
+
? { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) }
|
|
72
|
+
: undefined
|
|
73
|
+
}
|
|
68
74
|
apps={props.launchpadApps}
|
|
69
75
|
legalLinks={props.legalLinks}
|
|
70
76
|
variant="header"
|
|
@@ -74,7 +80,13 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
|
|
|
74
80
|
</div>
|
|
75
81
|
{props.authenticated ? (
|
|
76
82
|
<div class="hidden lg:block">
|
|
77
|
-
<ProfilePreferences
|
|
83
|
+
<ProfilePreferences
|
|
84
|
+
appHref={props.appHref}
|
|
85
|
+
avatarSrc={props.profileAvatarSrc}
|
|
86
|
+
initialTheme={props.theme}
|
|
87
|
+
name={props.profileName}
|
|
88
|
+
placement="header"
|
|
89
|
+
/>
|
|
78
90
|
</div>
|
|
79
91
|
) : (
|
|
80
92
|
<>
|
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -753,10 +753,8 @@ const HelpShell = (props: {
|
|
|
753
753
|
</Show>
|
|
754
754
|
<Show when={loadError()}>
|
|
755
755
|
{(message) => (
|
|
756
|
-
<NoticeCard tone="danger"
|
|
757
|
-
<
|
|
758
|
-
<p class="mt-1 text-sm">{message()}</p>
|
|
759
|
-
<Button size="sm" variant="secondary" class="mt-3" onClick={() => setLoadAttempt((value) => value + 1)}>
|
|
756
|
+
<NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
|
|
757
|
+
<Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
|
|
760
758
|
{t().tryAgain}
|
|
761
759
|
</Button>
|
|
762
760
|
</NoticeCard>
|
package/src/ssr/LayoutRail.tsx
CHANGED
|
@@ -12,6 +12,8 @@ export type LayoutAppLink = RailApp;
|
|
|
12
12
|
|
|
13
13
|
type LayoutRailProps = {
|
|
14
14
|
accent?: string;
|
|
15
|
+
/** Set while the installation offers the mobile app. */
|
|
16
|
+
appHref?: string;
|
|
15
17
|
appsLabel: string;
|
|
16
18
|
homeLabel: string;
|
|
17
19
|
launchpadApps: AppLaunchpadApp[];
|
|
@@ -31,11 +33,12 @@ type LayoutRailProps = {
|
|
|
31
33
|
const jsonScript = (value: unknown): string => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
32
34
|
|
|
33
35
|
export default function LayoutRail(props: LayoutRailProps) {
|
|
36
|
+
const profile = { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) };
|
|
34
37
|
return (
|
|
35
38
|
<>
|
|
36
|
-
<AppLaunchpad profile={
|
|
39
|
+
<AppLaunchpad profile={profile} apps={props.launchpadApps} legalLinks={props.legalLinks} />
|
|
37
40
|
<script id="cloud-app-launchpad-data" type="application/json">
|
|
38
|
-
{jsonScript({ apps: props.launchpadApps, legalLinks: props.legalLinks, profile
|
|
41
|
+
{jsonScript({ apps: props.launchpadApps, legalLinks: props.legalLinks, profile })}
|
|
39
42
|
</script>
|
|
40
43
|
<script id="cloud-rail-data" type="application/json">
|
|
41
44
|
{jsonScript({ apps: props.apps, settings: props.railSettings })}
|
|
@@ -49,7 +52,7 @@ export default function LayoutRail(props: LayoutRailProps) {
|
|
|
49
52
|
<nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={props.appsLabel}>
|
|
50
53
|
<RailApps apps={props.apps} settings={props.railSettings} currentPath={props.currentPath} />
|
|
51
54
|
<AppLaunchpad
|
|
52
|
-
profile={
|
|
55
|
+
profile={profile}
|
|
53
56
|
apps={props.launchpadApps}
|
|
54
57
|
legalLinks={props.legalLinks}
|
|
55
58
|
variant="rail"
|
|
@@ -58,7 +61,13 @@ export default function LayoutRail(props: LayoutRailProps) {
|
|
|
58
61
|
<div class="mt-auto shrink-0 flex flex-col items-center gap-1">
|
|
59
62
|
<GlobalSearchTrigger variant="rail" searchLinks={props.searchLinks} />
|
|
60
63
|
<LayoutHelpTrigger variant="rail" registerCommand searchHelpApps={props.searchHelpApps} accent={props.accent} />
|
|
61
|
-
<ProfilePreferences
|
|
64
|
+
<ProfilePreferences
|
|
65
|
+
appHref={props.appHref}
|
|
66
|
+
avatarSrc={props.profileAvatarSrc}
|
|
67
|
+
initialTheme={props.theme}
|
|
68
|
+
name={props.profileName}
|
|
69
|
+
placement="rail"
|
|
70
|
+
/>
|
|
62
71
|
</div>
|
|
63
72
|
</nav>
|
|
64
73
|
</aside>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Button, Dropdown, useLocale } from "@k2b/ui";
|
|
2
|
-
import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../browser/locale-preference";
|
|
3
2
|
import { canonicalLocale } from "../shared/locale";
|
|
3
|
+
import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
|
|
4
4
|
import type { CloudTheme } from "../shared/theme";
|
|
5
5
|
import { createPreferenceController } from "./preference-controller";
|
|
6
6
|
|
|
@@ -3,24 +3,30 @@ import { Button, ButtonLink } from "@k2b/ui";
|
|
|
3
3
|
import type { CloudTheme } from "../shared/theme";
|
|
4
4
|
import { createProfileActions } from "./profile-actions";
|
|
5
5
|
|
|
6
|
-
export function MobileProfileActions(props: {
|
|
7
|
-
|
|
6
|
+
export function MobileProfileActions(props: {
|
|
7
|
+
profile: { name: string; theme: CloudTheme; appHref?: string };
|
|
8
|
+
beforeSelect: () => Promise<boolean>;
|
|
9
|
+
}) {
|
|
10
|
+
const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
|
|
11
|
+
const navigate = (href: string) => (event: MouseEvent) => {
|
|
12
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
void props.beforeSelect().then((allowed) => {
|
|
15
|
+
if (allowed) documentNavigate(href);
|
|
16
|
+
});
|
|
17
|
+
};
|
|
8
18
|
return (
|
|
9
19
|
<section class="cloud-mobile-profile" aria-label={preferences.messages().menuLabel}>
|
|
10
|
-
<ButtonLink
|
|
11
|
-
href="/me"
|
|
12
|
-
variant="ghost"
|
|
13
|
-
onClick={(event) => {
|
|
14
|
-
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
15
|
-
event.preventDefault();
|
|
16
|
-
void props.beforeSelect().then((allowed) => {
|
|
17
|
-
if (allowed) documentNavigate("/me");
|
|
18
|
-
});
|
|
19
|
-
}}
|
|
20
|
-
>
|
|
20
|
+
<ButtonLink href="/me" variant="ghost" onClick={navigate("/me")}>
|
|
21
21
|
<i class="ti ti-user-circle" aria-hidden="true" />
|
|
22
22
|
{preferences.messages().profileSettings}
|
|
23
23
|
</ButtonLink>
|
|
24
|
+
{props.profile.appHref && (
|
|
25
|
+
<ButtonLink href={props.profile.appHref} variant="ghost" onClick={navigate(props.profile.appHref)}>
|
|
26
|
+
<i class="ti ti-device-mobile" aria-hidden="true" />
|
|
27
|
+
{preferences.messages().app}
|
|
28
|
+
</ButtonLink>
|
|
29
|
+
)}
|
|
24
30
|
<Button variant="ghost" onClick={preferences.toggleTheme}>
|
|
25
31
|
<i class={`ti ${preferences.theme() === "light" ? "ti-moon" : "ti-sun-high"}`} aria-hidden="true" />
|
|
26
32
|
{preferences.themeLabel()}
|
package/src/ssr/PageError.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { NotFoundState } from "@k2b/ui";
|
|
1
|
+
import { ButtonLink, NotFoundState, Placeholder } from "@k2b/ui";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import type { PageErrorOptions, PageErrorStatus } from "../_internal/page-responses";
|
|
4
|
+
import { PWA_SCOPE } from "../contracts/pwa";
|
|
4
5
|
import { getLocale } from "../server/locale";
|
|
5
6
|
import Layout from "./Layout";
|
|
6
7
|
import MinimalLayout from "./MinimalLayout";
|
|
8
|
+
import PwaLayout from "./PwaLayout";
|
|
7
9
|
import { pageErrorMessages } from "./page-error-messages";
|
|
10
|
+
import { pwaMessages } from "./pwa-messages";
|
|
8
11
|
|
|
9
12
|
export const renderPageError = (c: Context, status: PageErrorStatus, options: PageErrorOptions) => {
|
|
10
13
|
const { t } = pageErrorMessages.resolve([getLocale(c)]);
|
|
@@ -14,6 +17,25 @@ export const renderPageError = (c: Context, status: PageErrorStatus, options: Pa
|
|
|
14
17
|
const action = options.action ?? { label: t.home, href: "/" };
|
|
15
18
|
c.get("page").title = title;
|
|
16
19
|
|
|
20
|
+
if (options.layout === "pwa") {
|
|
21
|
+
const pwa = pwaMessages.resolve([getLocale(c)]).t;
|
|
22
|
+
return () => (
|
|
23
|
+
<PwaLayout c={c} title={title}>
|
|
24
|
+
<Placeholder
|
|
25
|
+
state={status >= 500 ? "error" : "empty"}
|
|
26
|
+
icon={status === 403 ? "ti ti-lock" : status === 404 ? "ti ti-compass-off" : undefined}
|
|
27
|
+
title={title}
|
|
28
|
+
description={description}
|
|
29
|
+
action={
|
|
30
|
+
<ButtonLink href={options.action?.href ?? PWA_SCOPE} variant="secondary">
|
|
31
|
+
{options.action?.label ?? pwa.errorAction}
|
|
32
|
+
</ButtonLink>
|
|
33
|
+
}
|
|
34
|
+
/>
|
|
35
|
+
</PwaLayout>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
return () =>
|
|
18
40
|
options.layout === "minimal" ? (
|
|
19
41
|
<MinimalLayout c={c}>
|
|
@@ -3,6 +3,8 @@ import type { CloudTheme } from "../shared/theme";
|
|
|
3
3
|
import { createProfileActions } from "./profile-actions";
|
|
4
4
|
|
|
5
5
|
type ProfilePreferencesProps = {
|
|
6
|
+
/** Set while the installation offers the mobile app. */
|
|
7
|
+
appHref?: string;
|
|
6
8
|
avatarSrc?: string;
|
|
7
9
|
initialTheme: CloudTheme;
|
|
8
10
|
name: string;
|
|
@@ -10,7 +12,7 @@ type ProfilePreferencesProps = {
|
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
13
|
-
const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme);
|
|
15
|
+
const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
|
|
14
16
|
|
|
15
17
|
const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="xs" />;
|
|
16
18
|
|
|
@@ -32,6 +34,12 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
|
32
34
|
<i class="ti ti-user-circle" aria-hidden="true" />
|
|
33
35
|
<span>{preferences.messages().profileSettings}</span>
|
|
34
36
|
</a>
|
|
37
|
+
{props.appHref && (
|
|
38
|
+
<a href={props.appHref} class="menu-item">
|
|
39
|
+
<i class="ti ti-device-mobile" aria-hidden="true" />
|
|
40
|
+
<span>{preferences.messages().app}</span>
|
|
41
|
+
</a>
|
|
42
|
+
)}
|
|
35
43
|
<button type="button" class="menu-item" onClick={signOut} disabled={signingOut()}>
|
|
36
44
|
<i class="ti ti-logout" aria-hidden="true" />
|
|
37
45
|
<span>{preferences.messages().signOut}</span>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { LocaleProvider, MobileShell, TabBar, type TabBarItem } from "@k2b/ui";
|
|
2
|
+
import type { JSX } from "solid-js";
|
|
3
|
+
import { PWA_SCOPE } from "../contracts/pwa";
|
|
4
|
+
import { getLocale } from "../server/locale";
|
|
5
|
+
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
6
|
+
import { visiblePwaParts } from "./app-navigation";
|
|
7
|
+
import type { LayoutContext } from "./layout-context";
|
|
8
|
+
import PwaRuntime from "./PwaRuntime.island";
|
|
9
|
+
import { pwaMessages } from "./pwa-messages";
|
|
10
|
+
import { getRuntimeContext } from "./runtime";
|
|
11
|
+
import TimezoneCookie from "./TimezoneCookie.island";
|
|
12
|
+
|
|
13
|
+
/** Parts with their own tab; Start lists every part and doubles as "More". */
|
|
14
|
+
const TAB_PARTS = 3;
|
|
15
|
+
|
|
16
|
+
export type PwaLayoutProps = {
|
|
17
|
+
/** The request context; the layout also sets the page's framing and referrer headers. */
|
|
18
|
+
c: LayoutContext & { header(name: string, value: string): void };
|
|
19
|
+
/** Header heading and document title. */
|
|
20
|
+
title: string;
|
|
21
|
+
/** Up-navigation inside the part, for example from a detail back to its list. */
|
|
22
|
+
back?: { href: string; label: string };
|
|
23
|
+
/** At most two icon buttons at the header end. */
|
|
24
|
+
actions?: JSX.Element;
|
|
25
|
+
children: JSX.Element;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The frame of every page in the installable mobile app (preview): the app's document head, a phone shell with a
|
|
30
|
+
* tab bar of Start and the first parts, and the browser runtime (service worker, offline notice, session keepalive).
|
|
31
|
+
* Pages render it below `/pwa/` with `ssr.pwaAccess`.
|
|
32
|
+
*/
|
|
33
|
+
export default function PwaLayout(props: PwaLayoutProps) {
|
|
34
|
+
const { c } = props;
|
|
35
|
+
const locale = getLocale(c);
|
|
36
|
+
const t = pwaMessages.resolve([locale]).t;
|
|
37
|
+
const user = c.get("user");
|
|
38
|
+
const cloud = c.get("settings")?.app?.name || "Cloud";
|
|
39
|
+
const page = c.get("page");
|
|
40
|
+
page.theme = readThemeFromCookieHeader(c.req.raw.headers.get("Cookie"));
|
|
41
|
+
page.title = props.title;
|
|
42
|
+
page.pwa = { name: cloud };
|
|
43
|
+
// No app page may be framed, and none leaks its address to another site.
|
|
44
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
45
|
+
c.header("Content-Security-Policy", "frame-ancestors 'none'");
|
|
46
|
+
|
|
47
|
+
const parts = visiblePwaParts(getRuntimeContext(c).apps, user, locale);
|
|
48
|
+
const path = new URL(c.req.raw.url).pathname;
|
|
49
|
+
const tabs = parts.slice(0, TAB_PARTS);
|
|
50
|
+
const currentTab = tabs.find((part) => path === part.pwa.href || path.startsWith(`${part.pwa.href}/`));
|
|
51
|
+
const items: TabBarItem[] = [
|
|
52
|
+
{ id: "start", label: t.start, icon: "ti ti-home", href: PWA_SCOPE, current: !currentTab },
|
|
53
|
+
...tabs.map((part) => ({ id: part.id, label: part.name, icon: part.icon, href: part.pwa.href, current: part === currentTab })),
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<LocaleProvider locale={locale}>
|
|
58
|
+
{/* Pages without a person show no times. Their pairing link lives only in memory, and a reload would lose it. */}
|
|
59
|
+
<TimezoneCookie reload={!!user} />
|
|
60
|
+
<MobileShell
|
|
61
|
+
header={<MobileShell.Header title={props.title} back={props.back} actions={props.actions} />}
|
|
62
|
+
footer={user && parts.length > 0 ? <TabBar label={t.tabs} items={items} /> : undefined}
|
|
63
|
+
>
|
|
64
|
+
{props.children}
|
|
65
|
+
</MobileShell>
|
|
66
|
+
<PwaRuntime keepalive={!!user} cloud={cloud} />
|
|
67
|
+
</LocaleProvider>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { observeMobileShell, syncThemeColor, type ToastHandle, toast, useLocale } from "@k2b/ui";
|
|
2
|
+
import { onCleanup, onMount } from "solid-js";
|
|
3
|
+
import { renewAppSession } from "../browser/app-session";
|
|
4
|
+
import { PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "../contracts/pwa";
|
|
5
|
+
import { pwaMessages } from "./pwa-messages";
|
|
6
|
+
|
|
7
|
+
/** Set once per browsing session, so the first page renews and later navigations do not. */
|
|
8
|
+
const RENEWED_MARKER = "cloud.pwa.renewed";
|
|
9
|
+
const KEEPALIVE_INTERVAL_MS = 60 * 60 * 1000;
|
|
10
|
+
/** Matches `cloud:theme-preference` in the preference controller. */
|
|
11
|
+
const THEME_PREFERENCE_EVENT = "cloud:theme-preference";
|
|
12
|
+
|
|
13
|
+
export type PwaRuntimeProps = {
|
|
14
|
+
/** A person uses the app: keep the app session alive. */
|
|
15
|
+
keepalive: boolean;
|
|
16
|
+
/** The installation name, for the account notice. */
|
|
17
|
+
cloud: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const firstPageOfSession = (): boolean => {
|
|
21
|
+
try {
|
|
22
|
+
if (window.sessionStorage.getItem(RENEWED_MARKER)) return false;
|
|
23
|
+
window.sessionStorage.setItem(RENEWED_MARKER, "1");
|
|
24
|
+
} catch {}
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The browser side of every mobile app page: the service worker, the offline notice, the app session keepalive,
|
|
30
|
+
* the status bar colour after a theme change, and the server-rendered shell's measurements.
|
|
31
|
+
*/
|
|
32
|
+
export default function PwaRuntime(props: PwaRuntimeProps) {
|
|
33
|
+
const locale = useLocale();
|
|
34
|
+
const t = () => pwaMessages.resolve([locale()]).t;
|
|
35
|
+
|
|
36
|
+
onMount(() => {
|
|
37
|
+
const cleanups: (() => void)[] = [];
|
|
38
|
+
onCleanup(() => {
|
|
39
|
+
for (const cleanup of cleanups) cleanup();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const shell = document.querySelector<HTMLElement>(".k2b-mobile-shell");
|
|
43
|
+
if (shell) cleanups.push(observeMobileShell(shell));
|
|
44
|
+
|
|
45
|
+
// The launch bounce marks its return; the page is here now, so the marker leaves the address.
|
|
46
|
+
const url = new URL(location.href);
|
|
47
|
+
if (url.searchParams.has("pwa_launch")) {
|
|
48
|
+
url.searchParams.delete("pwa_launch");
|
|
49
|
+
history.replaceState(history.state, "", url.pathname + url.search + url.hash);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Registration fails in private windows and insecure contexts; the app then simply works online only.
|
|
53
|
+
void navigator.serviceWorker?.register(PWA_SERVICE_WORKER_PATH, { scope: PWA_SCOPE, updateViaCache: "none" }).catch(() => undefined);
|
|
54
|
+
|
|
55
|
+
let offlineToast: ToastHandle | undefined;
|
|
56
|
+
const offline = () => {
|
|
57
|
+
offlineToast ??= toast(t().offline, { duration: 0, iconClass: "ti ti-wifi-off" });
|
|
58
|
+
};
|
|
59
|
+
const online = () => {
|
|
60
|
+
offlineToast?.dismiss();
|
|
61
|
+
offlineToast = undefined;
|
|
62
|
+
};
|
|
63
|
+
if (!navigator.onLine) offline();
|
|
64
|
+
window.addEventListener("offline", offline);
|
|
65
|
+
window.addEventListener("online", online);
|
|
66
|
+
cleanups.push(() => {
|
|
67
|
+
window.removeEventListener("offline", offline);
|
|
68
|
+
window.removeEventListener("online", online);
|
|
69
|
+
offlineToast?.dismiss();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const syncTheme = () => syncThemeColor();
|
|
73
|
+
window.addEventListener(THEME_PREFERENCE_EVENT, syncTheme);
|
|
74
|
+
cleanups.push(() => window.removeEventListener(THEME_PREFERENCE_EVENT, syncTheme));
|
|
75
|
+
|
|
76
|
+
if (!props.keepalive) return;
|
|
77
|
+
|
|
78
|
+
let otherAccountShown = false;
|
|
79
|
+
let disposed = false;
|
|
80
|
+
cleanups.push(() => {
|
|
81
|
+
disposed = true;
|
|
82
|
+
});
|
|
83
|
+
const renew = async () => {
|
|
84
|
+
const { otherAccount } = await renewAppSession();
|
|
85
|
+
if (disposed || !otherAccount || otherAccountShown) return;
|
|
86
|
+
otherAccountShown = true;
|
|
87
|
+
toast(t().otherAccount({ cloud: props.cloud, name: otherAccount.name }), { duration: 0, iconClass: "ti ti-alert-triangle" });
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (firstPageOfSession()) void renew();
|
|
91
|
+
const visible = () => {
|
|
92
|
+
if (document.visibilityState === "visible") void renew();
|
|
93
|
+
};
|
|
94
|
+
document.addEventListener("visibilitychange", visible);
|
|
95
|
+
const timer = setInterval(() => {
|
|
96
|
+
if (document.visibilityState === "visible") void renew();
|
|
97
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
98
|
+
cleanups.push(() => {
|
|
99
|
+
document.removeEventListener("visibilitychange", visible);
|
|
100
|
+
clearInterval(timer);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
@@ -2,12 +2,18 @@ import { cookies } from "@k2b/stdlib/browser";
|
|
|
2
2
|
import { onMount } from "solid-js";
|
|
3
3
|
import { TIMEZONE_COOKIE } from "../shared/time";
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export type TimezoneCookieProps = {
|
|
6
|
+
/** Reload once after a timezone change, so the page shows times in the browser's zone. Defaults to `true`. */
|
|
7
|
+
reload?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function TimezoneCookie(props: TimezoneCookieProps) {
|
|
6
11
|
onMount(() => {
|
|
7
12
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
8
13
|
const current = cookies.readCookie(TIMEZONE_COOKIE);
|
|
9
14
|
if (timeZone && current !== timeZone) {
|
|
10
15
|
cookies.writeCookie(TIMEZONE_COOKIE, timeZone);
|
|
16
|
+
if (props.reload === false) return;
|
|
11
17
|
try {
|
|
12
18
|
if (sessionStorage.getItem("cloud.timezone.reload") !== timeZone) {
|
|
13
19
|
sessionStorage.setItem("cloud.timezone.reload", timeZone);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { RuntimeAppMeta } from "../contracts/app";
|
|
2
|
-
import {
|
|
2
|
+
import { isPwaPartId } from "../contracts/pwa";
|
|
3
|
+
import { hasRole, type Role, type User } from "../contracts/shared";
|
|
4
|
+
import { resolveAppPresentation } from "../shared/app-presentation";
|
|
3
5
|
|
|
4
6
|
export type VisibleNavigationApp = RuntimeAppMeta & {
|
|
5
7
|
nav: NonNullable<RuntimeAppMeta["nav"]>;
|
|
@@ -25,17 +27,30 @@ export const hasDedicatedRuntimeRoute = (apps: readonly RuntimeAppMeta[], pathna
|
|
|
25
27
|
return !!match && match.app.id !== currentAppId;
|
|
26
28
|
};
|
|
27
29
|
|
|
30
|
+
const hasAnyRole = (user: User | undefined, roles: readonly Role[] | undefined): boolean =>
|
|
31
|
+
!roles || (!!user && roles.some((role) => (role === "guest" ? user.profile === "guest" : hasRole(user, role))));
|
|
32
|
+
|
|
28
33
|
/** Apps rendered in the navigation for a given authenticated user. */
|
|
29
34
|
export const visibleNavigationApps = (apps: readonly RuntimeAppMeta[], user: User | undefined): VisibleNavigationApp[] =>
|
|
30
35
|
apps.filter(
|
|
31
36
|
(app): app is VisibleNavigationApp =>
|
|
32
|
-
!!app.nav &&
|
|
33
|
-
app.nav.section !== "hidden" &&
|
|
34
|
-
(!app.nav.requiresAuth || !!user) &&
|
|
35
|
-
(!app.nav.requiresRoles ||
|
|
36
|
-
(!!user &&
|
|
37
|
-
app.nav.requiresRoles.some((role) => {
|
|
38
|
-
if (role === "guest") return user.profile === "guest";
|
|
39
|
-
return hasRole(user, role);
|
|
40
|
-
}))),
|
|
37
|
+
!!app.nav && app.nav.section !== "hidden" && (!app.nav.requiresAuth || !!user) && hasAnyRole(user, app.nav.requiresRoles),
|
|
41
38
|
);
|
|
39
|
+
|
|
40
|
+
export type VisiblePwaPart = RuntimeAppMeta & { pwa: NonNullable<RuntimeAppMeta["pwa"]> };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parts of the mobile app the user may see, localized and sorted by name. Empty without a user. The roles are
|
|
44
|
+
* coarse visibility like the navigation's; part routes and services still authorize.
|
|
45
|
+
*/
|
|
46
|
+
export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | undefined, locale: string): VisiblePwaPart[] => {
|
|
47
|
+
if (!user) return [];
|
|
48
|
+
// Like the gateway, which routes /pwa/<id> only to a valid part id that claims it.
|
|
49
|
+
return apps
|
|
50
|
+
.filter(
|
|
51
|
+
(app): app is VisiblePwaPart =>
|
|
52
|
+
!!app.pwa && isPwaPartId(app.id) && app.routes.includes(app.pwa.href) && hasAnyRole(user, app.pwa.requiresRoles),
|
|
53
|
+
)
|
|
54
|
+
.map((app) => resolveAppPresentation(app, locale))
|
|
55
|
+
.sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
|
|
56
|
+
};
|
package/src/ssr/index.ts
CHANGED
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
// Do NOT import from this barrel in .island.tsx or .client.tsx files!
|
|
3
3
|
|
|
4
4
|
export { default as AdminLayout } from "./AdminLayout";
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
hasDedicatedRuntimeRoute,
|
|
7
|
+
resolveRuntimeRoute,
|
|
8
|
+
type VisiblePwaPart,
|
|
9
|
+
visibleNavigationApps,
|
|
10
|
+
visiblePwaParts,
|
|
11
|
+
} from "./app-navigation";
|
|
6
12
|
export { default as Layout } from "./Layout";
|
|
7
13
|
export {
|
|
8
14
|
default as MinimalLayout,
|
|
9
15
|
type MinimalLayoutPreferencePosition,
|
|
10
16
|
type MinimalLayoutProps,
|
|
11
17
|
} from "./MinimalLayout";
|
|
18
|
+
export { default as PwaLayout, type PwaLayoutProps } from "./PwaLayout";
|
|
12
19
|
export { requestPath } from "./request-path";
|
|
13
20
|
export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
|
|
14
21
|
export type { UrlFilterField } from "./url-filter";
|