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
7 changes: 4 additions & 3 deletions e2e/payouts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,10 @@ async function bypassClientGuard(input: Locator, value: string): Promise<void> {
* percentage that copy is a lie, and the form's contents went with it. Same
* conversion `requireAdminAction` already went through (see e2e/admin.spec.ts).
*
* A code with no entry in either ERRORS map renders nothing at all, which is
* the one failure these pages cannot show the operator, so each is checked by
* name. `p.notice--bad`, never getByRole("alert"): arriving here from a server
* A code with no entry in either error map (NEW_OPERATION_ERRORS /
* OPERATION_ERRORS) renders nothing at all, which is the one failure these
* pages cannot show the operator, so each is checked by name. `p.notice--bad`,
* never getByRole("alert"): arriving here from a server
* action is a soft navigation, so Next's route announcer is populated and also
* carries role="alert".
*/
Expand Down
7 changes: 4 additions & 3 deletions src/app/account/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { cookies } from "next/headers";
import { getConfig } from "@/config";
import { getDb } from "@/db";
import { character } from "@/db/schema";
import { accountErrorUrl, loginErrorUrl } from "@/lib/error-redirects";
import { setMainCharacter, unlinkCharacter, wakeSelf } from "@/services/accounts";
import { getSessionAccount } from "@/services/session";

Expand All @@ -18,7 +19,7 @@ async function requireAccount(): Promise<string> {
// while this page is still open — the exact "alt-tabbed at 1am" session
// PRODUCT.md describes. That is an expected end state, not a bug: send the
// member back to sign in instead of throwing to the error boundary for it.
if (!sess) redirect("/login?error=session_expired");
if (!sess) redirect(loginErrorUrl("session_expired"));
return sess.accountId;
}

Expand All @@ -32,7 +33,7 @@ export async function setMainAction(characterId: number): Promise<void> {
// reclaim (background token-health job) can pull this character off the
// account first. That is a race the member just needs a fresh render for,
// not a bug worth an error boundary.
redirect("/account?error=stale_character");
redirect(accountErrorUrl("stale_character"));
}
revalidatePath("/account");
}
Expand All @@ -50,7 +51,7 @@ export async function unlinkAction(characterId: number): Promise<void> {
.select()
.from(character)
.where(and(eq(character.id, characterId), eq(character.accountId, accountId)));
if (owned.length === 0) redirect("/account?error=stale_character");
if (owned.length === 0) redirect(accountErrorUrl("stale_character"));
await db.transaction(async (dbtx) => {
// A last_character / not_owned rejection is a silent no-op here: the page
// hides the unlink control for the final character, and a reclaim race
Expand Down
30 changes: 7 additions & 23 deletions src/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getConfig } from "@/config";
import { getDb } from "@/db";
import { ACCOUNT_ERRORS, loginErrorUrl, lookupErrorMessage } from "@/lib/error-redirects";
import { getAccountView, type PushStatus } from "@/services/account-view";
import { canReadPayouts } from "@/services/payouts";
import { listAccountPayouts } from "@/services/payout-view";
Expand Down Expand Up @@ -42,27 +43,10 @@ export const metadata: Metadata = {
title: "Your account",
};

// Every code here is emitted by a callback route redirect. The distinction the
// copy has to carry is "retry works" (expired/failed) versus "retrying will do
// the same thing" (already_linked). Sign-in links expire 10 minutes after you
// start them (src/services/oauth-tx.ts).
const ERRORS: Record<string, string> = {
already_linked:
"That character belongs to an account with its own history, so it can't be merged automatically. Ask an admin.",
discord_already_linked: "That Discord account is already linked to another account.",
discord_denied: "Discord authorization was cancelled.",
discord_expired:
"That Discord link expired before it finished. Nothing changed. Start it again below.",
discord_failed:
"Discord couldn't be reached, so the link didn't finish. Nothing changed. Try again.",
link_expired:
"That character link expired before it finished. Nothing changed. Start it again below.",
link_failed:
"EVE couldn't be reached, so the character didn't finish linking. Nothing changed. Try again.",
stale_character:
"That character isn't on this account anymore. The page below is current.",
not_admin: "Your admin access was removed. This is your account page.",
};
// Every code this page renders lives in src/lib/error-redirects.ts, beside the
// builder its producers go through. Nearly all are emitted by a callback route
// redirect; the distinction the copy has to carry is "retry works"
// (expired/failed) versus "retrying will do the same thing" (already_linked).

/**
* The id the CONTACTS column header points `aria-describedby` at. The note is
Expand Down Expand Up @@ -116,10 +100,10 @@ export default async function AccountPage({
// cookie at all is a first-time visitor, who must not be told a session they
// never had has ended. `resolveAdmin` draws the same line for the admin
// pages.
if (!sess) redirect(sid ? "/login?error=session_expired" : "/login");
if (!sess) redirect(sid ? loginErrorUrl("session_expired") : "/login");
const view = await getAccountView(getDb(), cfg, sess.accountId);
const { error } = await searchParams;
const message = error ? ERRORS[error] : undefined;
const message = lookupErrorMessage(ACCOUNT_ERRORS, error);
const now = Date.now();
// Same tier-only gate the payouts pages themselves re-check — this only
// decides whether the link appears, never whether the route is reachable.
Expand Down
18 changes: 12 additions & 6 deletions src/app/admin/accounts/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getDb } from "@/db";
import { requireAdminAction } from "@/lib/admin-guard";
import { adminAccountsErrorUrl } from "@/lib/error-redirects";
import { demoteAdmin, promoteAdmin } from "@/services/accounts";
import {
approveAccount,
Expand All @@ -21,7 +22,7 @@ import { enqueueSync } from "@/services/outbox";
// Redirect to the styled notice rather than throw, same as demoteAdminAction's
// `last_admin` case below.
function redirectNotAdmin(): never {
redirect("/admin/accounts?error=not_admin");
redirect(adminAccountsErrorUrl("not_admin"));
}

/**
Expand All @@ -46,6 +47,13 @@ function redirectNotAdmin(): never {
* the unfiltered list: only approveAction's callers were looking at that
* filter when they clicked. `not_pending` always goes there regardless of the
* flag, since it can only ever be produced by approveAccount.
*
* Exhaustive on a SECOND axis since the destination URLs moved behind
* `adminAccountsErrorUrl`: the `?error=` codes below are `keyof
* ADMIN_ACCOUNTS_ERRORS`, so a code with no entry in the page's map — which
* would redirect and then render nothing at all — fails typecheck here too.
* The two axes are independent: the service union says which failures exist,
* the code union says which ones the page can explain.
*/
function redirectOnMutationError(
error: "not_authorized" | "not_found" | "not_pending",
Expand All @@ -57,12 +65,10 @@ function redirectOnMutationError(
case "not_pending":
// Two admins working the queue, or one with a stale tab: the account is
// approved, just not by them.
return redirect("/admin/accounts?tier=pending&error=not_pending");
return redirect(adminAccountsErrorUrl("not_pending", { tier: "pending" }));
case "not_found":
return redirect(
opts.fromQueue
? "/admin/accounts?tier=pending&error=not_found"
: "/admin/accounts?error=not_found",
adminAccountsErrorUrl("not_found", opts.fromQueue ? { tier: "pending" } : {}),
);
}
}
Expand Down Expand Up @@ -181,7 +187,7 @@ export async function demoteAdminAction(accountId: string): Promise<void> {
const result = await getDb().transaction((tx) => demoteAdmin(tx, actor, accountId));
if (!result.ok && result.error === "last_admin") {
// Surface the service's protection instead of a 500 (carry-over).
redirect("/admin/accounts?error=last_admin");
redirect(adminAccountsErrorUrl("last_admin"));
}
if (!result.ok && result.error === "not_authorized") redirectNotAdmin();
if (!result.ok) throw new Error(result.error);
Expand Down
22 changes: 7 additions & 15 deletions src/app/admin/accounts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { getConfig, type Config } from "@/config";
import { getDb } from "@/db";
import { requireAdminPage } from "@/lib/admin-guard";
import { ADMIN_ACCOUNTS_ERRORS, lookupErrorMessage } from "@/lib/error-redirects";
import { isContactsTarget } from "@/services/desired";
import {
countAccountsByTier,
Expand Down Expand Up @@ -54,18 +55,10 @@ const TIERS = ["flygd", "blue", "green"] as const;
// have to be findable. Drives the ?tier= whitelist and the filter chips only.
const TIER_FILTERS = ["pending", ...TIERS] as const;

const ERRORS: Record<string, string> = {
last_admin: "Cannot demote the last admin.",
not_admin:
"Your admin access changed since this page loaded. Refresh to see the current state.",
not_pending:
"That account was already approved by someone else. Refresh to see its current tier.",
// Shared by every admin mutation, not just approval (actions.ts): the merge
// feature can delete the row an admin's control targeted between page
// render and click, regardless of which action they clicked.
not_found:
"That account is gone: its character was linked to another account and merged in. There's nothing left to act on.",
};
// Every code this page renders lives in src/lib/error-redirects.ts, beside the
// builder actions.ts and admin-guard.ts go through. All of them are races
// between two legitimate admins rather than faults, which is why they are
// notices on a refreshed list and not error-boundary throws.

// The columns after the sortable ones, in render order. A list rather than a
// count because three separate things depend on the table's width — the
Expand Down Expand Up @@ -125,6 +118,7 @@ export default async function AdminAccountsPage({
// call assembles a full row (five unbounded scans plus per-character work)
// for every account just to get thrown away for a length.
const pendingCount = await countAccountsByTier(getDb(), "pending");
const errorMessage = lookupErrorMessage(ADMIN_ACCOUNTS_ERRORS, params.error);

const qs = (over: Record<string, string | undefined>) => {
const p = new URLSearchParams();
Expand All @@ -145,9 +139,7 @@ export default async function AdminAccountsPage({
</p>
</div>

{params.error && ERRORS[params.error] && (
<Notice tone="bad">{ERRORS[params.error]}</Notice>
)}
{errorMessage && <Notice tone="bad">{errorMessage}</Notice>}

{params.queued === "account" && (
<Notice>Sync queued. The worker picks it up within a few seconds.</Notice>
Expand Down
15 changes: 9 additions & 6 deletions src/app/auth/discord/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getConfig } from "@/config";
import { getDb } from "@/db";
import { exchangeDiscordCode, fetchDiscordUser } from "@/lib/discord/oauth";
import { accountErrorUrl, loginErrorUrl } from "@/lib/error-redirects";
import { getRequestAccount } from "@/lib/request-session";
import { DiscordLinkConflictError, linkDiscord } from "@/services/discord-link";
import { consumeOauthTransaction } from "@/services/oauth-tx";
Expand All @@ -12,21 +13,23 @@ export async function GET(req: NextRequest) {
const to = (path: string) => NextResponse.redirect(new URL(path, cfg.appBaseUrl));

// Provider denial (user declined the authorization): error param, no code
if (req.nextUrl.searchParams.get("error")) return to("/account?error=discord_denied");
if (req.nextUrl.searchParams.get("error")) return to(accountErrorUrl("discord_denied"));

const code = req.nextUrl.searchParams.get("code");
const state = req.nextUrl.searchParams.get("state");
// Only reachable from the account page, so /account is right for every
// failure here except a missing session.
if (!code || !state) return to("/account?error=discord_failed");
if (!code || !state) return to(accountErrorUrl("discord_failed"));

const tx = await consumeOauthTransaction(db, state, ["link-discord"]);
if (!tx) return to("/account?error=discord_expired");
if (!tx) return to(accountErrorUrl("discord_expired"));

const sess = await getRequestAccount(req);
if (!sess || sess.sessionId !== tx.sessionId || sess.accountId !== tx.accountId) {
// The transaction is consumed above, so neither destination can be replayed.
return to(sess ? "/account?error=discord_expired" : "/login?error=session_expired");
return to(
sess ? accountErrorUrl("discord_expired") : loginErrorUrl("session_expired"),
);
}

// Route handlers are not covered by app/error.tsx, so an uncaught throw from
Expand All @@ -44,12 +47,12 @@ export async function GET(req: NextRequest) {
if (err instanceof DiscordLinkConflictError) ok = false;
else throw err;
}
return to(ok ? "/account" : "/account?error=discord_already_linked");
return to(ok ? "/account" : accountErrorUrl("discord_already_linked"));
} catch (err) {
// Message only, for the reason spelled out in the EVE callback: a Postgres
// error object can carry the failing query and its parameters alongside the
// message, and those rows hold token material.
console.error("discord callback failed", err instanceof Error ? err.message : err);
return to("/account?error=discord_failed");
return to(accountErrorUrl("discord_failed"));
}
}
15 changes: 8 additions & 7 deletions src/app/auth/eve/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getConfig } from "@/config";
import { getDb } from "@/db";
import { exchangeEveCode, verifyEveAccessToken } from "@/lib/esi/sso";
import { accountErrorUrl, loginErrorUrl } from "@/lib/error-redirects";
import { getRequestAccount } from "@/lib/request-session";
import { sessionCookieAttrs } from "@/lib/session-cookie";
import {
Expand All @@ -18,19 +19,19 @@ export async function GET(req: NextRequest) {
const to = (path: string) => NextResponse.redirect(new URL(path, cfg.appBaseUrl));

// Provider denial (e.g. user clicked "cancel"): no code arrives, just error=
if (req.nextUrl.searchParams.get("error")) return to("/login?error=oauth_denied");
if (req.nextUrl.searchParams.get("error")) return to(loginErrorUrl("oauth_denied"));

const code = req.nextUrl.searchParams.get("code");
const state = req.nextUrl.searchParams.get("state");
// Without state there is no transaction, so nothing tells us whether this was
// a login or a character link. /login is the only destination we can be sure
// is correct for either.
if (!code || !state) return to("/login?error=oauth_failed");
if (!code || !state) return to(loginErrorUrl("oauth_failed"));

// Only EVE intents are consumable here; a link-discord transaction is
// rejected WITHOUT being consumed. All binding checks run before any EVE call.
const tx = await consumeOauthTransaction(db, state, ["login", "link-character"]);
if (!tx) return to("/login?error=oauth_expired");
if (!tx) return to(loginErrorUrl("oauth_expired"));

const sess = await getRequestAccount(req);
if (
Expand All @@ -41,7 +42,7 @@ export async function GET(req: NextRequest) {
// replayed. Signed in but holding someone else's (or a stale) transaction
// means retrying from the account page; no session at all means the session
// is the thing that's missing.
return to(sess ? "/account?error=link_expired" : "/login?error=session_expired");
return to(sess ? accountErrorUrl("link_expired") : loginErrorUrl("session_expired"));
}

// Everything past here talks to EVE or the database, and route handlers are
Expand All @@ -62,7 +63,7 @@ export async function GET(req: NextRequest) {
const result = await db.transaction((dbtx) =>
linkCharacter(dbtx, cfg, sess!.accountId, ch),
);
return to(result.ok ? "/account" : "/account?error=already_linked");
return to(result.ok ? "/account" : accountErrorUrl("already_linked"));
}

const { accountId } = await db.transaction((dbtx) => handleEveLogin(dbtx, cfg, ch));
Expand All @@ -82,8 +83,8 @@ export async function GET(req: NextRequest) {
console.error("eve callback failed", err instanceof Error ? err.message : err);
return to(
tx.intent === "link-character"
? "/account?error=link_failed"
: "/login?error=oauth_failed",
? accountErrorUrl("link_failed")
: loginErrorUrl("oauth_failed"),
);
}
}
16 changes: 5 additions & 11 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Notice } from "@/app/_components/ui";
import { getConfig } from "@/config";
import { LOGIN_ERRORS, lookupErrorMessage } from "@/lib/error-redirects";

// `await searchParams` below already forces dynamic rendering, so the
// getConfig() call cannot run at build time today. Declared anyway, matching
Expand All @@ -15,31 +16,24 @@

// Codes reaching this page: oauth_denied / oauth_expired / oauth_failed from
// the EVE callback, session_expired from account/actions.ts, account/page.tsx,
// and either callback when the session is gone mid-link. Sign-in links expire
// 10 minutes after you start them (src/services/oauth-tx.ts).
const ERRORS: Record<string, string> = {
oauth_denied: "Nothing changed. No access was granted. Sign in whenever you're ready.",
oauth_expired:
"That sign-in link expired before you finished. They last 10 minutes. Start again below.",
oauth_failed:
"EVE couldn't be reached, so sign-in didn't finish. Nothing changed. Try again.",
session_expired: "Your session ended. Sign in again to pick up where you left off.",
};
// admin-guard.ts, and either callback when the session is gone mid-link. The
// map itself lives in src/lib/error-redirects.ts, beside the builder every one
// of those producers now goes through — see that file for why.

export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const { error } = await searchParams;
const message = error ? ERRORS[error] : undefined;
const message = lookupErrorMessage(LOGIN_ERRORS, error);
const cfg = getConfig();
const scopes = cfg.eveSso.scopes;
const label = cfg.standings.label;
return (
<main className="launch">
<div className="launch__panel">
<img

Check warning on line 36 in src/app/login/page.tsx

View workflow job for this annotation

GitHub Actions / Typecheck, lint & format

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
className="launch__seal"
src="/brand/seal.webp"
alt="Zoo Landers mission seal"
Expand Down Expand Up @@ -78,7 +72,7 @@
)}
</div>
<a className="launch__action" href="/auth/eve/login">
<img

Check warning on line 75 in src/app/login/page.tsx

View workflow job for this annotation

GitHub Actions / Typecheck, lint & format

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src="https://web.ccpgamescdn.com/eveonlineassets/developers/eve-sso-login-black-large.png"
alt="Log in with EVE Online"
width={270}
Expand Down
6 changes: 3 additions & 3 deletions src/app/payouts/dropped.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ export function encodeDropped(dropped: DroppedLootLine[]): string {

/**
* Null for anything this page cannot faithfully render — an absent param, a
* hand-typed one, a truncated one. Same rule the `ERRORS` map follows for an
* unrecognized `?error=` code: degrade to the plain page, never to an empty
* or half-filled notice.
* hand-typed one, a truncated one. Same rule the `OPERATION_ERRORS` map follows
* for an unrecognized `?error=` code: degrade to the plain page, never to an
* empty or half-filled notice.
*/
export function decodeDropped(raw: string | undefined): DroppedReport | null {
if (!raw) return null;
Expand Down
Loading
Loading