@bygghammar/cms-platform 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.40.0
4
+
5
+ - New `backoffice/shell`: shared `BackofficeShell` (auth gate, no-access screen,
6
+ sidebar, sign-in form), a feature-flag driven `backofficeNav(siteConfig)` model
7
+ and `backofficeRedirectHost` for the admin-domain guard. A site's backoffice
8
+ layout route is now a thin adapter that supplies its auth client and admin check.
9
+
3
10
  ## 0.39.0
4
11
 
5
12
  - New `server/backup-core`: superadmin verification, dataset counting, archive build, analysis and restore (database + media) now live in the package. Hosts pass a service-role client factory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -154,7 +154,8 @@
154
154
  "./server/email-send-core": "./src/server/email-send-core.ts",
155
155
  "./server/forms-delivery-core": "./src/server/forms-delivery-core.ts",
156
156
  "./server/forms-submissions-core": "./src/server/forms-submissions-core.ts",
157
- "./server/backup-core": "./src/server/backup-core.ts"
157
+ "./server/backup-core": "./src/server/backup-core.ts",
158
+ "./backoffice/shell": "./src/backoffice/shell/index.ts"
158
159
  },
159
160
  "dependencies": {
160
161
  "fflate": "^0.8.3"
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Shared backoffice shell: auth gate, no-access screen, sidebar and content slot.
3
+ *
4
+ * A host route supplies its auth client, an admin check and the nav model; the
5
+ * layout, states and sign-in form are identical for every site.
6
+ */
7
+ import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
8
+ import { ExternalLink, LogOut } from "lucide-react";
9
+ import { useEffect, useState, type FormEvent, type ReactNode } from "react";
10
+ import { toast } from "sonner";
11
+
12
+ import { isBackofficeNavigationActive } from "../../navigation";
13
+ import type { BackofficeNavItem } from "./nav-model";
14
+
15
+ export type ShellAuthUser = { email?: string | null } | null;
16
+
17
+ export type ShellAuthAdapter = {
18
+ /** Current session user, or null when signed out. */
19
+ getUser: () => Promise<ShellAuthUser>;
20
+ /** Subscribe to session changes; returns an unsubscribe function. */
21
+ onUserChange: (handler: (user: ShellAuthUser) => void) => () => void;
22
+ signIn: (email: string, password: string) => Promise<{ error?: string | null }>;
23
+ signOut: () => Promise<unknown>;
24
+ /** True only for a genuine hosted misconfiguration (not "signed out"). */
25
+ isConfigBroken?: () => boolean;
26
+ };
27
+
28
+ export type BackofficeShellProps = {
29
+ /** Site name shown in the sidebar header. */
30
+ siteName: string;
31
+ /** Backoffice base path, e.g. `/kontoret`. */
32
+ backofficePath: string;
33
+ nav: BackofficeNavItem[];
34
+ auth: ShellAuthAdapter;
35
+ /** Resolves whether the signed-in user may use the backoffice. */
36
+ checkIsAdmin: () => Promise<boolean>;
37
+ /** localStorage key for the "remember me" email. */
38
+ rememberKey?: string;
39
+ /** Route content — normally `<Outlet />`. */
40
+ children: ReactNode;
41
+ };
42
+
43
+ type State =
44
+ | { kind: "loading" }
45
+ | { kind: "signed-out" }
46
+ | { kind: "config-error" }
47
+ | { kind: "not-admin"; email: string }
48
+ | { kind: "admin"; email: string };
49
+
50
+ export function BackofficeShell(props: BackofficeShellProps) {
51
+ const { auth, checkIsAdmin } = props;
52
+ const [state, setState] = useState<State>({ kind: "loading" });
53
+
54
+ useEffect(() => {
55
+ let alive = true;
56
+
57
+ const resolve = async (user: ShellAuthUser) => {
58
+ if (!alive) return;
59
+ if (!user) {
60
+ setState(auth.isConfigBroken?.() ? { kind: "config-error" } : { kind: "signed-out" });
61
+ return;
62
+ }
63
+ try {
64
+ const ok = await checkIsAdmin();
65
+ if (!alive) return;
66
+ setState(
67
+ ok
68
+ ? { kind: "admin", email: user.email ?? "" }
69
+ : { kind: "not-admin", email: user.email ?? "" },
70
+ );
71
+ } catch {
72
+ if (alive) setState({ kind: "signed-out" });
73
+ }
74
+ };
75
+
76
+ void auth.getUser().then(resolve);
77
+ const unsubscribe = auth.onUserChange(resolve);
78
+
79
+ return () => {
80
+ alive = false;
81
+ unsubscribe();
82
+ };
83
+ }, [auth, checkIsAdmin]);
84
+
85
+ if (state.kind === "loading") {
86
+ return (
87
+ <main className="min-h-screen bg-ink text-paper grid place-items-center">
88
+ <div className="text-sm uppercase tracking-[0.22em] text-paper/60">Loading…</div>
89
+ </main>
90
+ );
91
+ }
92
+
93
+ if (state.kind === "config-error") {
94
+ return (
95
+ <main className="min-h-screen bg-ink text-paper grid place-items-center px-6">
96
+ <div className="max-w-md text-center">
97
+ <h1 className="font-serif text-3xl">Authentication unavailable</h1>
98
+ <p className="mt-4 text-paper/70">
99
+ The backend configuration for this environment is missing or invalid, so the backoffice
100
+ cannot verify your account. The public website is unaffected. Check the deployment
101
+ configuration and reload this page.
102
+ </p>
103
+ </div>
104
+ </main>
105
+ );
106
+ }
107
+
108
+ if (state.kind === "signed-out") {
109
+ return <AuthGate auth={auth} rememberKey={props.rememberKey ?? "cms.backoffice.remember"} />;
110
+ }
111
+
112
+ if (state.kind === "not-admin") {
113
+ return (
114
+ <main className="min-h-screen bg-ink text-paper grid place-items-center px-6">
115
+ <div className="max-w-md text-center">
116
+ <h1 className="font-serif text-3xl">No access</h1>
117
+ <p className="mt-4 text-paper/70">
118
+ The account <span className="text-paper">{state.email}</span> does not have
119
+ administrator access to the backoffice.
120
+ </p>
121
+ <button
122
+ className="mt-8 inline-flex items-center border border-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-paper hover:text-ink transition-colors"
123
+ onClick={() => void auth.signOut()}
124
+ >
125
+ Sign out
126
+ </button>
127
+ </div>
128
+ </main>
129
+ );
130
+ }
131
+
132
+ return <Shell {...props} email={state.email} />;
133
+ }
134
+
135
+ function Shell({
136
+ siteName,
137
+ nav,
138
+ auth,
139
+ children,
140
+ }: BackofficeShellProps & { email: string }) {
141
+ const pathname = useRouterState({ select: (r) => r.location.pathname });
142
+
143
+ return (
144
+ <div className="min-h-screen bg-muted text-ink grid grid-cols-1 md:grid-cols-[240px_1fr]">
145
+ <aside className="bg-ink text-paper md:min-h-screen flex md:flex-col">
146
+ <div className="p-6 border-b border-paper/10">
147
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-paper/60">Backoffice</div>
148
+ <div className="mt-1 font-serif text-lg">{siteName} Backoffice</div>
149
+ </div>
150
+ <nav className="flex md:flex-col overflow-x-auto md:overflow-visible p-2 md:p-4 gap-1 flex-1">
151
+ {nav.map((n) => (
152
+ <Link
153
+ key={n.to}
154
+ to={n.to}
155
+ className={[
156
+ "flex items-center gap-3 px-4 py-2.5 text-sm transition-colors whitespace-nowrap",
157
+ isBackofficeNavigationActive(pathname, { to: n.to, exact: n.exact })
158
+ ? "bg-paper text-ink"
159
+ : "text-paper/75 hover:text-paper hover:bg-paper/10",
160
+ ].join(" ")}
161
+ >
162
+ <n.icon className="size-4" />
163
+ {n.label}
164
+ </Link>
165
+ ))}
166
+ <a
167
+ href="/"
168
+ target="_blank"
169
+ rel="noreferrer"
170
+ className="mt-2 md:mt-3 md:border-t md:border-paper/10 md:pt-3 flex items-center gap-3 px-4 py-2.5 text-sm whitespace-nowrap text-paper/75 transition-colors hover:text-paper hover:bg-paper/10"
171
+ >
172
+ <ExternalLink className="size-4" />
173
+ View website
174
+ </a>
175
+ </nav>
176
+ <SignedInPanel auth={auth} />
177
+ </aside>
178
+ <div className="flex-1 min-w-0">{children}</div>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ function SignedInPanel({ auth }: { auth: ShellAuthAdapter }) {
184
+ const [email, setEmail] = useState("");
185
+
186
+ useEffect(() => {
187
+ let alive = true;
188
+ void auth.getUser().then((user) => {
189
+ if (alive) setEmail(user?.email ?? "");
190
+ });
191
+ return () => {
192
+ alive = false;
193
+ };
194
+ }, [auth]);
195
+
196
+ return (
197
+ <div className="p-4 border-t border-paper/10 hidden md:block">
198
+ <div className="text-[0.65rem] uppercase tracking-[0.22em] text-paper/50">Signed in</div>
199
+ <div className="mt-1 text-sm text-paper truncate" title={email}>
200
+ {email}
201
+ </div>
202
+ <button
203
+ className="mt-4 inline-flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-paper/70 hover:text-paper"
204
+ onClick={async () => {
205
+ await auth.signOut();
206
+ toast.success("Signed out");
207
+ }}
208
+ >
209
+ <LogOut className="size-3.5" />
210
+ Sign out
211
+ </button>
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function AuthGate({ auth, rememberKey }: { auth: ShellAuthAdapter; rememberKey: string }) {
217
+ const [email, setEmail] = useState("");
218
+ const [password, setPassword] = useState("");
219
+ const [remember, setRemember] = useState(false);
220
+ const [busy, setBusy] = useState(false);
221
+ const navigate = useNavigate();
222
+
223
+ useEffect(() => {
224
+ const saved = window.localStorage.getItem(rememberKey);
225
+ if (saved) {
226
+ setEmail(saved);
227
+ setRemember(true);
228
+ }
229
+ }, [rememberKey]);
230
+
231
+ const submit = async (e: FormEvent) => {
232
+ e.preventDefault();
233
+ setBusy(true);
234
+ try {
235
+ const { error } = await auth.signIn(email, password);
236
+ if (error) throw new Error(error);
237
+ if (remember) window.localStorage.setItem(rememberKey, email);
238
+ else window.localStorage.removeItem(rememberKey);
239
+ toast.success("Signed in");
240
+ } catch (err) {
241
+ toast.error(err instanceof Error ? err.message : "Something went wrong");
242
+ } finally {
243
+ setBusy(false);
244
+ }
245
+ };
246
+
247
+ return (
248
+ <main className="min-h-screen bg-ink text-paper grid place-items-center px-6 py-16">
249
+ <div className="w-full max-w-sm">
250
+ <div className="text-[0.7rem] uppercase tracking-[0.22em] text-paper/60">Backoffice</div>
251
+ <h1 className="mt-3 font-serif text-3xl">Sign in</h1>
252
+ <p className="mt-3 text-sm text-paper/60">
253
+ Only authorized administrators can access the backoffice.
254
+ </p>
255
+
256
+ <form onSubmit={submit} className="mt-8 space-y-4">
257
+ <label className="block">
258
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-paper/60">Email</span>
259
+ <input
260
+ type="email"
261
+ required
262
+ value={email}
263
+ onChange={(e) => setEmail(e.target.value)}
264
+ className="mt-2 w-full bg-transparent border border-paper/25 px-4 py-3 text-paper placeholder:text-paper/40 focus:border-sea focus:outline-none"
265
+ autoComplete="email"
266
+ />
267
+ </label>
268
+ <label className="block">
269
+ <span className="text-[0.65rem] uppercase tracking-[0.22em] text-paper/60">
270
+ Password
271
+ </span>
272
+ <input
273
+ type="password"
274
+ required
275
+ minLength={8}
276
+ value={password}
277
+ onChange={(e) => setPassword(e.target.value)}
278
+ className="mt-2 w-full bg-transparent border border-paper/25 px-4 py-3 text-paper placeholder:text-paper/40 focus:border-sea focus:outline-none"
279
+ autoComplete="current-password"
280
+ />
281
+ </label>
282
+ <label className="flex items-center gap-3 text-xs text-paper/70 select-none cursor-pointer">
283
+ <input
284
+ type="checkbox"
285
+ checked={remember}
286
+ onChange={(e) => setRemember(e.target.checked)}
287
+ className="size-4 accent-sea bg-transparent border border-paper/25"
288
+ />
289
+ Remember me
290
+ </label>
291
+ <button
292
+ type="submit"
293
+ disabled={busy}
294
+ className="mt-2 w-full inline-flex items-center justify-center border border-paper bg-paper text-ink px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-transparent hover:text-paper transition-colors disabled:opacity-60"
295
+ >
296
+ {busy ? "Working…" : "Sign in"}
297
+ </button>
298
+ </form>
299
+
300
+ <div className="mt-10 text-center">
301
+ <button
302
+ onClick={() => navigate({ to: "/" })}
303
+ className="text-xs uppercase tracking-[0.22em] text-paper/50 hover:text-paper"
304
+ >
305
+ ← Back to website
306
+ </button>
307
+ </div>
308
+ </div>
309
+ </main>
310
+ );
311
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Admin-domain guard for the backoffice route.
3
+ *
4
+ * Returns the host the backoffice should be served from, or null when the
5
+ * current host is fine (local dev, Lovable preview, or already the admin host).
6
+ */
7
+ const DEV_HOST_SUFFIXES = [".lovable.app", ".lovableproject.com"];
8
+
9
+ function normalizeHost(value: string): string {
10
+ return value
11
+ .replace(/^https?:\/\//, "")
12
+ .replace(/\/+$/, "")
13
+ .replace(/^www\./, "")
14
+ .toLowerCase();
15
+ }
16
+
17
+ /** True for hosts where the admin-domain guard must not apply. */
18
+ export function isDevelopmentHost(host: string): boolean {
19
+ const h = normalizeHost(host);
20
+ return (
21
+ DEV_HOST_SUFFIXES.some((suffix) => h.endsWith(suffix)) ||
22
+ h === "localhost" ||
23
+ h.startsWith("localhost:") ||
24
+ h.startsWith("127.0.0.1")
25
+ );
26
+ }
27
+
28
+ /** Resolve the admin host to redirect to, or null to stay put. */
29
+ export function backofficeRedirectHost(
30
+ currentHost: string,
31
+ adminDomain: string | null | undefined,
32
+ ): string | null {
33
+ if (!currentHost || isDevelopmentHost(currentHost)) return null;
34
+ const current = normalizeHost(currentHost);
35
+ const admin = normalizeHost(adminDomain ?? "");
36
+ if (!admin || current === admin) return null;
37
+ return admin;
38
+ }
@@ -0,0 +1,9 @@
1
+ export { BackofficeShell } from "./BackofficeShell";
2
+ export type {
3
+ BackofficeShellProps,
4
+ ShellAuthAdapter,
5
+ ShellAuthUser,
6
+ } from "./BackofficeShell";
7
+ export { backofficeNav } from "./nav-model";
8
+ export type { BackofficeNavItem, BackofficeNavKey, BackofficeNavOptions } from "./nav-model";
9
+ export { backofficeRedirectHost, isDevelopmentHost } from "./domain-guard";
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Backoffice navigation model.
3
+ *
4
+ * The nav is derived from `SiteConfig` (backoffice base path + feature flags)
5
+ * so every site gets the same sidebar without copying a list of links.
6
+ * A host may override labels or drop/add items through `options`.
7
+ */
8
+ import type { LucideProps } from "lucide-react";
9
+ import {
10
+ Blocks,
11
+ Calendar,
12
+ ClipboardList,
13
+ FileText,
14
+ Globe,
15
+ GraduationCap,
16
+ ImageIcon,
17
+ Layers,
18
+ LayoutDashboard,
19
+ LayoutList,
20
+ MessageSquareQuote,
21
+ ShieldCheck,
22
+ } from "lucide-react";
23
+
24
+ import type { SiteConfig } from "../../config/site-config";
25
+ import type { BackofficeNavigationItem } from "../../navigation";
26
+
27
+ export type BackofficeNavKey =
28
+ | "dashboard"
29
+ | "pages"
30
+ | "global-sections"
31
+ | "blocks"
32
+ | "sections"
33
+ | "media"
34
+ | "seo"
35
+ | "domains"
36
+ | "trips"
37
+ | "courses"
38
+ | "forms"
39
+ | "reviews"
40
+ | "admin";
41
+
42
+ export type BackofficeNavItem = BackofficeNavigationItem<LucideProps>;
43
+
44
+ type Definition = {
45
+ key: BackofficeNavKey;
46
+ path: string;
47
+ label: string;
48
+ icon: BackofficeNavItem["icon"];
49
+ exact?: boolean;
50
+ /** Feature flag that has to be enabled for the item to appear. */
51
+ feature?: keyof SiteConfig["features"];
52
+ };
53
+
54
+ const DEFINITIONS: readonly Definition[] = [
55
+ { key: "dashboard", path: "", label: "Dashboard", icon: LayoutDashboard, exact: true },
56
+ { key: "pages", path: "/pages-builder", label: "Page builder", icon: Blocks },
57
+ { key: "global-sections", path: "/global-sections", label: "Global Sections", icon: Layers },
58
+ { key: "blocks", path: "/blocks", label: "Block library", icon: Blocks },
59
+ { key: "sections", path: "/sections", label: "Shared content", icon: LayoutList },
60
+ { key: "media", path: "/media", label: "Media", icon: ImageIcon },
61
+ { key: "seo", path: "/seo", label: "SEO", icon: FileText },
62
+ { key: "domains", path: "/domains", label: "Domains", icon: Globe, feature: "domains" },
63
+ { key: "trips", path: "/trips", label: "Trips", icon: Calendar, feature: "trips" },
64
+ { key: "courses", path: "/kurser", label: "Courses", icon: GraduationCap, feature: "courses" },
65
+ { key: "forms", path: "/forms", label: "Forms", icon: ClipboardList, feature: "forms" },
66
+ {
67
+ key: "reviews",
68
+ path: "/recensioner",
69
+ label: "Reviews",
70
+ icon: MessageSquareQuote,
71
+ feature: "reviews",
72
+ },
73
+ { key: "admin", path: "/admin", label: "Admin", icon: ShieldCheck },
74
+ ];
75
+
76
+ export type BackofficeNavOptions = {
77
+ /** Per-key label overrides, e.g. `{ trips: "Cruises" }`. */
78
+ labels?: Partial<Record<BackofficeNavKey, string>>;
79
+ /** Keys to hide regardless of feature flags. */
80
+ hide?: readonly BackofficeNavKey[];
81
+ };
82
+
83
+ /** Build the sidebar model for a site. */
84
+ export function backofficeNav(
85
+ config: SiteConfig,
86
+ options: BackofficeNavOptions = {},
87
+ ): BackofficeNavItem[] {
88
+ const base = config.backofficePath.replace(/\/+$/, "");
89
+ const hidden = new Set(options.hide ?? []);
90
+
91
+ return DEFINITIONS.filter((d) => {
92
+ if (hidden.has(d.key)) return false;
93
+ if (d.feature && config.features[d.feature] !== true) return false;
94
+ return true;
95
+ }).map((d) => ({
96
+ key: d.key,
97
+ to: `${base}${d.path}` || "/",
98
+ label: options.labels?.[d.key] ?? d.label,
99
+ icon: d.icon,
100
+ exact: d.exact === true,
101
+ }));
102
+ }