Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
# entry is the real caller, so anything a browser put in front of it is ignored. Serve
# this container directly with nothing in front and list it anyway, and the reverse holds:
# the caller supplies the only entry in the chain, the API takes it, and every per-IP rate
# limit the API has - magic-link requests and the contact form among them - is bypassable
# limit the API has - magic-link requests and the support form among them - is bypassable
# by changing one header. Leaving it unset costs one shared rate-limit bucket for
# everybody, which is the cheaper of the two mistakes.

Expand Down Expand Up @@ -60,7 +60,7 @@ NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
# origin: scheme and host, no trailing path.
# SITE_URL=https://dives.example.com

# Optional. Address offered on /contact as a fallback if a form submission fails.
# Optional. Address offered on /support as a fallback if a form submission fails.
# Display only - the form posts to the API, which decides where the message actually
# goes (CONTACT_FORM_EMAIL there); setting this does not change delivery. Leave it
# unset unless it matches that address: a failed submission then points at the GitHub
Expand Down
28 changes: 14 additions & 14 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1093,20 +1093,20 @@ in tenths of a bar) and are divided in `toChannelSeries`. Divided, never multipl
exactly the noise the integer encoding removes. `PROFILE_CHANNELS` holds the divisors and mirrors
the API's `DEPTH_SCALE`/`TEMPERATURE_SCALE`/`PRESSURE_SCALE`; the two lists are a pair.

## The contact form posts to the API, and the page it lives on claims only what exists
## The support form posts to the API, and the page it lives on claims only what exists

`contactAPI.sendMessage` posts to `POST /contact`; the API forwards it to its `CONTACT_FORM_EMAIL`,
`supportAPI.sendRequest` posts to `POST /support`; the API forwards it to its `CONTACT_FORM_EMAIL`,
and nothing here decides the recipient.

`NEXT_PUBLIC_CONTACT_EMAIL` is display only — the `mailto:` fallback in the error state — optional,
with no default: `contact@opendiving.app` would hand a self-hosted instance's visitors an address
that cannot see their server. Unset, `ContactForm` points at `FALLBACK_ISSUES_URL` in
`lib/contact.ts`, since a broken form is this repo's bug. Set it only where the operator reads the
that cannot see their server. Unset, `SupportForm` points at `FALLBACK_ISSUES_URL` in
`lib/support.ts`, since a broken form is this repo's bug. Set it only where the operator reads the
mailbox.

`CONTACT_CATEGORIES` in `lib/api/contact.ts` mirrors `ContactCategory` in the API's
`schemas/contact.py`; the backend 422s anything else, so adding a category changes both sides.
`contactSchema` duplicates the API's length bounds to fail before a round-trip.
`SUPPORT_CATEGORIES` in `lib/api/support.ts` mirrors `SupportCategory` in the API's
`schemas/support.py`; the backend 422s anything else, so adding a category changes both sides.
`supportSchema` duplicates the API's length bounds to fail before a round-trip.

The form prefills empty fields from `useAuth()`; `defaultValues` cannot, since the user arrives
after the auth bootstrap resolves. The page is a Server Component for `metadata`; only the form is
Expand Down Expand Up @@ -1471,7 +1471,7 @@ Two other contrast rules hold. The landing page's stats strip does not use
colour (3.67:1 in dark mode, `--primary` being mid-grey there); in-copy links are a plain underline
inheriting the surrounding colour.

`npx @axe-core/cli --tags="wcag2a,wcag2aa,wcag21aa"` over `/`, `/signin`, `/contact`, `/privacy` and
`npx @axe-core/cli --tags="wcag2a,wcag2aa,wcag21aa"` over `/`, `/signin`, `/support`, `/privacy` and
`/terms` is the check that covers contrast; the `code-quality` workflow scans only `/`, so the other
four are re-checked by hand after any change to `globals.css`.

Expand Down Expand Up @@ -3553,7 +3553,7 @@ An install is one compose file naming both `web` and `api`, so neither component
it without the other carrying a copy. It lives in `opendiving/opendiving` with its docs, and this
README links there rather than paraphrasing: two copies diverge, and the wrong one is the one the
reader found first. `README.md`'s _Full self-hosting docs_ goes to `.../opendiving/tree/main/docs`;
`landing-page.tsx`'s `SELF_HOSTING_URL` and the contact page's _Self-hosting quickstart_ land on
`landing-page.tsx`'s `SELF_HOSTING_URL` and the support page's _Self-hosting quickstart_ land on
`https://github.com/opendiving/opendiving` itself, which carries pitch and commands. What stays here
the bundle has no reason to know: building this image yourself, and `NEXT_PUBLIC_API_URL` as a build
arg for split-origin deployments. The README says where the project's instance is named (the front
Expand Down Expand Up @@ -3620,7 +3620,7 @@ by construction, thread and advisory together. A repository setting (Settings
a file. `security@opendiving.app` is the second channel and a real inbox. An address must pass "has
a maintainer created it and agreed to read it", not "does it look plausible" (so
`lib/runtime-config.ts` leaves `CONTACT_EMAIL` unset, and `conduct@opendiving.app` in
`CODE_OF_CONDUCT.md` is conduct only). The contact form's `security` category (`CONTACT_CATEGORIES`)
`CODE_OF_CONDUCT.md` is conduct only). The support form's `security` category (`SUPPORT_CATEGORIES`)
is no third channel on any instance, even the project's own: an unauthenticated public form posting
to whatever address the instance configured, without advisory-thread privacy. No supported-versions
table and no SLA: `publish-image.yml` aliases one tag and nothing is backported, so "the latest
Expand Down Expand Up @@ -4315,7 +4315,7 @@ disclosed by hand in §10.1.
## Privacy page: §6.3 enumerates every email, and the enumeration is exhaustive on purpose

§6.3 lists everything a diver receives, in three groups — mail following an action on this site,
security notices, the gear-service digest — with contact-form mail parenthesised as mail about you,
security notices, the gear-service digest — with support-form mail parenthesised as mail about you,
sent to `CONTACT_FORM_EMAIL`. Any new `send_*` function in the api owes this section a line;
`app/privacy/page.test.tsx` pins only the first group's count against its list.

Expand Down Expand Up @@ -4366,7 +4366,7 @@ Two sentences at the page's edges stay as they are. §7's "Personal information
deleted within 30 days" is byte-identical to what ships, because documents in the API repo,
including a config default, are written against that number. §13 promises nothing about delivery:
`CONTACT_EMAIL` in this repo is display-only while the API's `CONTACT_FORM_EMAIL` decides where a
submission goes, and the two can disagree. §13 links the contact page as "how to reach whoever runs
submission goes, and the two can disagree. §13 links the support page as "how to reach whoever runs
this copy" without asserting a form works, and renders neither a project-owned address nor the
public issue tracker — a diver filing an erasure request in public, to people who are not the
controller, is the outcome it must not inherit.
Expand Down Expand Up @@ -4453,7 +4453,7 @@ correct — it depends on the page's tree. The columns are group labels over lin
`footer.render.test.tsx` pins zero headings from the component.

`CardTitle` (`ui/card.tsx`) takes `as` (`"h2" | "h3" | "h4"`, default `h3`), tag only;
`contact/page.tsx` passes `as="h2"` because its cards are top-level sections.
`support/page.tsx` passes `as="h2"` because its cards are top-level sections.

CI misses both: `code-quality.yml` passes `--include="main"`, the footer is `<main>`'s sibling, and
`heading-order` is `best-practice`, outside `--tags`. `@axe-core/cli` pins a ChromeDriver major;
Expand Down Expand Up @@ -5271,7 +5271,7 @@ framing ("this copy", "the operator of this copy") resolves on either kind of in
The pages announce no project-operated instance and nothing about aggregation or telemetry (new
collection owing its own disclosure). Terms §9 limits the author's liability and §10 indemnifies the
writing; neither transfers to the same party as operator, and the AGPL finding stands. No project
address is printed: the one that can act is the operator's, which the contact page reaches.
address is printed: the one that can act is the operator's, which the support page reaches.

Claims to hunt are about identity ("a different party", "not parties to these Terms"), not servers;
read each section whole.
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ owners. Work out which one you have before you send it anywhere.
its operator can see it, fix it, or tell the people using it; where that operator is not this
project, its maintainers have no access to the instance and no way to reach its users. A
misconfigured deployment, an exposed database, a stale image on someone's box: report it to whoever
runs that server. Its contact form, if it has one, reaches that operator and nobody else.
runs that server. Its support form, if it has one, reaches that operator and nobody else.

**The code** is a flaw in this repository that would affect anyone running it. That one is ours, and
the rest of this file is about it.
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const inter = Inter({ subsets: ["latin"] });
// the build fails on it. `false` allows a blocking route, and the root is the only
// placement that reaches the routes needing it: both non-root layouts and every page
// behind the auth guard are Client Components, which cannot carry this export, and the
// handful of Server Component pages that could - `/`, `/contact`, `/privacy`, `/terms`,
// handful of Server Component pages that could - `/`, `/support`, `/privacy`, `/terms`,
// `/admin` - are not the ones that need it.
export const instant = false;

Expand Down
2 changes: 1 addition & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { LandingPage } from "@/components/layout/landing-page";

// A Server Component purely so it can export `metadata` - the same split
// `contact/page.tsx` uses, and the reason the page itself lives in
// `support/page.tsx` uses, and the reason the page itself lives in
// `components/layout/landing-page.tsx` with the `"use client"` on it. The landing
// page gates its whole render on `useRedirectIfAuthenticated`, so it cannot be a
// Server Component itself.
Expand Down
24 changes: 12 additions & 12 deletions src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function StorageKey({ name }: { name: string }) {
// client island. The island renders a stable server-side state and resolves the
// real one after hydration, which is what every storage consumer here does.
export default async function PrivacyPage() {
// Read here rather than in a client component for the reason `/contact` reads it
// Read here rather than in a client component for the reason `/support` reads it
// here: this is a Server Component, so the instance's configuration is legible
// without shipping it to the browser. The Google section below exists only where
// an instance has Google sign-in turned on.
Expand Down Expand Up @@ -235,8 +235,8 @@ export default async function PrivacyPage() {
service records
</li>
<li>
<strong>Contact Messages:</strong> Whatever you write on the
contact page, if this copy has one configured, and the address
<strong>Support Requests:</strong> Whatever you write on the
support page, if this copy has one configured, and the address
you give to reply to
</li>
</ul>
Expand Down Expand Up @@ -334,16 +334,16 @@ export default async function PrivacyPage() {
</li>
<li>
<strong>Rate-limit counters:</strong> To stop sign-in, the
contact form and the invite-request form being hammered, this
support form and the invite-request form being hammered, this
server counts recent requests in a short-lived store. The
counters are keyed three ways &mdash; by IP address, by the
email address a sign-in link, a contact message or an invitation
email address a sign-in link, a support request or an invitation
was requested for, and by account id for things you can only do
signed in, such as exporting your data, importing a logbook,
changing your username or email, registering a passkey, inviting
somebody, and looking up place and species names. Each counter
is a number and expires by itself: after 15 minutes on the
sign-in and account paths, after an hour on the contact form,
sign-in and account paths, after an hour on the support form,
the invite-request form, exports, logbook imports, and the
place- and species-name lookups. Other counters exist that hold
no identifier at all &mdash; they cap how often this server as a
Expand Down Expand Up @@ -429,7 +429,7 @@ export default async function PrivacyPage() {
to or removed from your account, tell your old address when your
email address is changed, and &mdash; if you have set a service
schedule on a piece of gear &mdash; remind you when it comes
due. If you use the contact form, deliver what you wrote to
due. If you use the support form, deliver what you wrote to
whoever runs this copy. Section 6.3 lists all of these and says
which arrive without you asking
</li>
Expand Down Expand Up @@ -929,7 +929,7 @@ export default async function PrivacyPage() {
</h3>
<p className="text-foreground mb-4">
This copy of OpenDiving sends you three kinds of email, and the
whole list is here. (It also delivers a contact-form message to
whole list is here. (It also delivers a support-form message to
whoever runs this copy, which is mail about you rather than to
you.)
</p>
Expand Down Expand Up @@ -1463,10 +1463,10 @@ export default async function PrivacyPage() {
else entirely; an author who does not run this copy holds none of
your data and could not answer for whoever does. The{" "}
<Link
href="/contact"
href="/support"
className="underline hover:text-muted-foreground"
>
contact page
support page
</Link>{" "}
is how this copy offers to reach them.
</p>
Expand All @@ -1475,7 +1475,7 @@ export default async function PrivacyPage() {
On this copy they are named rather than described:{" "}
<em>Who runs this copy</em> at the top of this page gives the
operator&rsquo;s name and an address that reaches them, so a
request need not go through the contact page at all. That
request need not go through the support page at all. That
address is the operator&rsquo;s, in the role that can act on it
&mdash; which leaves the next paragraph exactly as it stands.
</p>
Expand All @@ -1484,7 +1484,7 @@ export default async function PrivacyPage() {
Two things this page will not do, both deliberately. It will not
print an address belonging to the OpenDiving project as the
software&rsquo;s author, because the address that can act on a
privacy request is the operator&rsquo;s, and the contact page
privacy request is the operator&rsquo;s, and the support page
above is already it &mdash; on a copy the project runs itself,
that page reaches the project too, in the role that can answer.
Sent to the author instead, the request reaches people with no
Expand Down
22 changes: 10 additions & 12 deletions src/app/contact/page.tsx → src/app/support/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ContactForm } from "@/components/contact/contact-form";
import { ISSUE_TRACKERS } from "@/lib/contact";
import { SupportForm } from "@/components/support/support-form";
import { ISSUE_TRACKERS } from "@/lib/support";
import { runtimeConfig } from "@/lib/runtime-config";
import Link from "next/link";
import { Metadata } from "next";
import { AlertCircle, Anchor, Bug, Heart, Mail, Shield } from "lucide-react";

export const metadata: Metadata = {
// The root layout's `title.template` appends " | OpenDiving".
title: "Contact",
title: "Support",
description:
"Get in touch with the people who build OpenDiving - report a bug, request a feature, or send a message that reaches a real inbox.",
"Get support from the people who build OpenDiving - report a bug, request a feature, or send a message that reaches a real inbox.",
};

export default function ContactPage() {
export default function SupportPage() {
// Display-only, and deliberately without a default: this can't route mail on its own -
// the API's `CONTACT_FORM_EMAIL` decides where a submission actually goes - so
// defaulting it to the project's own address would hand a self-hosted instance's
Expand All @@ -31,13 +31,11 @@ export default function ContactPage() {
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-12 text-center">
<h1 className="text-4xl font-bold text-foreground mb-4">
Get in touch
</h1>
<h1 className="text-4xl font-bold text-foreground mb-4">Support</h1>
<p className="text-xl text-muted-foreground max-w-3xl mx-auto">
OpenDiving is an open-source dive log built by volunteers. There is no
support desk - but the form below reaches a real inbox, and most
things get fixed faster in the open, on GitHub.
OpenDiving is an open-source dive log built by volunteers. The form
below reaches a real inbox, and most things get fixed faster in the
open, on GitHub.
</p>
</div>

Expand Down Expand Up @@ -162,7 +160,7 @@ export default function ContactPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ContactForm fallbackEmail={contactEmail} />
<SupportForm fallbackEmail={contactEmail} />

<div className="rounded-md border bg-muted p-4">
<div className="flex items-start gap-2">
Expand Down
6 changes: 3 additions & 3 deletions src/app/terms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -609,10 +609,10 @@ export default async function TermsPage() {
what these Terms mean for you, or the Service being down &mdash;
the person to ask is whoever runs this copy of OpenDiving. The{" "}
<Link
href="/contact"
href="/support"
className="underline hover:text-muted-foreground"
>
contact page
support page
</Link>{" "}
is how this copy offers to reach them.
</p>
Expand All @@ -638,7 +638,7 @@ export default async function TermsPage() {
</>
)}
A question about your own account belongs to whoever runs this
copy, and the contact page above is how to reach them &mdash; on a
copy, and the support page above is how to reach them &mdash; on a
copy the project runs itself, that page reaches the project in the
role that can act. Sent to the author instead, such a question
reaches people who cannot. How quickly the operator of this copy
Expand Down
3 changes: 2 additions & 1 deletion src/components/layout/footer.render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ describe("Footer", () => {

for (const name of ["Platform", "Resources", "Support"]) {
const group = screen.getByRole("navigation", { name });
expect(group).toContainElement(screen.getByText(name));
// The Support column also holds a link reading "Support", so match the label.
expect(group).toContainElement(screen.getByText(name, { selector: "p" }));
}
});

Expand Down
4 changes: 2 additions & 2 deletions src/components/layout/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ export function Footer() {
</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<Link href="/contact" className="hover:text-foreground">
Contact
<Link href="/support" className="hover:text-foreground">
Support
</Link>
</li>
<li>
Expand Down
Loading
Loading