@valentinkolb/cloud 0.1.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.
Files changed (196) hide show
  1. package/package.json +69 -0
  2. package/public/logo.svg +1 -0
  3. package/scripts/build.ts +113 -0
  4. package/scripts/preload.ts +73 -0
  5. package/src/_internal/define-app.ts +399 -0
  6. package/src/_internal/heartbeat.ts +33 -0
  7. package/src/_internal/registry.ts +100 -0
  8. package/src/_internal/runtime-context.ts +38 -0
  9. package/src/api/accounts-entities.ts +134 -0
  10. package/src/api/admin-lifecycle.ts +210 -0
  11. package/src/api/auth/schemas.ts +28 -0
  12. package/src/api/auth.ts +230 -0
  13. package/src/api/index.ts +66 -0
  14. package/src/api/me.ts +206 -0
  15. package/src/api/search/schemas.ts +43 -0
  16. package/src/api/search.ts +130 -0
  17. package/src/clients/core.ts +19 -0
  18. package/src/config/env.ts +23 -0
  19. package/src/config/index.ts +6 -0
  20. package/src/config/ssr.ts +58 -0
  21. package/src/contracts/app.ts +140 -0
  22. package/src/contracts/index.ts +5 -0
  23. package/src/contracts/profile.ts +67 -0
  24. package/src/contracts/registry.ts +50 -0
  25. package/src/contracts/settings-types.ts +84 -0
  26. package/src/contracts/shared.ts +258 -0
  27. package/src/contracts/widgets.ts +121 -0
  28. package/src/index.ts +6 -0
  29. package/src/server/api/index.ts +1 -0
  30. package/src/server/api/respond.ts +55 -0
  31. package/src/server/api-client.ts +54 -0
  32. package/src/server/app-context.ts +39 -0
  33. package/src/server/index.ts +62 -0
  34. package/src/server/middleware/auth.ts +168 -0
  35. package/src/server/middleware/index.ts +7 -0
  36. package/src/server/middleware/middleware.ts +47 -0
  37. package/src/server/middleware/openapi.ts +126 -0
  38. package/src/server/middleware/rate-limit.ts +126 -0
  39. package/src/server/middleware/request-logger.ts +41 -0
  40. package/src/server/middleware/validator.ts +35 -0
  41. package/src/server/services/access.ts +294 -0
  42. package/src/server/services/freeipa/client.ts +100 -0
  43. package/src/server/services/freeipa/index.ts +9 -0
  44. package/src/server/services/freeipa/session.ts +78 -0
  45. package/src/server/services/freeipa/tls.ts +48 -0
  46. package/src/server/services/freeipa/util.ts +60 -0
  47. package/src/server/services/geo.ts +154 -0
  48. package/src/server/services/index.ts +28 -0
  49. package/src/server/services/services.ts +13 -0
  50. package/src/services/account-lifecycle/audit.ts +41 -0
  51. package/src/services/account-lifecycle/index.ts +907 -0
  52. package/src/services/account-lifecycle/scheduler.ts +347 -0
  53. package/src/services/account-model.ts +21 -0
  54. package/src/services/accounts/app.ts +966 -0
  55. package/src/services/accounts/authz.ts +22 -0
  56. package/src/services/accounts/base-group.ts +11 -0
  57. package/src/services/accounts/base-user.ts +45 -0
  58. package/src/services/accounts/entities.ts +529 -0
  59. package/src/services/accounts/group-sql.ts +106 -0
  60. package/src/services/accounts/groups.ts +246 -0
  61. package/src/services/accounts/index.ts +14 -0
  62. package/src/services/accounts/ipa-data.ts +64 -0
  63. package/src/services/accounts/lifecycle.ts +2 -0
  64. package/src/services/accounts/local-groups.ts +491 -0
  65. package/src/services/accounts/model.ts +135 -0
  66. package/src/services/accounts/switching.ts +117 -0
  67. package/src/services/accounts/users.ts +714 -0
  68. package/src/services/auth-flows/index.ts +6 -0
  69. package/src/services/auth-flows/ipa.ts +128 -0
  70. package/src/services/auth-flows/magic-link.ts +119 -0
  71. package/src/services/freeipa-config.ts +89 -0
  72. package/src/services/index.ts +46 -0
  73. package/src/services/ipa/auth.ts +122 -0
  74. package/src/services/ipa/groups.ts +684 -0
  75. package/src/services/ipa/guard.ts +17 -0
  76. package/src/services/ipa/index.ts +17 -0
  77. package/src/services/ipa/profile.ts +90 -0
  78. package/src/services/ipa/search.ts +154 -0
  79. package/src/services/ipa/sync.ts +740 -0
  80. package/src/services/ipa/users.ts +794 -0
  81. package/src/services/logging/index.ts +294 -0
  82. package/src/services/notifications/email.ts +123 -0
  83. package/src/services/notifications/index.ts +413 -0
  84. package/src/services/postgres.ts +51 -0
  85. package/src/services/providers/index.ts +27 -0
  86. package/src/services/providers/local/auth.ts +13 -0
  87. package/src/services/providers/local/index.ts +4 -0
  88. package/src/services/providers/local/users.ts +255 -0
  89. package/src/services/session/index.ts +137 -0
  90. package/src/services/settings/api.ts +61 -0
  91. package/src/services/settings/app.ts +101 -0
  92. package/src/services/settings/crypto.ts +69 -0
  93. package/src/services/settings/defaults.ts +824 -0
  94. package/src/services/settings/index.ts +203 -0
  95. package/src/services/settings/namespace.ts +9 -0
  96. package/src/services/settings/snapshot.ts +49 -0
  97. package/src/services/settings/store.ts +179 -0
  98. package/src/services/settings/templates.ts +10 -0
  99. package/src/services/weather/forecast.ts +287 -0
  100. package/src/services/weather/geo.ts +110 -0
  101. package/src/services/weather/index.ts +99 -0
  102. package/src/services/weather/location.ts +24 -0
  103. package/src/services/weather/locations.ts +125 -0
  104. package/src/services/weather/migrate.ts +22 -0
  105. package/src/services/weather/types.ts +61 -0
  106. package/src/services/weather/ui.ts +50 -0
  107. package/src/shared/account-display.ts +17 -0
  108. package/src/shared/account-session.ts +15 -0
  109. package/src/shared/icons.ts +109 -0
  110. package/src/shared/index.ts +10 -0
  111. package/src/shared/markdown/client.ts +130 -0
  112. package/src/shared/markdown/extensions/code.ts +58 -0
  113. package/src/shared/markdown/extensions/images.ts +43 -0
  114. package/src/shared/markdown/extensions/info-blocks.ts +93 -0
  115. package/src/shared/markdown/extensions/katex.ts +120 -0
  116. package/src/shared/markdown/extensions/links.ts +34 -0
  117. package/src/shared/markdown/extensions/tables.ts +88 -0
  118. package/src/shared/markdown/extensions/task-list.ts +53 -0
  119. package/src/shared/markdown/index.ts +97 -0
  120. package/src/shared/markdown/shared.ts +36 -0
  121. package/src/ssr/AdminLayout.tsx +42 -0
  122. package/src/ssr/AdminSidebar.tsx +95 -0
  123. package/src/ssr/Footer.island.tsx +62 -0
  124. package/src/ssr/GlobalSearchDialog.tsx +389 -0
  125. package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
  126. package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
  127. package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
  128. package/src/ssr/Layout.tsx +326 -0
  129. package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
  130. package/src/ssr/NavMenu.island.tsx +108 -0
  131. package/src/ssr/ThemeToggleRail.island.tsx +27 -0
  132. package/src/ssr/index.ts +5 -0
  133. package/src/ssr/islands/SearchBar.island.tsx +77 -0
  134. package/src/ssr/islands/index.ts +1 -0
  135. package/src/ssr/runtime.ts +22 -0
  136. package/src/styles/base-popover.css +28 -0
  137. package/src/styles/effects.css +65 -0
  138. package/src/styles/global.css +133 -0
  139. package/src/styles/input.css +54 -0
  140. package/src/styles/tokens.css +35 -0
  141. package/src/styles/utilities-buttons.css +125 -0
  142. package/src/styles/utilities-feedback.css +65 -0
  143. package/src/styles/utilities-layout.css +122 -0
  144. package/src/styles/utilities-navigation.css +196 -0
  145. package/src/types/ambient.d.ts +8 -0
  146. package/src/ui/admin-settings.tsx +148 -0
  147. package/src/ui/dialog-core.ts +146 -0
  148. package/src/ui/filter/FilterChip.tsx +196 -0
  149. package/src/ui/filter/index.ts +2 -0
  150. package/src/ui/index.ts +19 -0
  151. package/src/ui/input/Checkbox.tsx +55 -0
  152. package/src/ui/input/ColorInput.tsx +122 -0
  153. package/src/ui/input/DateTimeInput.tsx +86 -0
  154. package/src/ui/input/ImageInput.tsx +170 -0
  155. package/src/ui/input/NumberInput.tsx +113 -0
  156. package/src/ui/input/PinInput.tsx +169 -0
  157. package/src/ui/input/SegmentedControl.tsx +99 -0
  158. package/src/ui/input/Select.tsx +288 -0
  159. package/src/ui/input/SelectChip.tsx +61 -0
  160. package/src/ui/input/Slider.tsx +118 -0
  161. package/src/ui/input/Switch.tsx +62 -0
  162. package/src/ui/input/TagsInput.tsx +115 -0
  163. package/src/ui/input/TextInput.tsx +160 -0
  164. package/src/ui/input/index.ts +13 -0
  165. package/src/ui/input/types.ts +42 -0
  166. package/src/ui/input/util.tsx +105 -0
  167. package/src/ui/ipa/Avatar.tsx +28 -0
  168. package/src/ui/ipa/GroupView.tsx +36 -0
  169. package/src/ui/ipa/LoginBtn.tsx +16 -0
  170. package/src/ui/ipa/UserView.tsx +58 -0
  171. package/src/ui/ipa/index.ts +4 -0
  172. package/src/ui/misc/ContextMenu.tsx +211 -0
  173. package/src/ui/misc/CopyButton.tsx +28 -0
  174. package/src/ui/misc/Dropdown.tsx +194 -0
  175. package/src/ui/misc/EntitySearch.tsx +213 -0
  176. package/src/ui/misc/Lightbox.tsx +194 -0
  177. package/src/ui/misc/LinkCard.tsx +34 -0
  178. package/src/ui/misc/LogEntriesTable.tsx +61 -0
  179. package/src/ui/misc/MarkdownView.tsx +65 -0
  180. package/src/ui/misc/Pagination.tsx +51 -0
  181. package/src/ui/misc/PermissionEditor.tsx +379 -0
  182. package/src/ui/misc/ProgressBar.tsx +47 -0
  183. package/src/ui/misc/RemoveBtn.tsx +27 -0
  184. package/src/ui/misc/StatCell.tsx +90 -0
  185. package/src/ui/misc/index.ts +18 -0
  186. package/src/ui/navigation.ts +32 -0
  187. package/src/ui/prompts.tsx +854 -0
  188. package/src/ui/sidebar.tsx +468 -0
  189. package/src/ui/widgets/Widget.tsx +62 -0
  190. package/src/ui/widgets/WidgetCard.tsx +19 -0
  191. package/src/ui/widgets/WidgetHero.tsx +39 -0
  192. package/src/ui/widgets/WidgetList.tsx +84 -0
  193. package/src/ui/widgets/WidgetPills.tsx +68 -0
  194. package/src/ui/widgets/WidgetStat.tsx +67 -0
  195. package/src/ui/widgets/WidgetStatus.tsx +62 -0
  196. package/src/ui/widgets/index.ts +9 -0
@@ -0,0 +1,326 @@
1
+ import { hasRole, type User } from "../contracts/shared";
2
+ import type { JSX } from "solid-js/jsx-runtime";
3
+ import NavMenu from "./NavMenu.island";
4
+ import MoreAppsDropdown from "./MoreAppsDropdown.island";
5
+ import ThemeToggleRail from "./ThemeToggleRail.island";
6
+ import HotkeysHelpRail from "./HotkeysHelpRail.island";
7
+ import GlobalSearchTrigger from "./GlobalSearchTrigger.island";
8
+ import Footer from "./Footer.island";
9
+ import { dates } from "../shared";
10
+ import { getRuntimeContext, type RuntimeContext } from "./runtime";
11
+ import { resolveNavMatch } from "../contracts/app"; // ==========================
12
+ import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
13
+ // Types
14
+ type Breadcrumb = { title: string; href?: string };
15
+ type AppLink = { iconClass: string; label: string; href: string; match: string };
16
+ type LayoutContext = {
17
+ get(key: "user"): User | undefined;
18
+ get(key: "page"): { theme?: "light" | "dark" };
19
+ get(key: "runtime"): RuntimeContext;
20
+ /**
21
+ * Per-request settings snapshot (populated by snapshot middleware in
22
+ * `_internal/define-app.ts`). Loose-typed at this layer so Layout can be
23
+ * shared across apps with different SettingsMaps; reading core keys like
24
+ * `app.name`/`app.copyright` is safe because every container's snapshot
25
+ * includes core's keys.
26
+ */
27
+ get(key: "settings"): Record<string, any>;
28
+ req: { raw: { headers: Headers; url: string } };
29
+ };
30
+ type LayoutProps = {
31
+ children: JSX.Element;
32
+ c: LayoutContext;
33
+ title?: string | Breadcrumb[];
34
+ fullPage?: boolean /** Remove main padding for fullwidth app layouts */;
35
+ fullWidth?: boolean;
36
+ }; // ==========================
37
+ // Helpers
38
+ function active(pathname: string, match: string): string {
39
+ return pathname.startsWith(match) ? "active" : "";
40
+ }
41
+ function buildNavLinks(apps: RuntimeContext["apps"], user: User | undefined): { primary: AppLink[]; more: AppLink[] } {
42
+ const links = apps
43
+ .filter((app) => !!app.nav && app.nav.section !== "hidden")
44
+ .filter((app) => {
45
+ if (app.nav?.requiresAuth && !user) return false;
46
+ if (
47
+ app.nav?.requiresRoles &&
48
+ (!user ||
49
+ !app.nav.requiresRoles.some((role) => {
50
+ if (role === "guest") return user.profile === "guest";
51
+ return hasRole(user, role);
52
+ }))
53
+ ) {
54
+ return false;
55
+ }
56
+ return true;
57
+ })
58
+ .map((app) => ({
59
+ section: app.nav!.section,
60
+ link: {
61
+ iconClass: app.icon,
62
+ label: app.name,
63
+ href: app.nav!.href,
64
+ match: resolveNavMatch(app) ?? app.nav!.href.split("?")[0] ?? app.nav!.href,
65
+ } satisfies AppLink,
66
+ }));
67
+ const primary = links.filter((entry) => entry.section === "primary").map((entry) => entry.link);
68
+ const more = links.filter((entry) => entry.section === "more").map((entry) => entry.link);
69
+ if (user && hasRole(user, "admin")) {
70
+ more.push({ iconClass: "ti ti-settings", label: "Admin", href: "/admin", match: "/admin" });
71
+ }
72
+ return { primary, more };
73
+ } // ==========================
74
+ // Warning Components
75
+ const WARN_DAYS = 14;
76
+ function ProfileWarnings({ user }: { user: User }) {
77
+ if (user.profile === "guest") return null;
78
+ const missing: string[] = [];
79
+ if (!user.displayName) missing.push("display name");
80
+ if (!user.givenname) missing.push("first name");
81
+ if (!user.sn) missing.push("last name");
82
+ if (missing.length === 0) return null;
83
+ return (
84
+ <a href="/me" class="flex items-center gap-2 text-xs info-block-warning no-underline mb-2 md:mb-1.5 mx-2 md:ml-0 md:mr-1.5">
85
+ <i class="ti ti-user-exclamation" /> <span>Your profile is incomplete: {missing.join(",")} not set.</span>
86
+ </a>
87
+ );
88
+ }
89
+ function ExpiryWarnings({ user }: { user: User }) {
90
+ const now = Date.now();
91
+ const warnThreshold = now + WARN_DAYS * 24 * 60 * 60 * 1000;
92
+ const warnings: { icon: string; message: string; expired: boolean }[] = [];
93
+ if (user.accountExpires) {
94
+ const expires = new Date(user.accountExpires).getTime();
95
+ const accountLabel = user.provider === "ipa" ? "account" : user.profile === "guest" ? "guest account" : "account";
96
+ if (expires < now) warnings.push({ icon: "ti-calendar-event", message: "Your account has expired.", expired: true });
97
+ else if (expires < warnThreshold)
98
+ warnings.push({
99
+ icon: "ti-calendar-event",
100
+ message: `Your ${accountLabel} expires on ${dates.formatDate(user.accountExpires)}.`,
101
+ expired: false,
102
+ });
103
+ }
104
+ if (user.ipa?.passwordExpires) {
105
+ const expires = new Date(user.ipa.passwordExpires).getTime();
106
+ if (expires < now)
107
+ warnings.push({ icon: "ti-key", message: "Your password has expired. Please log out and in again to change it.", expired: true });
108
+ else if (expires < warnThreshold)
109
+ warnings.push({ icon: "ti-key", message: `Your password expires on ${dates.formatDate(user.ipa.passwordExpires)}.`, expired: false });
110
+ }
111
+ if (warnings.length === 0) return null;
112
+ return (
113
+ <div class="flex flex-col gap-1 px-2">
114
+ {" "}
115
+ {warnings.map((w) => (
116
+ <div class={`flex items-center gap-2 text-xs ${w.expired ? "info-block-danger" : "info-block-warning"}`}>
117
+ {" "}
118
+ <i class={`ti ${w.icon}`} /> <span>{w.message}</span>{" "}
119
+ </div>
120
+ ))}{" "}
121
+ </div>
122
+ );
123
+ } // ==========================
124
+ // Sub-Components
125
+ function BreadcrumbNav({ breadcrumbs }: { breadcrumbs: Breadcrumb[] }) {
126
+ return (
127
+ <nav class="flex items-center gap-1 sm:gap-2 min-w-0 text-sm md:text-xs">
128
+ {" "}
129
+ {breadcrumbs.map((crumb, i) => {
130
+ const isLast = i === breadcrumbs.length - 1;
131
+ return (
132
+ <>
133
+ {" "}
134
+ {i > 0 && <span class="text-zinc-400 dark:text-zinc-600 text-xs">/</span>}{" "}
135
+ {crumb.href && !isLast ? (
136
+ <a href={crumb.href} class="text-dimmed hover:text-primary truncate">
137
+ {" "}
138
+ {crumb.title}{" "}
139
+ </a>
140
+ ) : (
141
+ <span class="font-semibold text-primary truncate">{crumb.title}</span>
142
+ )}{" "}
143
+ </>
144
+ );
145
+ })}{" "}
146
+ </nav>
147
+ );
148
+ } // ==========================
149
+ // Main Layout
150
+ export default function Layout({ children, c, title, fullPage, fullWidth }: LayoutProps) {
151
+ const runtime = getRuntimeContext(c);
152
+ const cookie = c.req.raw.headers.get("Cookie") ?? "";
153
+ const themeMatch = cookie.match(/theme=([^;]+)/);
154
+ c.get("page").theme = themeMatch?.[1] === "dark" ? "dark" : "light";
155
+ const user = c.get("user");
156
+ const pathname = new URL(c.req.raw.url).pathname;
157
+ const { primary: primaryApps, more: moreApps } = buildNavLinks(runtime.apps, user);
158
+ const allApps = [...primaryApps, ...moreApps];
159
+ const mobileApps = allApps.filter((app) => app.href !== "/admin");
160
+ const searchHelpApps: GlobalSearchHelpApp[] = runtime.apps
161
+ .filter((app) => (app.searchTags?.length ?? 0) > 0)
162
+ .map((app) => ({
163
+ appId: app.id,
164
+ appName: app.name,
165
+ appIcon: app.icon,
166
+ help: app.searchHelp,
167
+ tags: [...new Set((app.searchTags ?? []).map((tag) => tag.toLowerCase()))],
168
+ tagHelp: [...(app.searchTagHelp ?? [])],
169
+ }))
170
+ .sort((a, b) => a.appName.localeCompare(b.appName));
171
+ const settings = c.get("settings");
172
+ const appName = settings?.app?.name || "Cloud";
173
+ // Project the user record down to what NavMenu actually renders. Without
174
+ // this, the full `User` (mail, ssh keys, phone, address, all group
175
+ // memberships) gets serialized into the island's data-props HTML on every
176
+ // authenticated page — defense-in-depth.
177
+ const navMenuUser = user
178
+ ? {
179
+ uid: user.uid,
180
+ displayName: user.displayName,
181
+ profile: user.profile,
182
+ roles: user.roles,
183
+ }
184
+ : undefined;
185
+ // Aggregate legalLinks from every running app (last-wins on duplicate href).
186
+ const legalLinks = (() => {
187
+ const seen = new Map<string, { label: string; href: string; icon?: string }>();
188
+ for (const app of runtime.apps) {
189
+ for (const link of app.legalLinks ?? []) seen.set(link.href, { ...link });
190
+ }
191
+ return [...seen.values()];
192
+ })();
193
+ const page = c.get("page") as Record<string, unknown>;
194
+ if (!page.title) page.title = typeof title === "string" ? title : appName;
195
+ const breadcrumbs: Breadcrumb[] = !title ? [{ title: appName }] : typeof title === "string" ? [{ title }] : title;
196
+ const showRail =
197
+ !!user; /* * Grid layout: * Rail mode: [rail | content] * No rail: [content] * * Rows: [header] [main] [footer?] * The rail spans rows 1+2 via grid-row, so logo aligns with the header. */
198
+ const contentPadding = "p-2.5 md:p-0 md:pr-2 md:pb-2";
199
+ const gridClass = showRail
200
+ ? "grid-cols-1 md:grid-cols-[auto_1fr] grid-rows-[auto_1fr]"
201
+ : `grid-cols-1 ${!fullPage ? "grid-rows-[auto_1fr_auto]" : "grid-rows-[auto_1fr]"}`;
202
+ return (
203
+ <div
204
+ class={`grid min-h-screen w-screen relative md:h-screen md:overflow-hidden bg-zinc-50 dark:bg-zinc-950 ${gridClass}`}
205
+ >
206
+ {" "}
207
+ {/* ── Rail: logo cell (row 1, col 1) — grid gives it the same height as the header ── */}{" "}
208
+ {showRail && (
209
+ <div class="hidden md:flex items-center justify-center w-12 bg-white/20 dark:bg-zinc-950/20">
210
+ {" "}
211
+ <a href="/" aria-label="Home">
212
+ {" "}
213
+ <img src="/branding/logo" alt="Logo" class="h-5 w-5" />{" "}
214
+ </a>{" "}
215
+ </div>
216
+ )}{" "}
217
+ {/* ── Header (row 1) ── */}{" "}
218
+ <header
219
+ class="flex justify-between items-center m-2 md:ml-0 md:m-1.5 py-1.5 md:py-2 px-2 md:px-3 paper"
220
+ style="box-shadow: var(--theme-shadow-elevated)"
221
+ >
222
+ {" "}
223
+ <div class="flex items-center gap-2 min-w-0">
224
+ {" "}
225
+ {/* Logo — only when no rail */}{" "}
226
+ {!showRail && (
227
+ <a href="/" class="shrink-0 flex items-center" aria-label="Home">
228
+ {" "}
229
+ <img src="/branding/logo" alt="Logo" class="h-6 w-6" />{" "}
230
+ </a>
231
+ )}{" "}
232
+ {showRail && (
233
+ <a
234
+ href="/"
235
+ aria-label="Home"
236
+ class="md:hidden inline-flex items-center justify-center w-8 h-8 rounded-lg text-dimmed hover:text-secondary hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
237
+ >
238
+ <img src="/branding/logo" alt="Home" class="h-4 w-4" />
239
+ </a>
240
+ )}{" "}
241
+ {/* Breadcrumbs — desktop, rail mode only */}{" "}
242
+ <div class="hidden md:flex items-center min-w-0">
243
+ {" "}
244
+ <BreadcrumbNav breadcrumbs={breadcrumbs} />{" "}
245
+ </div>{" "}
246
+ {/* Mobile breadcrumb */}{" "}
247
+ <div class="md:hidden flex items-center min-w-0">
248
+ {" "}
249
+ <BreadcrumbNav breadcrumbs={breadcrumbs.slice(-1)} />{" "}
250
+ </div>{" "}
251
+ </div>{" "}
252
+ <div class="flex items-center shrink-0 gap-1">
253
+ {user && (
254
+ <GlobalSearchTrigger
255
+ variant="header"
256
+ registerHotkey
257
+ class={showRail ? "md:hidden" : ""}
258
+ searchHelpApps={searchHelpApps}
259
+ />
260
+ )}
261
+ {" "}
262
+ {/* Desktop: direct /me link with avatar (logged in) or NavMenu (not logged in) */}{" "}
263
+ {user ? (
264
+ <>
265
+ {" "}
266
+ <a href="/me" class="hidden md:flex items-center justify-center cursor-pointer" aria-label="Profile">
267
+ {" "}
268
+ <span class="inline-flex items-center justify-center w-6 h-6 text-[9px] font-semibold rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300">
269
+ {" "}
270
+ {(user.displayName || user.uid).slice(0, 2).toUpperCase()}{" "}
271
+ </span>{" "}
272
+ </a>{" "}
273
+ <div class="md:hidden">
274
+ {" "}
275
+ <NavMenu user={navMenuUser} mobileApps={mobileApps} />{" "}
276
+ </div>{" "}
277
+ </>
278
+ ) : (
279
+ <NavMenu user={navMenuUser} mobileApps={mobileApps} />
280
+ )}{" "}
281
+ </div>{" "}
282
+ </header>{" "}
283
+ {/* ── Rail: apps cell (row 2, col 1) ── */}{" "}
284
+ {showRail && (
285
+ <div class="hidden md:flex flex-col items-center w-12 gap-1 pt-1 bg-white/20 dark:bg-zinc-950/20">
286
+ {" "}
287
+ {primaryApps.map((app) => (
288
+ <a href={app.href} class={`rail-item ${active(pathname, app.match) ? "rail-item-active" : ""}`} title={app.label}>
289
+ {" "}
290
+ <i class={`${app.iconClass} text-base`} />{" "}
291
+ </a>
292
+ ))}{" "}
293
+ <MoreAppsDropdown apps={moreApps} legalLinks={legalLinks} />
294
+ <div class="mt-auto pb-1 flex flex-col items-center gap-1">
295
+ {" "}
296
+ <GlobalSearchTrigger variant="rail" searchHelpApps={searchHelpApps} />{" "}
297
+ {" "}
298
+ <HotkeysHelpRail searchHelpApps={searchHelpApps} />{" "}
299
+ <ThemeToggleRail />{" "}
300
+ </div>{" "}
301
+ </div>
302
+ )}{" "}
303
+ {/* ── Main content (row 2) ── */}{" "}
304
+ <div class="flex flex-col min-h-0 min-w-0 bg-zinc-50 dark:bg-zinc-950">
305
+ {" "}
306
+ {user && <ProfileWarnings user={user} />} {user && <ExpiryWarnings user={user} />}{" "}
307
+ <main
308
+ class={`flex-1 min-h-0 ${contentPadding} ${fullPage || fullWidth ? "md:overflow-hidden flex flex-col" : "md:overflow-auto"}`}
309
+ >
310
+ {" "}
311
+ {children}{" "}
312
+ </main>{" "}
313
+ </div>{" "}
314
+ {/* ── Footer / Bottom bar (row 3) ── */}{" "}
315
+ {!fullPage && !showRail && (
316
+ <div>
317
+ {" "}
318
+ <div class="hidden md:block">
319
+ {" "}
320
+ <Footer isLoggedIn={!!user} appName={settings?.app?.copyright || appName} legalLinks={legalLinks} />{" "}
321
+ </div>{" "}
322
+ </div>
323
+ )}{" "}
324
+ </div>
325
+ );
326
+ }
@@ -0,0 +1,61 @@
1
+ import { Dropdown } from "../ui";
2
+
3
+ type AppLink = {
4
+ iconClass: string;
5
+ label: string;
6
+ href: string;
7
+ };
8
+
9
+ type LegalLink = {
10
+ label: string;
11
+ href: string;
12
+ icon?: string;
13
+ };
14
+
15
+ type MoreAppsDropdownProps = {
16
+ apps: AppLink[];
17
+ /**
18
+ * Legal/info links contributed by every running app via `defineApp.legalLinks`.
19
+ * Computed server-side via `listLegalLinks()` (or the runtime aggregation in
20
+ * Layout.tsx) and passed in as a prop. Empty array = section hidden.
21
+ */
22
+ legalLinks?: LegalLink[];
23
+ };
24
+
25
+ /** Dropdown for secondary apps in the rail nav. */
26
+ export default function MoreAppsDropdown(props: MoreAppsDropdownProps) {
27
+ const legalLinks = props.legalLinks ?? [];
28
+ const trigger = (
29
+ <span class="rail-item" title="More">
30
+ <i class="ti ti-dots-vertical text-base" />
31
+ </span>
32
+ );
33
+
34
+ const appItems = props.apps.map((app) => ({
35
+ icon: app.iconClass,
36
+ label: app.label,
37
+ href: app.href,
38
+ }));
39
+
40
+ const legalItems = legalLinks.map((link) => ({
41
+ icon: link.icon ?? "ti ti-file-text",
42
+ label: link.label,
43
+ href: link.href,
44
+ }));
45
+
46
+ const elements = legalItems.length > 0
47
+ ? [
48
+ ...(appItems.length > 0 ? [{ items: appItems }] : []),
49
+ { sectionLabel: "Legal", items: legalItems },
50
+ ]
51
+ : appItems;
52
+
53
+ return (
54
+ <Dropdown
55
+ trigger={trigger}
56
+ elements={elements}
57
+ position="bottom-right"
58
+ width="w-44"
59
+ />
60
+ );
61
+ }
@@ -0,0 +1,108 @@
1
+ import type { Role } from "../contracts/shared";
2
+ import { Dropdown } from "../ui";
3
+
4
+ type MobileAppLink = {
5
+ href: string;
6
+ iconClass: string;
7
+ label: string;
8
+ };
9
+
10
+ /**
11
+ * Minimal user projection for the nav menu — covers exactly what's rendered
12
+ * (initials, display name, uid, profile flag, admin role check). Avoids
13
+ * serializing the full `User` (incl. mail, ssh keys, phone, address, group
14
+ * memberships) into HTML `data-props` on every authenticated page.
15
+ */
16
+ export type NavMenuUser = {
17
+ uid: string;
18
+ displayName: string;
19
+ profile: string;
20
+ roles: Role[];
21
+ };
22
+
23
+ type NavMenuProps = {
24
+ user?: NavMenuUser;
25
+ mobileApps: MobileAppLink[];
26
+ };
27
+
28
+ const hasRole = (roles: Role[], ...required: Role[]) => required.some((role) => roles.includes(role));
29
+
30
+ /** Navigation dropdown menu - always visible, adapts to auth state. */
31
+ export default function NavMenu(props: NavMenuProps) {
32
+ const getElements = () => [
33
+ // Top: Profile or Login
34
+ ...(props.user
35
+ ? [
36
+ {
37
+ element: (
38
+ <a
39
+ href="/me"
40
+ class="flex border-b border-zinc-200 p-4 dark:border-zinc-800 transition-colors hover:bg-white/30 dark:hover:bg-white/10"
41
+ >
42
+ <div class="flex items-center gap-3">
43
+ <div class="flex shrink-0 items-center justify-center rounded-full bg-zinc-200 dark:bg-zinc-700 font-semibold text-zinc-600 dark:text-zinc-300 h-8 w-8 text-xs">
44
+ {(props.user.displayName || props.user.uid).slice(0, 2).toUpperCase()}
45
+ </div>
46
+ <div class="flex-1">
47
+ <div class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">{props.user.displayName || props.user.uid}</div>
48
+ {props.user.displayName && props.user.profile !== "guest" && (
49
+ <div class="hidden sm:block text-xs text-dimmed">{props.user.uid}</div>
50
+ )}
51
+ </div>
52
+ </div>
53
+ </a>
54
+ ),
55
+ },
56
+ ]
57
+ : [
58
+ {
59
+ icon: "ti ti-login",
60
+ label: "Sign In",
61
+ href: "/auth/login",
62
+ },
63
+ ]),
64
+ // Section: Apps (mobile only — on desktop, tabs/rail handle this)
65
+ ...(props.user
66
+ ? [
67
+ {
68
+ element: (
69
+ <div class="md:hidden">
70
+ <div class="px-4 pt-3 pb-1 text-xs uppercase tracking-wider font-medium text-zinc-500">Apps</div>
71
+ {props.mobileApps.map((app) => (
72
+ <a
73
+ href={app.href}
74
+ class="flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10 text-zinc-700 dark:text-zinc-300"
75
+ >
76
+ <i class={app.iconClass} />
77
+ <span>{app.label}</span>
78
+ </a>
79
+ ))}
80
+ {hasRole(props.user.roles, "admin") && (
81
+ <a
82
+ href="/admin"
83
+ class="flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10 text-zinc-700 dark:text-zinc-300"
84
+ >
85
+ <i class="ti ti-shield-cog" />
86
+ <span>Admin</span>
87
+ </a>
88
+ )}
89
+ </div>
90
+ ),
91
+ },
92
+ ]
93
+ : []),
94
+ ];
95
+
96
+ return (
97
+ <Dropdown
98
+ trigger={
99
+ <button type="button" class="icon-btn inline items-center justify-center" aria-label="Menu">
100
+ <i class="ti ti-menu-2 text-lg" />
101
+ </button>
102
+ }
103
+ position="bottom-left"
104
+ width="w-64"
105
+ elements={getElements()}
106
+ />
107
+ );
108
+ }
@@ -0,0 +1,27 @@
1
+ import { createSignal } from "solid-js";
2
+ import { theme, type ThemeMode } from "@valentinkolb/stdlib/browser";
3
+
4
+ /** Theme toggle button for the desktop rail navigation. */
5
+ export default function ThemeToggleRail() {
6
+ const [mode, setMode] = createSignal<ThemeMode>(typeof document !== "undefined" ? theme.getCurrent() : "light");
7
+
8
+ const toggleTheme = () => {
9
+ setMode(theme.toggle());
10
+ };
11
+
12
+ return (
13
+ <button
14
+ type="button"
15
+ class={`rail-item ${
16
+ mode() === "light"
17
+ ? "text-violet-500 hover:text-violet-600 dark:text-violet-400 dark:hover:text-violet-300 hover:bg-violet-500/10 dark:hover:bg-violet-500/15"
18
+ : "text-amber-500 hover:text-amber-600 dark:text-amber-400 dark:hover:text-amber-300 hover:bg-amber-500/10 dark:hover:bg-amber-500/15"
19
+ }`}
20
+ onClick={toggleTheme}
21
+ aria-label={mode() === "light" ? "Switch to dark mode" : "Switch to light mode"}
22
+ title={mode() === "light" ? "Dark mode" : "Light mode"}
23
+ >
24
+ <i class={`ti ${mode() === "light" ? "ti-moon" : "ti-sun-high"} text-base`} />
25
+ </button>
26
+ );
27
+ }
@@ -0,0 +1,5 @@
1
+ // Server-only exports (these transitively import bun:sql via services)
2
+ // Do NOT import from this barrel in .island.tsx or .client.tsx files!
3
+ export { default as Layout } from "./Layout";
4
+ export { default as AdminLayout } from "./AdminLayout";
5
+ export { getRuntimeContext, type RuntimeContext } from "./runtime";
@@ -0,0 +1,77 @@
1
+ import { createEffect, createSignal, onMount } from "solid-js";
2
+ import TextInput from "../../ui/input/TextInput";
3
+
4
+ type SearchBarProps = {
5
+ action?: string;
6
+ value?: string;
7
+ param?: string;
8
+ pageParam?: string;
9
+ placeholder?: string;
10
+ ariaLabel?: string;
11
+ };
12
+
13
+ /** Search bar that filters content via URL query parameter. */
14
+ export default function SearchBar(props: SearchBarProps = {}) {
15
+ const param = props.param ?? "search";
16
+ const pageParam = props.pageParam ?? "page";
17
+ const [query, setQuery] = createSignal(props.value ?? "");
18
+
19
+ createEffect(() => {
20
+ if (props.value !== undefined) {
21
+ setQuery(props.value);
22
+ }
23
+ });
24
+
25
+ onMount(() => {
26
+ if (props.value !== undefined) return;
27
+ const fallback = new URLSearchParams(window.location.search).get(param) ?? "";
28
+ setQuery(fallback);
29
+ });
30
+
31
+ const handleSubmit = (e: Event): void => {
32
+ e.preventDefault();
33
+ const current = new URL(window.location.href);
34
+ const url = props.action ? new URL(props.action, window.location.origin) : current;
35
+ const value = query().trim();
36
+
37
+ if (value.length > 0) {
38
+ url.searchParams.set(param, value);
39
+ } else {
40
+ url.searchParams.delete(param);
41
+ }
42
+ url.searchParams.delete(pageParam);
43
+
44
+ window.location.href = url.toString();
45
+ };
46
+
47
+ const handleClear = (): void => {
48
+ const current = new URL(window.location.href);
49
+ const url = props.action ? new URL(props.action, window.location.origin) : current;
50
+
51
+ url.searchParams.delete(param);
52
+ url.searchParams.delete(pageParam);
53
+
54
+ window.location.href = url.toString();
55
+ };
56
+
57
+ return (
58
+ <form onSubmit={handleSubmit} role="search" class="w-full">
59
+ <TextInput
60
+ name={param}
61
+ type="search"
62
+ placeholder={props.placeholder ?? "Search..."}
63
+ ariaLabel={props.ariaLabel ?? "Search"}
64
+ icon="ti ti-search"
65
+ activeIcon="ti ti-search"
66
+ value={query}
67
+ onInput={setQuery}
68
+ clearable
69
+ clearLabel="Clear search"
70
+ onClear={handleClear}
71
+ />
72
+ <button type="submit" class="hidden">
73
+ Search
74
+ </button>
75
+ </form>
76
+ );
77
+ }
@@ -0,0 +1 @@
1
+ export { default as SearchBar } from "./SearchBar.island";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Runtime context helpers for SSR components.
3
+ * Extracted from _core/runtime-helpers.ts — only the generic parts.
4
+ */
5
+ import type { CloudRuntime } from "../contracts/app";
6
+
7
+ export type RuntimeContext = CloudRuntime;
8
+
9
+ type RuntimeCarrier = {
10
+ get: (key: any) => unknown;
11
+ };
12
+
13
+ /**
14
+ * Reads the runtime context from a Hono request context.
15
+ */
16
+ export const getRuntimeContext = (carrier: RuntimeCarrier): RuntimeContext => {
17
+ const runtime = carrier.get("runtime");
18
+ if (!runtime || typeof runtime !== "object" || !Array.isArray((runtime as RuntimeContext).apps)) {
19
+ throw new Error("Runtime context is missing on request context");
20
+ }
21
+ return runtime as RuntimeContext;
22
+ };
@@ -0,0 +1,28 @@
1
+ /* Popover and island base behavior */
2
+ /* Make solid-island transparent in layout */
3
+ solid-island {
4
+ display: contents;
5
+ }
6
+
7
+ /* Popover positioning */
8
+ [popover]:not(.paper) {
9
+ background: transparent;
10
+ border: none;
11
+ }
12
+
13
+ [popover] {
14
+ margin: 0;
15
+ inset: unset;
16
+ }
17
+
18
+ [popover]:popover-open {
19
+ @starting-style {
20
+ opacity: 0;
21
+ transform: translateY(-10px);
22
+ }
23
+ }
24
+
25
+ [popover] {
26
+ transition: opacity 0.2s, transform 0.2s, overlay 0.2s allow-discrete,
27
+ display 0.2s allow-discrete;
28
+ }