@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.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- 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
|
+
}
|
package/src/ssr/index.ts
ADDED
|
@@ -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
|
+
}
|