Skip to content

Commit 5fe3e84

Browse files
committed
Wire up Oban, email change, error pages, CSP, readiness, DB SSL
Six production-readiness fixes to the template: - Oban: start it in the supervision tree, add the jobs-table migration, and ship a reference worker (it was configured but never running, so enqueuing a job would have crashed). - Email change: link-confirmed flow with a confirmation link to the new address and a heads-up to the old one. Adds a `sent_to` token column, Accounts.request_email_change/3 + apply_email_change/2, a Settings section, and i18n. - Error UX: branded, self-contained 404/500 pages and a top-level React ErrorBoundary keyed on the Inertia page. - Content-Security-Policy: nonce + strict-dynamic in prod, a relaxed dev profile for LiveReload/LiveDashboard, and a :csp_extra_sources config seam for third-party origins. - Health: add a /health/ready readiness probe that checks the DB pool (the existing /health stays a cheap liveness probe). - DB SSL: on by default in prod (verify_peer + SNI from DATABASE_URL), opt out with DATABASE_SSL=false.
1 parent 94df2dc commit 5fe3e84

33 files changed

Lines changed: 1190 additions & 33 deletions

assets/js/app.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createInertiaApp, router } from '@inertiajs/react';
44
import { createElement, StrictMode } from 'react';
55
import { createRoot } from 'react-dom/client';
66
import { AppProviders } from './app-providers';
7+
import ErrorBoundary from './components/ErrorBoundary';
78
import { syncLocale } from './components/LocaleSync';
89
import Toaster from './components/Toaster';
910
import { toast } from './components/toast';
@@ -60,7 +61,12 @@ createInertiaApp({
6061
long-lived (sockets, caches, listeners) survives route changes. */}
6162
<App {...props}>
6263
{({ Component, props: pageProps, key }) => (
63-
<AppProviders>{createElement(Component, { key: key ?? undefined, ...pageProps })}</AppProviders>
64+
<AppProviders>
65+
{/* Keyed on the page key so navigation remounts the boundary
66+
and clears any caught error — long-lived providers above
67+
stay mounted. */}
68+
<ErrorBoundary key={key ?? undefined}>{createElement(Component, pageProps)}</ErrorBoundary>
69+
</AppProviders>
6470
)}
6571
</App>
6672
<Toaster />
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Component, type ErrorInfo, type ReactNode } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import Button from './Button';
4+
5+
interface Props {
6+
children: ReactNode;
7+
}
8+
9+
interface State {
10+
hasError: boolean;
11+
}
12+
13+
/**
14+
* Top-level client error boundary. Catches render-time exceptions
15+
* thrown by a page (or anything below it) so a single broken component
16+
* shows a branded fallback instead of a blank white screen.
17+
*
18+
* In `app.tsx` it's keyed on the Inertia page key, so navigating to a
19+
* different page remounts it and clears the error automatically — the
20+
* user can recover by clicking a link without a full reload.
21+
*
22+
* This is the one place the project's "no raw try/catch" rule doesn't
23+
* apply: React error boundaries are the supported synchronous catch-all
24+
* for the render tree, with no Promise to route through `go()`.
25+
*/
26+
export default class ErrorBoundary extends Component<Props, State> {
27+
state: State = { hasError: false };
28+
29+
static getDerivedStateFromError(): State {
30+
return { hasError: true };
31+
}
32+
33+
componentDidCatch(error: Error, info: ErrorInfo) {
34+
// Surface to the console in every environment. Wire your error
35+
// monitoring SDK (Sentry, AppSignal, …) in here.
36+
console.error('Unhandled render error:', error, info.componentStack);
37+
}
38+
39+
render() {
40+
if (this.state.hasError) return <ErrorFallback />;
41+
return this.props.children;
42+
}
43+
}
44+
45+
function ErrorFallback() {
46+
const { t } = useTranslation();
47+
48+
return (
49+
<div role="alert" className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
50+
<h1 className="text-2xl font-semibold">{t('error.title')}</h1>
51+
<p className="max-w-md text-sm text-gray-600 dark:text-gray-400">{t('error.body')}</p>
52+
<Button type="button" onClick={() => window.location.reload()}>
53+
{t('error.reload')}
54+
</Button>
55+
</div>
56+
);
57+
}

assets/js/i18n/locales/en.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export default {
1616
themeDark: 'Dark',
1717
themeSystem: 'System',
1818
},
19+
error: {
20+
title: 'Something went wrong',
21+
body: 'An unexpected error occurred. Try reloading the page — if it keeps happening, please come back in a little while.',
22+
reload: 'Reload',
23+
},
1924
home: {
2025
welcome: 'Welcome to ElixirReactStarter',
2126
stack: 'Phoenix 1.8 · Inertia.js · React · SSR',
@@ -32,6 +37,13 @@ export default {
3237
},
3338
settings: {
3439
title: 'Settings',
40+
changeEmail: {
41+
title: 'Change email',
42+
current: 'Your current email is {{email}}.',
43+
newEmail: 'New email',
44+
currentPassword: 'Current password',
45+
submit: 'Send confirmation link',
46+
},
3547
changePassword: {
3648
title: 'Change password',
3749
warning: 'Updating your password will log you out of any other devices.',

assets/js/i18n/locales/es.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export default {
1616
themeDark: 'Oscuro',
1717
themeSystem: 'Sistema',
1818
},
19+
error: {
20+
title: 'Algo salió mal',
21+
body: 'Ocurrió un error inesperado. Intenta recargar la página; si el problema persiste, vuelve a intentarlo más tarde.',
22+
reload: 'Recargar',
23+
},
1924
home: {
2025
welcome: 'Bienvenido a ElixirReactStarter',
2126
stack: 'Phoenix 1.8 · Inertia.js · React · SSR',
@@ -32,6 +37,13 @@ export default {
3237
},
3338
settings: {
3439
title: 'Ajustes',
40+
changeEmail: {
41+
title: 'Cambiar correo electrónico',
42+
current: 'Tu correo electrónico actual es {{email}}.',
43+
newEmail: 'Nuevo correo electrónico',
44+
currentPassword: 'Contraseña actual',
45+
submit: 'Enviar enlace de confirmación',
46+
},
3547
changePassword: {
3648
title: 'Cambiar contraseña',
3749
warning: 'Cambiar tu contraseña cerrará tu sesión en los demás dispositivos.',

assets/js/pages/Settings.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useForm } from '@inertiajs/react';
1+
import { useForm, usePage } from '@inertiajs/react';
22
import { useState } from 'react';
33
import { useTranslation } from 'react-i18next';
44
import {
@@ -22,13 +22,77 @@ export default function Settings() {
2222
<AppLayout title={t('settings.title')}>
2323
<div className="max-w-xl space-y-12">
2424
<h1 className="text-2xl font-semibold">{t('settings.title')}</h1>
25+
<ChangeEmailSection />
2526
<ChangePasswordSection />
2627
<DeleteAccountSection />
2728
</div>
2829
</AppLayout>
2930
);
3031
}
3132

33+
function ChangeEmailSection() {
34+
const { t } = useTranslation();
35+
const currentEmail = usePage().props.current_user?.email ?? '';
36+
const { data, setData, put, processing, errors, reset } = useForm({
37+
current_password: '',
38+
email: '',
39+
});
40+
41+
return (
42+
<section className="space-y-4">
43+
<header>
44+
<h2 className="text-lg font-medium">{t('settings.changeEmail.title')}</h2>
45+
<p className="text-sm text-gray-600 dark:text-gray-400">
46+
{t('settings.changeEmail.current', { email: currentEmail })}
47+
</p>
48+
</header>
49+
<form
50+
onSubmit={(e) => {
51+
e.preventDefault();
52+
put('/settings/email', { onSuccess: () => reset() });
53+
}}
54+
className="space-y-4"
55+
>
56+
<div>
57+
<label htmlFor="new_email" className="block text-sm mb-1">
58+
{t('settings.changeEmail.newEmail')}
59+
</label>
60+
<input
61+
id="new_email"
62+
type="email"
63+
autoComplete="email"
64+
value={data.email}
65+
onChange={(e) => setData('email', e.target.value)}
66+
className={inputClass}
67+
required
68+
/>
69+
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email}</p>}
70+
</div>
71+
72+
<div>
73+
<label htmlFor="email_current_password" className="block text-sm mb-1">
74+
{t('settings.changeEmail.currentPassword')}
75+
</label>
76+
<input
77+
id="email_current_password"
78+
type="password"
79+
autoComplete="current-password"
80+
value={data.current_password}
81+
onChange={(e) => setData('current_password', e.target.value)}
82+
className={inputClass}
83+
required
84+
/>
85+
{errors.current_password && <p className="mt-1 text-sm text-red-600">{errors.current_password}</p>}
86+
</div>
87+
88+
<Button type="submit" disabled={processing}>
89+
{t('settings.changeEmail.submit')}
90+
</Button>
91+
</form>
92+
</section>
93+
);
94+
}
95+
3296
function ChangePasswordSection() {
3397
const { t } = useTranslation();
3498
const { data, setData, put, processing, errors, reset } = useForm({

config/prod.exs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ config :elixir_react_starter, ElixirReactStarterWeb.Endpoint,
1313
# cookie must never travel over plain HTTP.
1414
config :elixir_react_starter, session_secure: true
1515

16+
# Strict Content-Security-Policy in production: nonce + strict-dynamic
17+
# for scripts, no framing, first-party only. Add third-party origins via
18+
# :csp_extra_sources rather than loosening this. Dev/test default to the
19+
# relaxed profile so LiveReload / LiveDashboard keep working.
20+
config :elixir_react_starter, content_security_policy: :strict
21+
1622
# Force using SSL in production. This also sets the "strict-security-transport" header,
1723
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
1824
# Note `:force_ssl` is required to be set at compile-time.

config/runtime.exs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,38 @@ if config_env() == :prod do
3232

3333
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
3434

35+
database_url = get_env!.("DATABASE_URL")
36+
37+
# Encrypt the DB connection by default. Production traffic to a managed
38+
# Postgres almost always crosses a network boundary, so TLS is on
39+
# unless explicitly disabled with DATABASE_SSL=false (e.g. a sidecar
40+
# proxy that already encrypts, or local-socket access).
41+
#
42+
# We verify the server certificate against the OS trust store and pin
43+
# the hostname (SNI) parsed from DATABASE_URL — so a MITM with a valid
44+
# cert for some *other* host can't intercept. Providers whose cert
45+
# chains aren't in the system store (some managed PG offer a private
46+
# CA) need either their CA added to the OS trust store or DATABASE_SSL=false.
47+
db_ssl =
48+
if System.get_env("DATABASE_SSL", "true") in ~w(true 1) do
49+
db_host = URI.parse(database_url).host || ""
50+
51+
[
52+
verify: :verify_peer,
53+
cacerts: :public_key.cacerts_get(),
54+
server_name_indication: to_charlist(db_host),
55+
depth: 3,
56+
# Refuse to connect if the chain can't be verified, rather than
57+
# silently downgrading to an unauthenticated session.
58+
customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)]
59+
]
60+
else
61+
false
62+
end
63+
3564
config :elixir_react_starter, ElixirReactStarter.Repo,
36-
# ssl: true,
37-
url: get_env!.("DATABASE_URL"),
65+
ssl: db_ssl,
66+
url: database_url,
3867
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
3968
# For machines with several cores, consider starting multiple pools of `pool_size`
4069
# pool_count: 4,

lib/elixir_react_starter/accounts.ex

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ defmodule ElixirReactStarter.Accounts do
1010
1111
Generated CRUD helpers come from `use ElixirReactStarter.Context`. The custom
1212
functions here cover registration, the session lifecycle, email
13-
confirmation, password reset, authenticated password change, and
14-
account deletion (the last two re-verify the current password).
13+
confirmation, password reset, the link-confirmed email change, the
14+
authenticated password change, and account deletion (the last three
15+
re-verify the current password).
1516
1617
The `User` schema is deliberately minimal — email, hashed password,
1718
locale, confirmed_at. Add profile fields (name, avatar, …) per project.
@@ -160,6 +161,98 @@ defmodule ElixirReactStarter.Accounts do
160161
|> log_result("Email confirmed for user #{user.id}")
161162
end
162163

164+
# =============================================================================
165+
# Email change (authenticated, link-confirmed)
166+
# =============================================================================
167+
@doc """
168+
Starts an email change after re-verifying the current password.
169+
170+
Validates the new address (format, length, not unchanged, not already
171+
taken) and, on success, issues a single-use `email_change` token whose
172+
`sent_to` holds the pending address. Returns `{:ok, raw_token}` — the
173+
caller mails the confirmation link to the *new* address and notifies
174+
the *old* one. The user's email isn't touched until they click the
175+
link (`apply_email_change/2`).
176+
177+
Returns `{:error, :invalid_password}` or `{:error, changeset}`.
178+
"""
179+
def request_email_change(user, current_password, new_email) do
180+
if User.valid_password?(user, current_password) do
181+
user
182+
|> User.email_changeset(%{email: new_email})
183+
|> validate_email_change()
184+
|> case do
185+
%{valid?: true} ->
186+
Repo.delete_all(UserToken.delete_user_tokens_by_context_query(user.id, "email_change"))
187+
{raw_token, user_token} = UserToken.build_email_change_token(user, new_email)
188+
Repo.insert!(user_token)
189+
Logger.info("Email change requested for user #{user.id}")
190+
{:ok, raw_token}
191+
192+
changeset ->
193+
{:error, %{changeset | action: :update}}
194+
end
195+
else
196+
Logger.warning("Failed email change attempt for user #{user.id}: incorrect password")
197+
{:error, :invalid_password}
198+
end
199+
end
200+
201+
@doc """
202+
Applies a pending email change. Verifies the `email_change` token
203+
belongs to `user` and hasn't expired, then swaps in the address it
204+
was issued for. The DB unique index is the final guard against the
205+
address being claimed between request and click (TOCTOU). Consumes
206+
every email-change token for the user on success.
207+
208+
Returns `{:ok, user}`, `{:error, :invalid_token}`, or
209+
`{:error, changeset}`.
210+
"""
211+
def apply_email_change(user, raw_token) do
212+
query = UserToken.verify_email_change_token_query(raw_token, user.id)
213+
214+
case Repo.one(query) do
215+
%UserToken{sent_to: new_email} ->
216+
user
217+
|> User.email_changeset(%{email: new_email})
218+
|> Repo.update()
219+
|> case do
220+
{:ok, updated} ->
221+
Repo.delete_all(
222+
UserToken.delete_user_tokens_by_context_query(user.id, "email_change")
223+
)
224+
225+
Logger.info("Email changed for user #{user.id}")
226+
{:ok, updated}
227+
228+
{:error, _changeset} = error ->
229+
error
230+
end
231+
232+
nil ->
233+
{:error, :invalid_token}
234+
end
235+
end
236+
237+
# Layers the checks `User.email_changeset/2` can't express on its own:
238+
# the address must actually differ and must not already belong to
239+
# another account.
240+
defp validate_email_change(changeset) do
241+
cond do
242+
not changeset.valid? ->
243+
changeset
244+
245+
Ecto.Changeset.get_change(changeset, :email) == nil ->
246+
Ecto.Changeset.add_error(changeset, :email, "is the same as your current email")
247+
248+
get_user_by(email: Ecto.Changeset.get_change(changeset, :email)) ->
249+
Ecto.Changeset.add_error(changeset, :email, "has already been taken")
250+
251+
true ->
252+
changeset
253+
end
254+
end
255+
163256
# =============================================================================
164257
# Password change (authenticated)
165258
# =============================================================================

0 commit comments

Comments
 (0)