@bygghammar/cms-platform 0.39.0 → 0.41.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.41.0
4
+
5
+ - New `backoffice/routes`: a host registry (`registerBackofficeHost`) plus package-owned
6
+ route components for audit, email logs, log index, roles, users, version, site
7
+ settings, redirects, tracking, contact details and form settings. A site route
8
+ file is now just `createFileRoute(...)({ component: XRoute })`.
9
+
10
+ ## 0.40.0
11
+
12
+ - New `backoffice/shell`: shared `BackofficeShell` (auth gate, no-access screen,
13
+ sidebar, sign-in form), a feature-flag driven `backofficeNav(siteConfig)` model
14
+ and `backofficeRedirectHost` for the admin-domain guard. A site's backoffice
15
+ layout route is now a thin adapter that supplies its auth client and admin check.
16
+
3
17
  ## 0.39.0
4
18
 
5
19
  - 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.41.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,10 @@
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",
159
+ "./backoffice/routes": "./src/backoffice/routes/index.tsx",
160
+ "./backoffice/routes/host": "./src/backoffice/routes/host.ts"
158
161
  },
159
162
  "dependencies": {
160
163
  "fflate": "^0.8.3"
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Backoffice host registry.
3
+ *
4
+ * A site registers its server functions and site-level facts once; the package
5
+ * then owns the backoffice route components themselves, so a site's route file
6
+ * is a two-line `createFileRoute(...)({ component: XRoute })`.
7
+ */
8
+
9
+ import type { SiteConfig } from "../../config/site-config";
10
+
11
+ /* eslint-disable @typescript-eslint/no-explicit-any */
12
+ /** A TanStack server function as seen from the host (called via `useServerFn`). */
13
+ export type HostServerFn = (...args: any[]) => Promise<any>;
14
+ /* eslint-enable @typescript-eslint/no-explicit-any */
15
+
16
+ /** Everything the package needs from a host to render the backoffice routes. */
17
+ export type BackofficeHost = {
18
+ /** Site configuration used for defaults on the settings screen. */
19
+ siteConfig: SiteConfig;
20
+ /** Locales the site publishes, used by tracking/domain screens. */
21
+ locales: readonly string[];
22
+ /** Role identifiers this platform build supports. */
23
+ allRoles: string[];
24
+ /** Company details used as code defaults on the contact screen. */
25
+ company: Record<string, string>;
26
+ /** Locale used to preview form copy in the backoffice. */
27
+ previewLocale?: string;
28
+ /** Server functions, grouped per module. */
29
+ fns: {
30
+ // admin
31
+ listAuditLogs: HostServerFn;
32
+ listEmailLogs: HostServerFn;
33
+ listUsers: HostServerFn;
34
+ getMyRoles: HostServerFn;
35
+ createUser: HostServerFn;
36
+ deleteUser: HostServerFn;
37
+ setUserPassword: HostServerFn;
38
+ sendPasswordResetEmail: HostServerFn;
39
+ setUserRoles: HostServerFn;
40
+ getSystemVersion: HostServerFn;
41
+ // site settings
42
+ getSiteSettings: HostServerFn;
43
+ saveSiteSettings: HostServerFn;
44
+ clearSiteSetting: HostServerFn;
45
+ // redirects
46
+ listRedirects: HostServerFn;
47
+ saveRedirect: HostServerFn;
48
+ deleteRedirect: HostServerFn;
49
+ // tracking + domains
50
+ listInjections: HostServerFn;
51
+ saveInjection: HostServerFn;
52
+ deleteInjection: HostServerFn;
53
+ getLocaleDomains: HostServerFn;
54
+ // shared content
55
+ getContentOverrides: HostServerFn;
56
+ saveContentSection: HostServerFn;
57
+ // forms
58
+ getFormDefaults: HostServerFn;
59
+ saveFormDefaults: HostServerFn;
60
+ purgeOldSubmissions: HostServerFn;
61
+ };
62
+ };
63
+
64
+ let current: BackofficeHost | null = null;
65
+
66
+ /** Registers the host adapters. Call once, from a module the routes import. */
67
+ export function registerBackofficeHost(host: BackofficeHost): BackofficeHost {
68
+ current = host;
69
+ return host;
70
+ }
71
+
72
+ /** Reads the registered host, failing loudly when a site forgot to register. */
73
+ export function backofficeHost(): BackofficeHost {
74
+ if (!current) {
75
+ throw new Error(
76
+ "Backoffice host not registered — call registerBackofficeHost() before rendering backoffice routes.",
77
+ );
78
+ }
79
+ return current;
80
+ }
81
+
82
+ /** Absolute backoffice paths derived from the site's configured base path. */
83
+ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
84
+ const base = host.siteConfig.backofficePath;
85
+ return {
86
+ base,
87
+ admin: `${base}/admin`,
88
+ audit: `${base}/admin/audit`,
89
+ emailLogs: `${base}/admin/email-logs`,
90
+ users: `${base}/admin/users`,
91
+ forms: `${base}/forms`,
92
+ };
93
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Package-owned backoffice route components.
3
+ *
4
+ * Each component is a complete screen: it reads the host adapters from the
5
+ * registry, wires queries/mutations and renders the shared view. A site route
6
+ * file only maps a URL to one of these.
7
+ */
8
+
9
+ import { Link, useNavigate } from "@tanstack/react-router";
10
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
11
+ import { useServerFn } from "@tanstack/react-start";
12
+
13
+ import type { SiteSettingOverrides } from "../../config/site-config";
14
+ import { AuditView } from "../AuditView";
15
+ import { ContactDetailsView } from "../ContactDetailsView";
16
+ import { EmailLogsView } from "../EmailLogsView";
17
+ import { FormSettingsView } from "../FormSettingsView";
18
+ import { LogsView } from "../LogsView";
19
+ import { RedirectsView } from "../RedirectsView";
20
+ import { RolesView } from "../RolesView";
21
+ import { SiteSettingsView } from "../SiteSettingsView";
22
+ import { TrackingView } from "../TrackingView";
23
+ import { UsersView } from "../UsersView";
24
+ import { VersionView } from "../VersionView";
25
+ import { backofficeHost, backofficePaths } from "./host";
26
+
27
+ export * from "./host";
28
+
29
+ /** Audit trail screen. */
30
+ export function AuditRoute() {
31
+ const { fns } = backofficeHost();
32
+ const fetchLogs = useServerFn(fns.listAuditLogs);
33
+ const q = useQuery({
34
+ queryKey: ["admin", "audit"],
35
+ queryFn: () => fetchLogs({ data: { limit: 300 } }),
36
+ });
37
+ return <AuditView rows={(q.data ?? []) as never} />;
38
+ }
39
+
40
+ /** Outbound email log screen. */
41
+ export function EmailLogsRoute() {
42
+ const { fns } = backofficeHost();
43
+ const fetchLogs = useServerFn(fns.listEmailLogs);
44
+ const q = useQuery({
45
+ queryKey: ["admin", "email-logs"],
46
+ queryFn: () => fetchLogs({ data: { limit: 300 } }),
47
+ });
48
+ return <EmailLogsView rows={(q.data ?? []) as never} />;
49
+ }
50
+
51
+ /** Log index linking the audit trail and email log. */
52
+ export function LogsRoute() {
53
+ const paths = backofficePaths();
54
+ return <LogsView auditPath={paths.audit} emailLogsPath={paths.emailLogs} />;
55
+ }
56
+
57
+ /** Role reference screen. */
58
+ export function RolesRoute() {
59
+ const host = backofficeHost();
60
+ return <RolesView allRoles={host.allRoles} usersPath={backofficePaths(host).users} />;
61
+ }
62
+
63
+ /** User administration screen. */
64
+ export function UsersRoute() {
65
+ const { fns, allRoles } = backofficeHost();
66
+ const qc = useQueryClient();
67
+ const fetchUsers = useServerFn(fns.listUsers);
68
+ const fetchMyRoles = useServerFn(fns.getMyRoles);
69
+ const runCreate = useServerFn(fns.createUser);
70
+ const runDelete = useServerFn(fns.deleteUser);
71
+ const runSetPw = useServerFn(fns.setUserPassword);
72
+ const runReset = useServerFn(fns.sendPasswordResetEmail);
73
+ const runSetRoles = useServerFn(fns.setUserRoles);
74
+
75
+ const usersQ = useQuery({ queryKey: ["admin", "users"], queryFn: () => fetchUsers() });
76
+ const myRolesQ = useQuery({ queryKey: ["admin", "my-roles"], queryFn: () => fetchMyRoles() });
77
+
78
+ return (
79
+ <UsersView
80
+ deps={{
81
+ users: (usersQ.data ?? []) as never,
82
+ myRoles: myRolesQ.data ?? [],
83
+ allRoles,
84
+ createUser: (input) => runCreate(input as never),
85
+ deleteUser: (input) => runDelete(input),
86
+ setUserPassword: (input) => runSetPw(input),
87
+ sendPasswordResetEmail: (input) => runReset(input),
88
+ setUserRoles: (input) => runSetRoles(input as never),
89
+ refresh: () => qc.invalidateQueries({ queryKey: ["admin", "users"] }),
90
+ }}
91
+ />
92
+ );
93
+ }
94
+
95
+ /** CMS build and platform version screen. */
96
+ export function VersionRoute() {
97
+ const { fns } = backofficeHost();
98
+ const fetchVersion = useServerFn(fns.getSystemVersion);
99
+ const q = useQuery({ queryKey: ["admin", "system-version"], queryFn: () => fetchVersion() });
100
+ return (
101
+ <VersionView
102
+ deps={{ build: q.data?.build, releases: q.data?.releases ?? [], loading: q.isLoading }}
103
+ />
104
+ );
105
+ }
106
+
107
+ /** Site settings screen (code defaults plus database overrides). */
108
+ export function SiteSettingsRoute() {
109
+ const { fns, siteConfig } = backofficeHost();
110
+ const load = useServerFn(fns.getSiteSettings);
111
+ const save = useServerFn(fns.saveSiteSettings);
112
+ const clear = useServerFn(fns.clearSiteSetting);
113
+ const q = useQuery({ queryKey: ["admin", "site-settings"], queryFn: () => load() });
114
+
115
+ return (
116
+ <SiteSettingsView
117
+ deps={{
118
+ config: q.data?.config ?? siteConfig,
119
+ overrides: (q.data?.overrides ?? {}) as SiteSettingOverrides,
120
+ saveSettings: (input) => save(input as never),
121
+ clearSetting: (input) => clear(input as never),
122
+ refresh: () => q.refetch(),
123
+ originSource: q.data?.originSource ?? "code",
124
+ }}
125
+ />
126
+ );
127
+ }
128
+
129
+ /** URL redirect administration. */
130
+ export function RedirectsRoute() {
131
+ const { fns } = backofficeHost();
132
+ const list = useServerFn(fns.listRedirects);
133
+ const save = useServerFn(fns.saveRedirect);
134
+ const remove = useServerFn(fns.deleteRedirect);
135
+ const q = useQuery({ queryKey: ["cms", "redirects"], queryFn: () => list() });
136
+
137
+ return (
138
+ <RedirectsView
139
+ deps={{
140
+ rows: q.data ?? [],
141
+ saveRedirect: (input) => save(input),
142
+ deleteRedirect: (input) => remove(input),
143
+ refresh: () => q.refetch(),
144
+ }}
145
+ />
146
+ );
147
+ }
148
+
149
+ /** Code & tracking injection screen. */
150
+ export function TrackingRoute() {
151
+ const { fns, locales } = backofficeHost();
152
+ const list = useServerFn(fns.listInjections);
153
+ const save = useServerFn(fns.saveInjection);
154
+ const remove = useServerFn(fns.deleteInjection);
155
+ const domains = useServerFn(fns.getLocaleDomains);
156
+
157
+ const q = useQuery({ queryKey: ["admin", "injections"], queryFn: () => list() });
158
+ const domainsQ = useQuery({ queryKey: ["admin", "locale-domains"], queryFn: () => domains() });
159
+
160
+ return (
161
+ <TrackingView
162
+ deps={{
163
+ profiles: q.data ?? [],
164
+ domains: domainsQ.data ?? [],
165
+ locales: locales as never,
166
+ saveInjection: (input) => save(input as never),
167
+ deleteInjection: (input) => remove(input),
168
+ refresh: () => q.refetch(),
169
+ }}
170
+ />
171
+ );
172
+ }
173
+
174
+ /** Company contact details screen. */
175
+ export function ContactDetailsRoute() {
176
+ const { fns, company } = backofficeHost();
177
+ const fetchOverrides = useServerFn(fns.getContentOverrides);
178
+ const save = useServerFn(fns.saveContentSection);
179
+ const qc = useQueryClient();
180
+ const overridesQ = useQuery({
181
+ queryKey: ["cms", "content-overrides"],
182
+ queryFn: () => fetchOverrides(),
183
+ });
184
+
185
+ return (
186
+ <ContactDetailsView
187
+ deps={{
188
+ defaults: company,
189
+ stored: overridesQ.data?.company as Record<string, string> | undefined,
190
+ loading: overridesQ.isLoading,
191
+ saveSection: (input) => save(input as never),
192
+ refresh: () => qc.invalidateQueries({ queryKey: ["cms", "content-overrides"] }),
193
+ }}
194
+ />
195
+ );
196
+ }
197
+
198
+ /** Form defaults and retention screen. */
199
+ export function FormSettingsRoute() {
200
+ const host = backofficeHost();
201
+ const { fns } = host;
202
+ const load = useServerFn(fns.getFormDefaults);
203
+ const save = useServerFn(fns.saveFormDefaults);
204
+ const purge = useServerFn(fns.purgeOldSubmissions);
205
+ const formsPath = backofficePaths(host).forms;
206
+ const { data, isLoading } = useQuery({ queryKey: ["form-defaults"], queryFn: () => load() });
207
+
208
+ return (
209
+ <FormSettingsView
210
+ deps={{
211
+ defaults: data,
212
+ isLoading,
213
+ saveDefaults: (input) => save(input as never),
214
+ purgeOldSubmissions: (input) => purge(input),
215
+ previewLocale: host.previewLocale ?? host.siteConfig.defaultLocale,
216
+ BackLink: ({ className, children }) => (
217
+ <Link to={formsPath} className={className}>
218
+ {children}
219
+ </Link>
220
+ ),
221
+ }}
222
+ />
223
+ );
224
+ }
225
+
226
+ /** Shared-content SEO detail helper: back navigation to the SEO list. */
227
+ export function useSeoListNavigate() {
228
+ const navigate = useNavigate();
229
+ const paths = backofficePaths();
230
+ return () => navigate({ to: `${paths.base}/seo` });
231
+ }
@@ -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
+ }