mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
Merge pull request #40 from Dvorinka/fix/signup-bounce
fix(auth): sign-up bounce — surface errors, dynamic trusted origins, unbreakable mail probe
This commit is contained in:
4 files changed
+50
-9
No files matched your search
@@ -4,6 +4,12 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Self-hosted sign-up works on any origin** — trusted origins are now derived from the request's public host (forwarded by nginx), so browsing the instance via a LAN IP, hostname, or custom port no longer fails every auth POST with a 403. Static `VITE_APP_URL`/`AUTH_BASE_URL` origins and `BETTER_AUTH_TRUSTED_ORIGINS` still apply.
|
||||
- **Sign-up/sign-in failures are surfaced, not swallowed** — better-auth resolves `{ data, error }` instead of throwing, so a failed request previously showed "Account created — signing you in" and bounced back to the bootstrap screen. The real server error (invalid origin, rate limit, disabled sign-up) is now displayed.
|
||||
- **Mail probe can no longer break auth** — `hasMailTransport` degrades to "no transport" instead of throwing when `core.settings` is unreadable, and the no-transport startup backfill is best-effort. Previously, auth could crash-loop while the backend was still migrating on a fresh volume — every sign-up 502'd during the window.
|
||||
|
||||
## [0.8.1] - 2026-09-28
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -53,7 +53,19 @@ export const auth = betterAuth({
|
||||
appName: "Primora",
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
trustedOrigins: [env.VITE_APP_URL, env.AUTH_BASE_URL],
|
||||
// Self-host: the browser reaches /auth through the same nginx origin as the
|
||||
// app, so whatever host actually served this request is trusted — static
|
||||
// localhost defaults alone 403 every sign-up on LAN IPs or custom ports.
|
||||
// An attacker's cross-site Origin still won't match the server host.
|
||||
trustedOrigins: (request) => {
|
||||
const origins = [env.VITE_APP_URL, env.AUTH_BASE_URL];
|
||||
const host = request?.headers.get("x-forwarded-host") ?? request?.headers.get("host");
|
||||
if (host) {
|
||||
const proto = request?.headers.get("x-forwarded-proto") ?? "http";
|
||||
origins.push(`${proto}://${host}`);
|
||||
}
|
||||
return origins;
|
||||
},
|
||||
database: authPool,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
@@ -155,10 +167,15 @@ export async function runAuthMigrations() {
|
||||
await migrations.runMigrations();
|
||||
}
|
||||
|
||||
/** No transport → existing unverified users can never resolve that state. */
|
||||
/** No transport → existing unverified users can never resolve that state.
|
||||
* Best-effort: a startup failure here must never take the service down. */
|
||||
export async function verifyUsersWithoutMailTransport() {
|
||||
if (await hasMailTransport()) return;
|
||||
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`);
|
||||
try {
|
||||
if (await hasMailTransport()) return;
|
||||
await authPool.query(`update "user" set "emailVerified" = true where "emailVerified" = false`);
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({ level: "warn", msg: "verify_without_transport_failed", error }));
|
||||
}
|
||||
}
|
||||
|
||||
/** Bootstrap admins by email — run after migrations so the role column exists. */
|
||||
|
||||
@@ -32,10 +32,17 @@ async function resolveMailConfig() {
|
||||
|
||||
// True when a mail transport exists (Resend key or SMTP host). Verification
|
||||
// and reset mails only make sense when mail can actually leave the box.
|
||||
// Mail is optional — a failed settings read must degrade to "no transport",
|
||||
// never break sign-up.
|
||||
export async function hasMailTransport(): Promise<boolean> {
|
||||
const cfg = await resolveMailConfig();
|
||||
if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
|
||||
return "smtp" in cfg && Boolean(cfg.smtp);
|
||||
try {
|
||||
const cfg = await resolveMailConfig();
|
||||
if ("resendApiKey" in cfg && cfg.resendApiKey) return true;
|
||||
return "smtp" in cfg && Boolean(cfg.smtp);
|
||||
} catch (error) {
|
||||
console.warn(JSON.stringify({ level: "warn", msg: "mail_transport_probe_failed", error }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTransactionalEmail(input: {
|
||||
|
||||
@@ -735,11 +735,22 @@ export default function App() {
|
||||
await runPending("auth-submit", async () => {
|
||||
setAuthMessage("");
|
||||
try {
|
||||
// better-auth client resolves { data, error } instead of throwing on
|
||||
// HTTP failures — surface res.error or the UI claims success while
|
||||
// nothing was created.
|
||||
if (mode() === "sign-up") {
|
||||
await authClient.signUp.email({ email: email(), password: password(), name: name() });
|
||||
const res = await authClient.signUp.email({ email: email(), password: password(), name: name() });
|
||||
if (res.error) {
|
||||
setAuthMessage(res.error.message ?? "Sign-up failed");
|
||||
return;
|
||||
}
|
||||
setAuthMessage("Account created — signing you in.");
|
||||
} else {
|
||||
await authClient.signIn.email({ email: email(), password: password() });
|
||||
const res = await authClient.signIn.email({ email: email(), password: password() });
|
||||
if (res.error) {
|
||||
setAuthMessage(res.error.message ?? "Sign-in failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setAuthMessage(getErrorMessage(error, "Authentication failed"));
|
||||
|
||||
Reference in new issue
Block a user