From 06093af633e1d54f120c88d28a45e57405245694 Mon Sep 17 00:00:00 2001 From: Loyyd Date: Sat, 13 Jun 2026 09:34:28 +0200 Subject: [PATCH] Add admin dashboard and Pocket ID auth notes --- .env.example | 14 + .forgejo/workflows/deploy.yml | 7 + docs/ADMIN.md | 49 +++ docs/POCKET-ID-AUTH.md | 75 ++++ scripts/copy-static-assets.mjs | 7 + src/components/SiteLayout.astro | 3 + src/data/events.ts | 31 ++ src/pages/admin/index.astro | 647 ++++++++++++++++++++++++++++++++ src/pages/contact.astro | 103 ++++- src/pages/events.astro | 40 +- 10 files changed, 947 insertions(+), 29 deletions(-) create mode 100644 .env.example create mode 100644 docs/ADMIN.md create mode 100644 docs/POCKET-ID-AUTH.md create mode 100644 src/data/events.ts create mode 100644 src/pages/admin/index.astro diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..38f90cb6 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Hosted form endpoint used by the contact form. +# Examples: Formspree, Basin, Getform, or a self-hosted POST endpoint. +PUBLIC_CONTACT_FORM_ENDPOINT= + +# Optional comma-separated fallback recipients used by the mailto fallback. +PUBLIC_CONTACT_RECIPIENTS=directors@familyfed.ie,info@familyfed.ie,media@familyfed.ie + +# Optional admin URL for reviewing stored contact form submissions. +PUBLIC_CONTACT_SUBMISSIONS_URL= + +# Optional Plausible analytics configuration. +PUBLIC_PLAUSIBLE_DOMAIN=familyfed.ie +PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.io/js/script.js +PUBLIC_ANALYTICS_DASHBOARD_URL= diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 787303ae..89777ee4 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -15,6 +15,12 @@ jobs: env: NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }} NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }} + PUBLIC_ANALYTICS_DASHBOARD_URL: ${{ secrets.PUBLIC_ANALYTICS_DASHBOARD_URL }} + PUBLIC_CONTACT_FORM_ENDPOINT: ${{ secrets.PUBLIC_CONTACT_FORM_ENDPOINT }} + PUBLIC_CONTACT_RECIPIENTS: ${{ secrets.PUBLIC_CONTACT_RECIPIENTS }} + PUBLIC_CONTACT_SUBMISSIONS_URL: ${{ secrets.PUBLIC_CONTACT_SUBMISSIONS_URL }} + PUBLIC_PLAUSIBLE_DOMAIN: ${{ secrets.PUBLIC_PLAUSIBLE_DOMAIN }} + PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${{ secrets.PUBLIC_PLAUSIBLE_SCRIPT_SRC }} REPO_URL: https://git.bcgen.ie/familyfedie/familyfedie-website.git run: | set -eu @@ -35,6 +41,7 @@ jobs: -c npm run build test -f dist/index.html + test -f dist/admin/index.html test -f dist/speeches/index.html test -f dist/assets/icons/familyfed-favicon.png diff --git a/docs/ADMIN.md b/docs/ADMIN.md new file mode 100644 index 00000000..5f7ad127 --- /dev/null +++ b/docs/ADMIN.md @@ -0,0 +1,49 @@ +# FamilyFed.ie admin notes + +The admin dashboard is generated at `/admin/index.html`. To make +`https://admin.familyfed.ie/` show the dashboard, configure the web server or +reverse proxy for that hostname to serve `dist/admin/index.html` as its root +document. + +Protect `admin.familyfed.ie` at the web server, reverse proxy, or hosting layer +with Pocket ID authentication. Require the Pocket ID user group +`familyfed_admin`. The generated page has `noindex`, but a static page cannot +enforce a secure admin login by itself. + +See `docs/POCKET-ID-AUTH.md` for the exact proxy/auth requirements. + +## Content changes + +- Calendar events live in `src/data/events.ts`. +- Speeches live in `content/speeches//.md`. +- The admin page includes builders that generate valid snippets/files for both. + +Because this is a static Astro site, the browser cannot securely write content +back into the repository by itself. Commit generated content changes to the +repository and deploy as usual. + +## Contact form + +Set `PUBLIC_CONTACT_FORM_ENDPOINT` during the build to make the contact form +send real submissions through a hosted form provider such as Formspree, Basin, +Getform, or a self-hosted endpoint. + +Optional variables: + +- `PUBLIC_CONTACT_RECIPIENTS`: comma-separated mail fallback recipients. +- `PUBLIC_CONTACT_SUBMISSIONS_URL`: admin dashboard URL for form submissions. + +If no endpoint is configured, the form falls back to opening the visitor's email +application with the message filled in. + +For deployment, add the matching variables as Forgejo secrets. The deploy +workflow passes them into the Astro build. + +## Analytics + +Set `PUBLIC_PLAUSIBLE_DOMAIN=familyfed.ie` to load Plausible analytics on the +public site. If using a self-hosted Plausible instance, set +`PUBLIC_PLAUSIBLE_SCRIPT_SRC` to the script URL. + +Set `PUBLIC_ANALYTICS_DASHBOARD_URL` to the analytics dashboard URL so the admin +page can link directly to visitor reports. diff --git a/docs/POCKET-ID-AUTH.md b/docs/POCKET-ID-AUTH.md new file mode 100644 index 00000000..7dd2d2c0 --- /dev/null +++ b/docs/POCKET-ID-AUTH.md @@ -0,0 +1,75 @@ +# Pocket ID auth for admin.familyfed.ie + +`admin.familyfed.ie` must be protected before traffic reaches the static Astro +files. Pocket ID is an OIDC provider, so the auth check belongs in the reverse +proxy or auth middleware. + +Required policy: + +- Host: `admin.familyfed.ie` +- Required Pocket ID group: `familyfed_admin` +- Static upstream/root: `dist/admin/index.html` + +## Recommended Tinyauth setup + +Pocket ID's proxy guide points to Tinyauth for reverse-proxy protection, and +Tinyauth supports Pocket ID groups through the `oauth.groups` app label. + +Create a Pocket ID OIDC client: + +- Name: `FamilyFed Admin` +- Callback URL: `https://auth.familyfed.ie/api/oauth/callback/pocketid` +- Scopes: `openid email profile groups` + +Configure Tinyauth with the Pocket ID client: + +```yaml +environment: + TINYAUTH_OAUTH_AUTOREDIRECT: pocketid + TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTID: "" + TINYAUTH_OAUTH_PROVIDERS_POCKETID_CLIENTSECRET: "" + TINYAUTH_OAUTH_PROVIDERS_POCKETID_AUTHURL: "https://pocket-id.familyfed.ie/authorize" + TINYAUTH_OAUTH_PROVIDERS_POCKETID_TOKENURL: "https://pocket-id.familyfed.ie/api/oidc/token" + TINYAUTH_OAUTH_PROVIDERS_POCKETID_USERINFOURL: "https://pocket-id.familyfed.ie/api/oidc/userinfo" + TINYAUTH_OAUTH_PROVIDERS_POCKETID_REDIRECTURL: "https://auth.familyfed.ie/api/oauth/callback/pocketid" + TINYAUTH_OAUTH_PROVIDERS_POCKETID_SCOPES: "openid email profile groups" + TINYAUTH_OAUTH_PROVIDERS_POCKETID_NAME: "Pocket ID" +``` + +Add app access labels for the admin host: + +```yaml +labels: + tinyauth.apps.familyfed-admin.config.domain: "admin.familyfed.ie" + tinyauth.apps.familyfed-admin.oauth.groups: "familyfed_admin" +``` + +Users outside `familyfed_admin` should receive the unauthorized page from the +auth middleware and never reach the static admin HTML. + +## Caddy with caddy-security + +If the live proxy is Caddy with `caddy-security`, create a Pocket ID OIDC client +with this callback URL: + +```text +https://admin.familyfed.ie/caddy-security/oauth2/generic/authorization-code-callback +``` + +Configure the authorization policy to allow only users whose OIDC groups claim +contains `familyfed_admin`, then serve or reverse-proxy the static admin output. +Pocket ID's own guide documents the Caddy callback shape and OIDC provider +settings; the group condition must be added in the Caddy authorization policy. + +## Deployment checks + +The site build now verifies that `dist/admin/index.html` exists. That confirms +the static admin page is available for the host, but it does not prove the +external proxy has enabled Pocket ID. Verify live protection with: + +```bash +curl -I https://admin.familyfed.ie/ +``` + +Expected unauthenticated behavior is a redirect to the Pocket ID/Tinyauth login +or a `401`/`403` response from the auth middleware. diff --git a/scripts/copy-static-assets.mjs b/scripts/copy-static-assets.mjs index 0525126e..5461f7f2 100644 --- a/scripts/copy-static-assets.mjs +++ b/scripts/copy-static-assets.mjs @@ -17,10 +17,17 @@ for (const dir of staticDirs) { const speechIndexSource = path.join(distRoot, "speeches.html"); const speechIndexDestination = path.join(distRoot, "speeches", "index.html"); +const adminIndexSource = path.join(distRoot, "admin.html"); +const adminIndexDestination = path.join(distRoot, "admin", "index.html"); if (fs.existsSync(speechIndexSource)) { fs.mkdirSync(path.dirname(speechIndexDestination), { recursive: true }); fs.copyFileSync(speechIndexSource, speechIndexDestination); } +if (fs.existsSync(adminIndexSource)) { + fs.mkdirSync(path.dirname(adminIndexDestination), { recursive: true }); + fs.copyFileSync(adminIndexSource, adminIndexDestination); +} + console.log(`Copied ${staticDirs.join(", ")} into dist/.`); diff --git a/src/components/SiteLayout.astro b/src/components/SiteLayout.astro index b56edd27..d5cdf798 100644 --- a/src/components/SiteLayout.astro +++ b/src/components/SiteLayout.astro @@ -18,6 +18,8 @@ const siteOrigin = Astro.site?.origin ?? "https://familyfed.ie"; const absoluteUrl = (value) => value ? new URL(value, siteOrigin).toString() : undefined; const canonicalUrl = absoluteUrl(canonical ?? pathname); const imageUrl = absoluteUrl(image); +const plausibleDomain = import.meta.env.PUBLIC_PLAUSIBLE_DOMAIN ?? ""; +const plausibleScriptSrc = import.meta.env.PUBLIC_PLAUSIBLE_SCRIPT_SRC ?? "https://plausible.io/js/script.js"; const metaTitle = title .replace(/–|–/g, "-") .replace(/‘|’|‘|’/g, "'") @@ -49,6 +51,7 @@ const metaTitle = title + {plausibleDomain && } diff --git a/src/data/events.ts b/src/data/events.ts new file mode 100644 index 00000000..a3b0a451 --- /dev/null +++ b/src/data/events.ts @@ -0,0 +1,31 @@ +export type CalendarEvent = { + id: string; + title: string; + date: string; + startTime: string; + endTime: string; + location: string; + details: string; + url?: string; +}; + +export type RecurringCalendarEvent = Omit & { + weekday: number; + startDate?: string; + endDate?: string; +}; + +export const recurringCalendarEvents: RecurringCalendarEvent[] = [ + { + id: "sunday-service", + title: "Sunday Service", + startTime: "11:00", + endTime: "12:00", + location: "19 North Great Georges St., Dublin 1, Ireland", + details: "Weekly Sunday Service video upload and community worship.", + url: "/services.html", + weekday: 0, + }, +]; + +export const calendarEvents: CalendarEvent[] = []; diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro new file mode 100644 index 00000000..cb508692 --- /dev/null +++ b/src/pages/admin/index.astro @@ -0,0 +1,647 @@ +--- +import { getArchiveEntries } from "../../lib/archive"; +import { calendarEvents, recurringCalendarEvents } from "../../data/events"; + +const entries = await getArchiveEntries(); +const blogEntries = entries.filter((entry) => entry.data.type === "blog"); +const speechEntries = entries.filter((entry) => entry.data.type === "speech"); +const contactFormEndpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? ""; +const contactSubmissionsUrl = import.meta.env.PUBLIC_CONTACT_SUBMISSIONS_URL ?? ""; +const analyticsDashboardUrl = import.meta.env.PUBLIC_ANALYTICS_DASHBOARD_URL ?? ""; +const plausibleDomain = import.meta.env.PUBLIC_PLAUSIBLE_DOMAIN ?? ""; +const today = new Date().toISOString().slice(0, 10); +const adminConfig = { + analyticsDashboardUrl, + contactEndpointConfigured: Boolean(contactFormEndpoint), + contactSubmissionsUrl, + plausibleDomain, +}; +--- + + + + + + + + FamilyFed.ie Admin + + + + +
+
+
+
+ +
+

admin.familyfed.ie

+

FamilyFed.ie Admin

+
+
+ +
+
+ +
+
+
+ {calendarEvents.length + recurringCalendarEvents.length} + Calendar event definitions +
+
+ {speechEntries.length} + Speeches in content +
+
+ {blogEntries.length} + Blog and news posts +
+
+ {plausibleDomain || "Off"} + Visitor analytics +
+
+ +
+
+
+

Add Calendar Event

+

Generate a valid event entry for src/data/events.ts. Add the snippet inside calendarEvents, then run the build.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Add Speech

+

Generate a Markdown speech file for content/speeches. The generated path matches the archive structure used by the site.

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + + Download .md +
+
+ + +
+
+
+
+ + +
+
+
+ + + + diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 8fafbe9e..dc1687b1 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -2,6 +2,29 @@ import SiteLayout from "../components/SiteLayout.astro"; import TwoColumnPage from "../components/TwoColumnPage.astro"; import articleHtml from "../content/pages/contact.html?raw"; + +const contactFormEndpoint = import.meta.env.PUBLIC_CONTACT_FORM_ENDPOINT ?? ""; +const contactRecipients = import.meta.env.PUBLIC_CONTACT_RECIPIENTS ?? "directors@familyfed.ie,info@familyfed.ie,media@familyfed.ie"; +const contactFormAction = contactFormEndpoint || `mailto:${contactRecipients}`; +const contactFormConfig = JSON.stringify({ + endpoint: contactFormEndpoint, + recipients: contactRecipients, +}); +const escapeHtmlAttribute = (value: string) => + value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +const contactArticleHtml = articleHtml + .replace( + 'action="mailto:directors@familyfed.ie,info@familyfed.ie,media@familyfed.ie"', + `action="${escapeHtmlAttribute(contactFormAction)}"`, + ) + .replace( + 'method="post" enctype="text/plain"', + contactFormEndpoint ? 'method="post"' : 'method="post" enctype="text/plain"', + ); --- - - diff --git a/src/pages/events.astro b/src/pages/events.astro index 7c8bef36..fd996032 100644 --- a/src/pages/events.astro +++ b/src/pages/events.astro @@ -1,6 +1,7 @@ --- import SiteLayout from "../components/SiteLayout.astro"; import mainHtml from "../content/pages/events.html?raw"; +import { calendarEvents, recurringCalendarEvents } from "../data/events"; --- -