@valentinkolb/cloud 0.4.0 → 0.5.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 +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Marked } from "marked";
|
|
9
|
+
import sanitizeHtml from "sanitize-html";
|
|
9
10
|
import { infoBlocksExtension } from "./extensions/info-blocks";
|
|
10
11
|
import { taskListExtension } from "./extensions/task-list";
|
|
11
12
|
import { tablesExtension } from "./extensions/tables";
|
|
@@ -13,6 +14,8 @@ import { linksExtension } from "./extensions/links";
|
|
|
13
14
|
import { imagesExtension } from "./extensions/images";
|
|
14
15
|
import { codeExtension } from "./extensions/code";
|
|
15
16
|
import { katexExtension } from "./extensions/katex";
|
|
17
|
+
import { markExtension } from "./extensions/mark";
|
|
18
|
+
import { subSupExtension } from "./extensions/sub-sup";
|
|
16
19
|
import { markdownClient } from "./client";
|
|
17
20
|
|
|
18
21
|
// Create a configured marked instance
|
|
@@ -33,12 +36,83 @@ const createMarked = () => {
|
|
|
33
36
|
marked.use(imagesExtension());
|
|
34
37
|
marked.use(katexExtension());
|
|
35
38
|
marked.use(codeExtension());
|
|
39
|
+
// Inline-style decorators come last so they run after structural tokenizers.
|
|
40
|
+
marked.use(markExtension());
|
|
41
|
+
marked.use(subSupExtension());
|
|
36
42
|
|
|
37
43
|
return marked;
|
|
38
44
|
};
|
|
39
45
|
|
|
40
46
|
const marked = createMarked();
|
|
41
47
|
|
|
48
|
+
const sanitizeRenderedHtml = (html: string): string =>
|
|
49
|
+
sanitizeHtml(html, {
|
|
50
|
+
allowedTags: [
|
|
51
|
+
...sanitizeHtml.defaults.allowedTags,
|
|
52
|
+
"annotation",
|
|
53
|
+
"br",
|
|
54
|
+
"div",
|
|
55
|
+
"figcaption",
|
|
56
|
+
"figure",
|
|
57
|
+
"i",
|
|
58
|
+
"img",
|
|
59
|
+
"input",
|
|
60
|
+
"mark",
|
|
61
|
+
"math",
|
|
62
|
+
"mfrac",
|
|
63
|
+
"mi",
|
|
64
|
+
"mn",
|
|
65
|
+
"mo",
|
|
66
|
+
"mover",
|
|
67
|
+
"mrow",
|
|
68
|
+
"msqrt",
|
|
69
|
+
"msub",
|
|
70
|
+
"msubsup",
|
|
71
|
+
"msup",
|
|
72
|
+
"mtext",
|
|
73
|
+
"semantics",
|
|
74
|
+
"span",
|
|
75
|
+
"sub",
|
|
76
|
+
"sup",
|
|
77
|
+
"table",
|
|
78
|
+
"tbody",
|
|
79
|
+
"td",
|
|
80
|
+
"th",
|
|
81
|
+
"thead",
|
|
82
|
+
"tr",
|
|
83
|
+
],
|
|
84
|
+
allowedAttributes: {
|
|
85
|
+
"*": ["aria-hidden", "aria-label", "class", "title"],
|
|
86
|
+
a: ["href", "name", "rel", "target", "title"],
|
|
87
|
+
annotation: ["encoding"],
|
|
88
|
+
code: ["class"],
|
|
89
|
+
div: ["class", "data-block-name", "data-script-source", "style"],
|
|
90
|
+
img: ["alt", "class", "height", "loading", "src", "title", "width", "style"],
|
|
91
|
+
input: ["checked", "class", "disabled", "type"],
|
|
92
|
+
math: ["xmlns"],
|
|
93
|
+
pre: ["class"],
|
|
94
|
+
span: ["aria-hidden", "class", "style", "title"],
|
|
95
|
+
},
|
|
96
|
+
allowedSchemes: ["http", "https", "mailto", "tel", "note", "attach"],
|
|
97
|
+
allowedSchemesByTag: {
|
|
98
|
+
img: ["http", "https", "attach"],
|
|
99
|
+
},
|
|
100
|
+
allowedStyles: {
|
|
101
|
+
div: {
|
|
102
|
+
height: [/^\d+(?:\.\d+)?px$/],
|
|
103
|
+
},
|
|
104
|
+
img: {
|
|
105
|
+
"max-height": [/^none$/, /^\d+(?:\.\d+)?px$/],
|
|
106
|
+
"max-width": [/^\d+(?:\.\d+)?px$/],
|
|
107
|
+
},
|
|
108
|
+
span: {
|
|
109
|
+
"margin-right": [/^-?\d+(?:\.\d+)?em$/],
|
|
110
|
+
top: [/^-?\d+(?:\.\d+)?em$/],
|
|
111
|
+
width: [/^\d+(?:\.\d+)?%$/],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
42
116
|
/**
|
|
43
117
|
* Render markdown to HTML for server-side display.
|
|
44
118
|
* The output matches the visual styling of the CodeMirror editor.
|
|
@@ -72,7 +146,7 @@ export function renderMarkdown(content: string): string {
|
|
|
72
146
|
const html = marked.parse(content);
|
|
73
147
|
if (typeof html !== "string") return "";
|
|
74
148
|
|
|
75
|
-
return html;
|
|
149
|
+
return sanitizeRenderedHtml(html);
|
|
76
150
|
}
|
|
77
151
|
|
|
78
152
|
/**
|
|
@@ -84,7 +158,7 @@ export function renderMarkdownSync(content: string): string {
|
|
|
84
158
|
const html = marked.parse(content);
|
|
85
159
|
if (typeof html !== "string") return "";
|
|
86
160
|
|
|
87
|
-
return html;
|
|
161
|
+
return sanitizeRenderedHtml(html);
|
|
88
162
|
}
|
|
89
163
|
|
|
90
164
|
export { marked };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
|
|
3
|
+
export type MockCoverTheme = "blue" | "emerald" | "amber" | "rose" | "violet" | "slate" | "teal";
|
|
4
|
+
|
|
5
|
+
export type MockCoverIcon = "book" | "camera" | "device-projector" | "microphone" | "package";
|
|
6
|
+
|
|
7
|
+
export type MockCoverOptions = {
|
|
8
|
+
icon?: MockCoverIcon | string;
|
|
9
|
+
theme?: MockCoverTheme;
|
|
10
|
+
seed?: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
size?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type MockCover = {
|
|
16
|
+
svg: string;
|
|
17
|
+
dataUrl: string;
|
|
18
|
+
mimeType: "image/svg+xml";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const themes: Record<MockCoverTheme, { from: string; to: string; accent: string; panel: string }> = {
|
|
22
|
+
blue: { from: "#dbeafe", to: "#93c5fd", accent: "#2563eb", panel: "#eff6ff" },
|
|
23
|
+
emerald: { from: "#d1fae5", to: "#6ee7b7", accent: "#059669", panel: "#ecfdf5" },
|
|
24
|
+
amber: { from: "#fef3c7", to: "#fbbf24", accent: "#d97706", panel: "#fffbeb" },
|
|
25
|
+
rose: { from: "#ffe4e6", to: "#fda4af", accent: "#e11d48", panel: "#fff1f2" },
|
|
26
|
+
violet: { from: "#ede9fe", to: "#c4b5fd", accent: "#7c3aed", panel: "#f5f3ff" },
|
|
27
|
+
slate: { from: "#e2e8f0", to: "#94a3b8", accent: "#475569", panel: "#f8fafc" },
|
|
28
|
+
teal: { from: "#ccfbf1", to: "#5eead4", accent: "#0f766e", panel: "#f0fdfa" },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const themeNames = Object.keys(themes) as MockCoverTheme[];
|
|
32
|
+
|
|
33
|
+
const iconPaths: Record<MockCoverIcon, string[]> = {
|
|
34
|
+
book: ["M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0", "M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0", "M3 6l0 13", "M12 6l0 13", "M21 6l0 13"],
|
|
35
|
+
camera: [
|
|
36
|
+
"M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2",
|
|
37
|
+
"M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0",
|
|
38
|
+
],
|
|
39
|
+
"device-projector": [
|
|
40
|
+
"M8 9a5 5 0 1 0 10 0a5 5 0 0 0 -10 0",
|
|
41
|
+
"M9 6h-4a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h14a2 2 0 0 0 2 -2v-8a2 2 0 0 0 -2 -2h-2",
|
|
42
|
+
"M6 15h1",
|
|
43
|
+
"M7 18l-1 2",
|
|
44
|
+
"M18 18l1 2",
|
|
45
|
+
],
|
|
46
|
+
microphone: [
|
|
47
|
+
"M9 5a3 3 0 0 1 3 -3a3 3 0 0 1 3 3v5a3 3 0 0 1 -3 3a3 3 0 0 1 -3 -3l0 -5",
|
|
48
|
+
"M5 10a7 7 0 0 0 14 0",
|
|
49
|
+
"M8 21l8 0",
|
|
50
|
+
"M12 17l0 4",
|
|
51
|
+
],
|
|
52
|
+
package: ["M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5", "M12 12l8 -4.5", "M12 12l0 9", "M12 12l-8 -4.5", "M16 5.25l-8 4.5"],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const aliases: Record<string, MockCoverIcon> = {
|
|
56
|
+
books: "book",
|
|
57
|
+
"ti ti-book": "book",
|
|
58
|
+
"ti ti-books": "book",
|
|
59
|
+
"ti ti-camera": "camera",
|
|
60
|
+
"ti ti-device-projector": "device-projector",
|
|
61
|
+
"ti ti-microphone": "microphone",
|
|
62
|
+
"ti ti-package": "package",
|
|
63
|
+
"ti ti-packages": "package",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const hashSeed = (seed: string): number => {
|
|
67
|
+
let hash = 2166136261;
|
|
68
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
69
|
+
hash ^= seed.charCodeAt(i);
|
|
70
|
+
hash = Math.imul(hash, 16777619);
|
|
71
|
+
}
|
|
72
|
+
return hash >>> 0;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const escapeXml = (value: string): string =>
|
|
76
|
+
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
77
|
+
|
|
78
|
+
const resolveIcon = (icon: MockCoverOptions["icon"]): MockCoverIcon => {
|
|
79
|
+
const key = String(icon ?? "package")
|
|
80
|
+
.trim()
|
|
81
|
+
.toLowerCase();
|
|
82
|
+
if (key in iconPaths) return key as MockCoverIcon;
|
|
83
|
+
return aliases[key] ?? "package";
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const createMockCoverSvg = (options: MockCoverOptions = {}): string => {
|
|
87
|
+
const size = Math.max(160, Math.min(options.size ?? 720, 1600));
|
|
88
|
+
const seed = options.seed ?? options.label ?? options.icon ?? "mock-cover";
|
|
89
|
+
const themeName = options.theme ?? themeNames[hashSeed(String(seed)) % themeNames.length] ?? "blue";
|
|
90
|
+
const theme = themes[themeName];
|
|
91
|
+
const icon = resolveIcon(options.icon);
|
|
92
|
+
const gradientId = `g${hashSeed(`${seed}:gradient`).toString(36)}`;
|
|
93
|
+
const title = escapeXml(options.label ?? "Mock cover");
|
|
94
|
+
const iconMarkup = iconPaths[icon].map((path) => `<path d="${path}" />`).join("");
|
|
95
|
+
|
|
96
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 720 720" role="img" aria-label="${title}">
|
|
97
|
+
<defs>
|
|
98
|
+
<linearGradient id="${gradientId}" x1="72" x2="648" y1="72" y2="648" gradientUnits="userSpaceOnUse">
|
|
99
|
+
<stop offset="0" stop-color="${theme.from}" />
|
|
100
|
+
<stop offset="1" stop-color="${theme.to}" />
|
|
101
|
+
</linearGradient>
|
|
102
|
+
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
|
|
103
|
+
<feDropShadow dx="0" dy="22" stdDeviation="24" flood-color="#0f172a" flood-opacity=".16" />
|
|
104
|
+
</filter>
|
|
105
|
+
</defs>
|
|
106
|
+
<rect width="720" height="720" fill="url(#${gradientId})" />
|
|
107
|
+
<rect x="160" y="160" width="400" height="400" rx="96" fill="${theme.panel}" opacity=".92" filter="url(#shadow)" />
|
|
108
|
+
<g transform="translate(216 216) scale(12)" fill="none" stroke="${theme.accent}" stroke-width="1.55" stroke-linecap="round" stroke-linejoin="round">
|
|
109
|
+
${iconMarkup}
|
|
110
|
+
</g>
|
|
111
|
+
</svg>`;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const createMockCover = (options: MockCoverOptions = {}): MockCover => {
|
|
115
|
+
const svg = createMockCoverSvg(options);
|
|
116
|
+
return {
|
|
117
|
+
svg,
|
|
118
|
+
dataUrl: `data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`,
|
|
119
|
+
mimeType: "image/svg+xml",
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const parseDataUrl = (dataUrl: string): { mimeType: string; bytes: Uint8Array } | null => {
|
|
124
|
+
const match = /^data:([^;,]+);base64,(.+)$/s.exec(dataUrl);
|
|
125
|
+
if (!match) return null;
|
|
126
|
+
return {
|
|
127
|
+
mimeType: match[1] || "application/octet-stream",
|
|
128
|
+
bytes: new Uint8Array(Buffer.from(match[2] ?? "", "base64")),
|
|
129
|
+
};
|
|
130
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createAuthLoginUrl, createAuthPasswordResetUrl, createLoginRedirectUrl, normalizeRedirectTo, redirectPathFromRequestUrl } from "./redirect";
|
|
3
|
+
|
|
4
|
+
describe("redirect helpers", () => {
|
|
5
|
+
test("normalizes local redirect paths", () => {
|
|
6
|
+
expect(normalizeRedirectTo("/app/dashboard")).toBe("/app/dashboard");
|
|
7
|
+
expect(normalizeRedirectTo("/oauth/authorize?client_id=cli&state=abc")).toBe("/oauth/authorize?client_id=cli&state=abc");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("rejects external or ambiguous redirect targets", () => {
|
|
11
|
+
expect(normalizeRedirectTo("https://example.com/app")).toBeUndefined();
|
|
12
|
+
expect(normalizeRedirectTo("//example.com/app")).toBeUndefined();
|
|
13
|
+
expect(normalizeRedirectTo("app/dashboard")).toBeUndefined();
|
|
14
|
+
expect(normalizeRedirectTo("/\\example.com")).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("preserves request query parameters for login redirects", () => {
|
|
18
|
+
expect(redirectPathFromRequestUrl("https://cloud.local/oauth/authorize?client_id=cli&state=abc")).toBe("/oauth/authorize?client_id=cli&state=abc");
|
|
19
|
+
expect(createLoginRedirectUrl("https://cloud.local/oauth/authorize?client_id=cli&state=abc")).toBe("/auth/login?redirectTo=%2Foauth%2Fauthorize%3Fclient_id%3Dcli%26state%3Dabc");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("builds magic login links with safe redirects only", () => {
|
|
23
|
+
const safeUrl = createAuthLoginUrl("https://cloud.example", {
|
|
24
|
+
token: "token-id",
|
|
25
|
+
redirectTo: "/oauth/authorize?client_id=cli",
|
|
26
|
+
});
|
|
27
|
+
expect(safeUrl).toBe("https://cloud.example/auth/login?token=token-id&redirectTo=%2Foauth%2Fauthorize%3Fclient_id%3Dcli");
|
|
28
|
+
|
|
29
|
+
const externalUrl = createAuthLoginUrl("https://cloud.example", {
|
|
30
|
+
token: "token-id",
|
|
31
|
+
redirectTo: "https://evil.example",
|
|
32
|
+
});
|
|
33
|
+
expect(externalUrl).toBe("https://cloud.example/auth/login?token=token-id");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("builds password reset links with safe redirects only", () => {
|
|
37
|
+
const safeUrl = createAuthPasswordResetUrl("https://cloud.example", {
|
|
38
|
+
token: "token-id",
|
|
39
|
+
redirectTo: "/app/dashboard",
|
|
40
|
+
});
|
|
41
|
+
expect(safeUrl).toBe("https://cloud.example/auth/password-reset?token=token-id&redirectTo=%2Fapp%2Fdashboard");
|
|
42
|
+
|
|
43
|
+
const externalUrl = createAuthPasswordResetUrl("https://cloud.example", {
|
|
44
|
+
token: "token-id",
|
|
45
|
+
redirectTo: "https://evil.example",
|
|
46
|
+
});
|
|
47
|
+
expect(externalUrl).toBe("https://cloud.example/auth/password-reset?token=token-id");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const REDIRECT_BASE = "https://cloud.local";
|
|
2
|
+
|
|
3
|
+
/** Normalize a user-provided post-login redirect to a local Cloud path. */
|
|
4
|
+
export const normalizeRedirectTo = (value: string | null | undefined): string | undefined => {
|
|
5
|
+
if (!value) return undefined;
|
|
6
|
+
|
|
7
|
+
const trimmed = value.trim();
|
|
8
|
+
if (!trimmed || !trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) return undefined;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(trimmed, REDIRECT_BASE);
|
|
12
|
+
if (url.origin !== REDIRECT_BASE) return undefined;
|
|
13
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
14
|
+
} catch {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Extract a safe post-login redirect target from an incoming request URL. */
|
|
20
|
+
export const redirectPathFromRequestUrl = (requestUrl: string): string => {
|
|
21
|
+
const url = new URL(requestUrl);
|
|
22
|
+
return normalizeRedirectTo(`${url.pathname}${url.search}`) ?? "/";
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Build the local login URL for an incoming protected request. */
|
|
26
|
+
export const createLoginRedirectUrl = (requestUrl: string): string => {
|
|
27
|
+
const params = new URLSearchParams();
|
|
28
|
+
params.set("redirectTo", redirectPathFromRequestUrl(requestUrl));
|
|
29
|
+
return `/auth/login?${params.toString()}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Build an absolute auth login URL while preserving only safe local redirects. */
|
|
33
|
+
export const createAuthLoginUrl = (appUrl: string, params: { token?: string; redirectTo?: string | null | undefined } = {}): string => {
|
|
34
|
+
const url = new URL(`${appUrl.replace(/\/$/, "")}/auth/login`);
|
|
35
|
+
if (params.token) url.searchParams.set("token", params.token);
|
|
36
|
+
|
|
37
|
+
const redirectTo = normalizeRedirectTo(params.redirectTo);
|
|
38
|
+
if (redirectTo) url.searchParams.set("redirectTo", redirectTo);
|
|
39
|
+
|
|
40
|
+
return url.toString();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Build an absolute password-reset URL while preserving only safe local redirects. */
|
|
44
|
+
export const createAuthPasswordResetUrl = (appUrl: string, params: { token: string; redirectTo?: string | null | undefined }): string => {
|
|
45
|
+
const url = new URL(`${appUrl.replace(/\/$/, "")}/auth/password-reset`);
|
|
46
|
+
url.searchParams.set("token", params.token);
|
|
47
|
+
|
|
48
|
+
const redirectTo = normalizeRedirectTo(params.redirectTo);
|
|
49
|
+
if (redirectTo) url.searchParams.set("redirectTo", redirectTo);
|
|
50
|
+
|
|
51
|
+
return url.toString();
|
|
52
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readThemeFromCookieHeader } from "./theme";
|
|
3
|
+
|
|
4
|
+
describe("readThemeFromCookieHeader", () => {
|
|
5
|
+
test("defaults to light", () => {
|
|
6
|
+
expect(readThemeFromCookieHeader("")).toBe("light");
|
|
7
|
+
expect(readThemeFromCookieHeader(null)).toBe("light");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("reads light and dark theme cookies", () => {
|
|
11
|
+
expect(readThemeFromCookieHeader("theme=dark")).toBe("dark");
|
|
12
|
+
expect(readThemeFromCookieHeader("session=abc; theme=light; other=1")).toBe("light");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("uses the last valid duplicate theme cookie", () => {
|
|
16
|
+
expect(readThemeFromCookieHeader("theme=dark; theme=light")).toBe("light");
|
|
17
|
+
expect(readThemeFromCookieHeader("theme=light; theme=dark")).toBe("dark");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("ignores invalid theme cookie values", () => {
|
|
21
|
+
expect(readThemeFromCookieHeader("theme=dark; theme=broken")).toBe("dark");
|
|
22
|
+
expect(readThemeFromCookieHeader("theme=broken; theme=light")).toBe("light");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type CloudTheme = "light" | "dark";
|
|
2
|
+
|
|
3
|
+
const THEME_COOKIE = "theme";
|
|
4
|
+
const COOKIE_MAX_AGE_SECONDS = 31536000;
|
|
5
|
+
|
|
6
|
+
const isCloudTheme = (value: string): value is CloudTheme => value === "light" || value === "dark";
|
|
7
|
+
|
|
8
|
+
const decodeCookieValue = (value: string): string => {
|
|
9
|
+
try {
|
|
10
|
+
return decodeURIComponent(value);
|
|
11
|
+
} catch {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the persisted theme from a Cookie header.
|
|
18
|
+
*
|
|
19
|
+
* Browsers can send duplicate cookie names when legacy path-specific cookies
|
|
20
|
+
* exist. RFC ordering puts more specific paths first, so the root preference
|
|
21
|
+
* written by the current app is usually the last valid `theme` entry.
|
|
22
|
+
*/
|
|
23
|
+
export const readThemeFromCookieHeader = (cookieHeader: string | null | undefined): CloudTheme => {
|
|
24
|
+
let resolved: CloudTheme = "light";
|
|
25
|
+
for (const part of (cookieHeader ?? "").split(";")) {
|
|
26
|
+
const trimmed = part.trim();
|
|
27
|
+
const separatorIndex = trimmed.indexOf("=");
|
|
28
|
+
if (separatorIndex <= 0) continue;
|
|
29
|
+
if (trimmed.slice(0, separatorIndex) !== THEME_COOKIE) continue;
|
|
30
|
+
|
|
31
|
+
const value = decodeCookieValue(trimmed.slice(separatorIndex + 1));
|
|
32
|
+
if (isCloudTheme(value)) resolved = value;
|
|
33
|
+
}
|
|
34
|
+
return resolved;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const themeBootstrapScript = `!function(){var e=document.documentElement;if(!e.hasAttribute("data-theme-fixed")){var t="light";document.cookie.split(";").forEach(function(e){var r=e.trim(),i=r.indexOf("=");if(i>0&&r.slice(0,i)==="theme"){var o;try{o=decodeURIComponent(r.slice(i+1))}catch(e){o=r.slice(i+1)}(o==="light"||o==="dark")&&(t=o)}});e.classList.remove("light","dark");e.classList.add(t)}}();`;
|
|
38
|
+
|
|
39
|
+
const legacyThemeCookiePaths = (pathname: string): string[] => {
|
|
40
|
+
const paths = new Set(["/", "/me", "/app", "/admin"]);
|
|
41
|
+
if (pathname) paths.add(pathname);
|
|
42
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
43
|
+
let current = "";
|
|
44
|
+
for (const part of parts) {
|
|
45
|
+
current += `/${part}`;
|
|
46
|
+
paths.add(current);
|
|
47
|
+
}
|
|
48
|
+
return [...paths];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const setThemePreference = (mode: CloudTheme): CloudTheme => {
|
|
52
|
+
if (typeof document === "undefined") return mode;
|
|
53
|
+
|
|
54
|
+
document.documentElement.classList.remove("light", "dark");
|
|
55
|
+
document.documentElement.classList.add(mode);
|
|
56
|
+
|
|
57
|
+
const secure = location.protocol === "https:" ? "; Secure" : "";
|
|
58
|
+
for (const path of legacyThemeCookiePaths(location.pathname)) {
|
|
59
|
+
document.cookie = `${THEME_COOKIE}=; path=${path}; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax${secure}`;
|
|
60
|
+
}
|
|
61
|
+
document.cookie = `${THEME_COOKIE}=${encodeURIComponent(mode)}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax${secure}`;
|
|
62
|
+
return mode;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const getCurrentThemePreference = (): CloudTheme => {
|
|
66
|
+
if (typeof document === "undefined") return "light";
|
|
67
|
+
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
|
68
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const TIMEZONE_COOKIE = "cloud.timezone";
|
|
2
|
+
|
|
3
|
+
export const normalizeTimeZone = (value: string | null | undefined, fallback = "UTC"): string => {
|
|
4
|
+
const candidate = typeof value === "string" ? value.trim() : "";
|
|
5
|
+
if (!candidate) return fallback;
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
new Intl.DateTimeFormat(undefined, { timeZone: candidate }).format(new Date());
|
|
9
|
+
return candidate;
|
|
10
|
+
} catch {
|
|
11
|
+
return fallback;
|
|
12
|
+
}
|
|
13
|
+
};
|
package/src/ssr/AdminLayout.tsx
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { JSX } from "solid-js/jsx-runtime";
|
|
2
|
-
import
|
|
2
|
+
import type { LayoutAnnouncementsState } from "../server/middleware/settings";
|
|
3
3
|
import AdminSidebar from "./AdminSidebar";
|
|
4
|
+
import Layout from "./Layout";
|
|
4
5
|
import { getRuntimeContext, type RuntimeContext } from "./runtime";
|
|
6
|
+
|
|
5
7
|
type Breadcrumb = { title: string; href?: string };
|
|
6
8
|
type AdminLayoutContext = {
|
|
7
9
|
get(key: "user"): any;
|
|
8
10
|
get(key: "page"): any;
|
|
9
11
|
get(key: "runtime"): RuntimeContext;
|
|
10
12
|
get(key: "settings"): Record<string, any>;
|
|
13
|
+
get(key: "announcements"): LayoutAnnouncementsState | undefined;
|
|
11
14
|
req: { raw: { headers: Headers; url: string } };
|
|
12
15
|
};
|
|
13
16
|
type Props = {
|
|
@@ -18,7 +21,8 @@ type Props = {
|
|
|
18
21
|
stretch?: boolean;
|
|
19
22
|
};
|
|
20
23
|
export default function AdminLayout({ children, c, title, stretch }: Props) {
|
|
21
|
-
const
|
|
24
|
+
const url = new URL(c.req.raw.url);
|
|
25
|
+
const currentPath = `${url.pathname}${url.search}`;
|
|
22
26
|
const runtime = getRuntimeContext(c);
|
|
23
27
|
const breadcrumbs: Breadcrumb[] = [
|
|
24
28
|
{ title: "Start", href: "/" },
|
|
@@ -30,7 +34,7 @@ export default function AdminLayout({ children, c, title, stretch }: Props) {
|
|
|
30
34
|
return (
|
|
31
35
|
<Layout c={c} fullWidth title={breadcrumbs}>
|
|
32
36
|
<div class="app-cols flex-1 min-h-0">
|
|
33
|
-
<AdminSidebar
|
|
37
|
+
<AdminSidebar currentPath={currentPath} apps={runtime.apps} />
|
|
34
38
|
<div class="flex-1 min-w-0 min-h-0 flex flex-col">
|
|
35
39
|
<div class={`flex-1 min-h-0 ${stretch ? "flex flex-col" : "overflow-y-auto"}`} style="scrollbar-gutter: stable">
|
|
36
40
|
{children}
|