From 45d58e981058d600468c405bcc8897ac30c8e5e3 Mon Sep 17 00:00:00 2001 From: Mahdi Khanzadi Date: Sun, 2 Aug 2026 13:21:44 +0200 Subject: [PATCH] Feat: block users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the admin control for banning an account and makes the whole app react when the API refuses a banned session. - A "banned" switch on the user edit form. It only appears for an existing user, since banning is an action on an account rather than part of creating one, and it sends the intent only — the API keeps the date. When the user is already banned the switch says since when - The API answers 403 with {"code": "user_banned", "message": "..."} for a banned session. Both DAL drivers recognise it and force a sign-out instead of showing the usual permission warning: the server driver via a ServerBannedInterceptor, the client driver via a redirect to a new /api/auth/banned route that clears the session and lands on the login page with the explanation - The login form says the account is suspended rather than letting the refusal read as a wrong password - catch blocks that swallowed failures now call unstable_rethrow first. Next's redirect() travels as a throw, so without this the forced sign-out would be caught and discarded by the very code it passes through Co-Authored-By: Claude Opus 5 --- src/app/(auth)/[lang]/auth/login/page.tsx | 14 ++- .../(dashboard)/dashboard/settings/page.tsx | 7 +- .../dashboard/users/[id]/(index)/page.tsx | 9 ++ src/app/api/auth/banned/route.ts | 30 +++++++ src/components/query-client-provider.tsx | 8 ++ src/dal/client/client-dal-driver.ts | 2 + src/dal/private/private-dal-driver.ts | 4 + src/dal/private/users.ts | 8 +- .../articles/actions/delete-article.ts | 4 +- src/features/auth/actions/login.ts | 13 +++ src/features/auth/components/login-form.tsx | 36 ++++++-- .../elements/actions/delete-element.ts | 4 +- .../languages/actions/delete-language.ts | 4 +- src/features/roles/actions/delete-role.ts | 4 +- src/features/users/actions/delete-user.ts | 4 +- src/features/users/actions/upsert-user.ts | 16 +++- .../upsert-user-form/upsert-user-form.tsx | 28 ++++++ src/i18n/dictionaries/en.json | 7 +- src/i18n/dictionaries/fa.json | 7 +- src/lib/api/banned.ts | 88 +++++++++++++++++++ src/lib/api/validation-errors.ts | 12 +++ src/lib/auth/dal/clientBanRedirect.ts | 23 +++++ .../server/ServerBannedInterceptor.ts | 27 ++++++ 23 files changed, 337 insertions(+), 22 deletions(-) create mode 100644 src/app/api/auth/banned/route.ts create mode 100644 src/lib/api/banned.ts create mode 100644 src/lib/auth/dal/clientBanRedirect.ts create mode 100644 src/lib/auth/interception/interceptors/server/ServerBannedInterceptor.ts diff --git a/src/app/(auth)/[lang]/auth/login/page.tsx b/src/app/(auth)/[lang]/auth/login/page.tsx index 81b1f034..17daafe3 100644 --- a/src/app/(auth)/[lang]/auth/login/page.tsx +++ b/src/app/(auth)/[lang]/auth/login/page.tsx @@ -1,6 +1,7 @@ import {type Metadata} from "next"; import {LoginForm} from "@/features/auth/components/login-form"; import {getDictionary} from "@/i18n/dictionary"; +import {BAN_NOTICE_PARAM, BAN_REASON, BAN_REASON_PARAM} from "@/lib/api/banned"; export async function generateMetadata(props: { params: Promise<{lang: string}>; @@ -15,13 +16,24 @@ export async function generateMetadata(props: { type Props = { searchParams: Promise<{ callbackUrl?: string; + [BAN_REASON_PARAM]?: string; + [BAN_NOTICE_PARAM]?: string; }>; }; async function LoginPage(props: Props) { const searchParams = await props.searchParams; const callbackUrl = searchParams.callbackUrl; - return ; + // Set by the sign-out route after the API refused a banned account. + const banned = searchParams[BAN_REASON_PARAM] === BAN_REASON; + + return ( + + ); } export default LoginPage; diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index 8a0d6829..93e88627 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -1,4 +1,5 @@ import {Metadata} from "next"; +import {unstable_rethrow} from "next/navigation"; import {Stack, Paper} from "@mantine/core"; import {DashboardBreadcrumbs} from "@/features/breadcrumbs/components/breadcrumbs"; import {AppSettingForm} from "@/features/settings/components/app-setting-form"; @@ -25,8 +26,10 @@ async function SettingsPage() { let languages: Language[] = []; try { languages = (await fetchLanguages()).items ?? []; - } catch { - // Fail open: the default-language select renders empty if unavailable. + } catch (error) { + // Fail open: the default-language select renders empty if unavailable — + // but not for a signed-out or banned session, which has to travel. + unstable_rethrow(error); } return ( diff --git a/src/app/(dashboard)/dashboard/users/[id]/(index)/page.tsx b/src/app/(dashboard)/dashboard/users/[id]/(index)/page.tsx index 5c79379f..3b9c07c0 100644 --- a/src/app/(dashboard)/dashboard/users/[id]/(index)/page.tsx +++ b/src/app/(dashboard)/dashboard/users/[id]/(index)/page.tsx @@ -7,6 +7,7 @@ import {withPermissions} from "@/components/with-authorization"; import {fetchUser} from "@/dal/private/users"; import {fetchLanguages, type Language} from "@/dal/public/languages"; import {APP_PATHS} from "@/lib/app-paths"; +import {isGregorianStartDateTime} from "@/lib/date-and-time"; import {getServerDictionary} from "@/i18n/server"; export async function generateMetadata(): Promise { @@ -30,6 +31,12 @@ async function UpdateUserPage({params}: Props) { } const userData = await fetchUser(userId); + // The API sends Go's zero time for users who were never banned. + const bannedAt = + userData.banned_at && !isGregorianStartDateTime(userData.banned_at) + ? userData.banned_at + : ""; + let languages: Language[] = []; let defaultLanguageCode = ""; try { @@ -62,6 +69,8 @@ async function UpdateUserPage({params}: Props) { defaultEmail: userData.email, defaultUsername: userData.username, defaultLanguageCode: userData.language_code ?? defaultLanguageCode, + defaultBanned: userData.banned === true, + defaultBannedAt: bannedAt, }} languages={languages} /> diff --git a/src/app/api/auth/banned/route.ts b/src/app/api/auth/banned/route.ts new file mode 100644 index 00000000..b6991340 --- /dev/null +++ b/src/app/api/auth/banned/route.ts @@ -0,0 +1,30 @@ +import {NextRequest, NextResponse} from "next/server"; +import {ACCESS_TOKEN_COOKIE_NAME, REFRESH_TOKEN_COOKIE_NAME} from "@/constants"; +import {BAN_NOTICE_PARAM, BAN_REASON, BAN_REASON_PARAM} from "@/lib/api/banned"; +import {APP_PATHS} from "@/lib/app-paths"; + +// Ends the session of a user the API has told us is banned. Cookies can't be +// written while a page renders, which is where a ban is usually discovered, so +// the DAL interceptors send the user here instead: one place that clears the +// tokens and forwards the API's message to the login page. The language cookie +// stays, so that page still greets them in their own language. +export async function GET(request: NextRequest) { + const message = request.nextUrl.searchParams.get(BAN_NOTICE_PARAM); + + const params = new URLSearchParams({[BAN_REASON_PARAM]: BAN_REASON}); + if (message) { + params.set(BAN_NOTICE_PARAM, message); + } + + // A relative Location, so the redirect can't leak the host the app happens to + // be bound to inside its container. The language middleware then prefixes the + // user's language. + const response = new NextResponse(null, { + status: 307, + headers: {location: `${APP_PATHS.auth.login}?${params.toString()}`}, + }); + response.cookies.delete(ACCESS_TOKEN_COOKIE_NAME); + response.cookies.delete(REFRESH_TOKEN_COOKIE_NAME); + + return response; +} diff --git a/src/components/query-client-provider.tsx b/src/components/query-client-provider.tsx index 35fd0ba6..6ab8fdeb 100644 --- a/src/components/query-client-provider.tsx +++ b/src/components/query-client-provider.tsx @@ -9,6 +9,7 @@ import { } from "@tanstack/react-query"; import {notifications} from "@mantine/notifications"; import {getClientDictionary} from "@/i18n/provider"; +import {isBannedError} from "@/lib/api/banned"; import type {TFunction} from "@/i18n/dictionary"; function messageFor(t: TFunction, status: number) { @@ -32,6 +33,13 @@ function messageFor(t: TFunction, status: number) { function handleError(err: any) { const {t} = getClientDictionary(); + + // The client DAL is already taking the user to the sign-out route; a + // "you don't have permission" toast on the way out would only mislead. + if (isBannedError(err)) { + return; + } + const status = err.response?.status; if (status) { notifications.show({ diff --git a/src/dal/client/client-dal-driver.ts b/src/dal/client/client-dal-driver.ts index f2fa7397..8a3d07b9 100644 --- a/src/dal/client/client-dal-driver.ts +++ b/src/dal/client/client-dal-driver.ts @@ -3,6 +3,7 @@ import {PUBLIC_BACKEND_URL} from "@/constants"; import {attachAuthHeaderClient} from "@/lib/auth/dal/attachAuthHeaderClient"; import {attachLanguageHeaderClient} from "@/lib/auth/dal/attachLanguageHeaderClient"; import {clientRefreshOn401} from "@/lib/auth/dal/clientRefreshOn401"; +import {clientBanRedirect} from "@/lib/auth/dal/clientBanRedirect"; const BASE_URL = `${PUBLIC_BACKEND_URL}/api`; const clientDalDriver = axios.create({ @@ -15,5 +16,6 @@ const clientDalDriver = axios.create({ attachAuthHeaderClient(clientDalDriver); attachLanguageHeaderClient(clientDalDriver); clientRefreshOn401(clientDalDriver); +clientBanRedirect(clientDalDriver); export {clientDalDriver}; diff --git a/src/dal/private/private-dal-driver.ts b/src/dal/private/private-dal-driver.ts index 19726172..0164e948 100644 --- a/src/dal/private/private-dal-driver.ts +++ b/src/dal/private/private-dal-driver.ts @@ -5,6 +5,7 @@ import {INTERNAL_BACKEND_URL} from "@/constants"; import InterceptorManager from "@/lib/auth/interception/interceptor-manager/InterceptorManager"; import ServerPublicInterceptor from "@/lib/auth/interception/interceptors/server/ServerPublicInterceptor"; import ServerProxyHeaderInterceptor from "@/lib/auth/interception/interceptors/server/ServerProxyHeaderInterceptor"; +import ServerBannedInterceptor from "@/lib/auth/interception/interceptors/server/ServerBannedInterceptor"; import {attachAuthHeaderServer} from "@/lib/auth/dal/attachAuthHeaderServer"; import {attachLanguageHeaderServer} from "@/lib/auth/dal/attachLanguageHeaderServer"; @@ -20,6 +21,9 @@ export const privateDalDriver = axios.create({ attachAuthHeaderServer(privateDalDriver); attachLanguageHeaderServer(privateDalDriver); +// The ban check runs first, so it still sees the raw AxiosError before +// `ServerPublicInterceptor` turns it into a `DALDriverError`. InterceptorManager.create(privateDalDriver) + .add(ServerBannedInterceptor) .add(ServerPublicInterceptor) .add(ServerProxyHeaderInterceptor); diff --git a/src/dal/private/users.ts b/src/dal/private/users.ts index 61548e0c..46c4617a 100644 --- a/src/dal/private/users.ts +++ b/src/dal/private/users.ts @@ -14,11 +14,15 @@ export async function fetchUser(userId: string, config?: AxiosRequestConfig) { return response.data; } -export async function createUser(data: Record) { +// Mostly form strings, plus the odd non-string field the API types more +// precisely (e.g. `banned` is a JSON boolean). +type UserPayload = Record; + +export async function createUser(data: UserPayload) { return await privateDalDriver.post("dashboard/users", data); } -export async function updateUser(data: Record) { +export async function updateUser(data: UserPayload) { return await privateDalDriver.put("dashboard/users", data); } diff --git a/src/features/articles/actions/delete-article.ts b/src/features/articles/actions/delete-article.ts index 2474a1a8..9158bc8d 100644 --- a/src/features/articles/actions/delete-article.ts +++ b/src/features/articles/actions/delete-article.ts @@ -1,6 +1,7 @@ "use server"; import {revalidatePath} from "next/cache"; +import {unstable_rethrow} from "next/navigation"; import {APP_PATHS} from "@/lib/app-paths"; import {privateDalDriver} from "@/dal/private/private-dal-driver"; @@ -20,7 +21,8 @@ export async function deleteArticle( ); revalidatePath(APP_PATHS.dashboard.articles.index); return true; - } catch { + } catch (error) { + unstable_rethrow(error); return false; } } diff --git a/src/features/auth/actions/login.ts b/src/features/auth/actions/login.ts index b1798531..365e85a0 100644 --- a/src/features/auth/actions/login.ts +++ b/src/features/auth/actions/login.ts @@ -18,6 +18,8 @@ import { extractValidationErrors, type ValidationFormState, } from "@/lib/api/validation-errors"; +import {bannedMessage, isBannedError} from "@/lib/api/banned"; +import {getServerDictionary} from "@/i18n/server"; type FormState = ValidationFormState | null; @@ -56,6 +58,17 @@ export async function login( return {success: true}; } catch (e) { + // A banned account is turned away here rather than at the door, so say why + // instead of letting it read as a wrong password. + if (isBannedError(e)) { + const {t} = await getServerDictionary(); + return { + success: false, + message: bannedMessage(e) || t("errors.banned"), + values, + }; + } + const errors = extractValidationErrors(e); if (errors) { return {success: false, errors, values}; diff --git a/src/features/auth/components/login-form.tsx b/src/features/auth/components/login-form.tsx index e67c41c5..ae618d3f 100644 --- a/src/features/auth/components/login-form.tsx +++ b/src/features/auth/components/login-form.tsx @@ -28,11 +28,15 @@ import {login} from "../actions/login"; type Props = { callbackUrl?: string; + // Set when the user landed here because the API reported their account as + // banned; `bannedNotice` is the API's own (already translated) wording. + banned?: boolean; + bannedNotice?: string; }; const LOGIN_FIELDS = ["identity", "password"] as const; -export function LoginForm({callbackUrl}: Props) { +export function LoginForm({callbackUrl, banned, bannedNotice}: Props) { const t = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); @@ -50,6 +54,15 @@ export function LoginForm({callbackUrl}: Props) { }, [state, queryClient, router, callbackUrl]); const formErrors = nonFieldErrors(state?.errors, LOGIN_FIELDS); + // A message the API sent about the attempt as a whole (a ban, say) replaces + // the generic "wrong credentials" guess. + const failureMessages = state?.message + ? [state.message] + : formErrors.length > 0 + ? formErrors + : !state?.errors + ? [t("auth.login.invalidCredentials")] + : []; return ( @@ -68,6 +81,19 @@ export function LoginForm({callbackUrl}: Props) { {t("auth.login.title")} + {/* Shown when the API ended the session mid-use, so the user isn't left + wondering why they were dropped here. */} + {banned === true && ( + } + > + {bannedNotice || t("errors.banned")} + + )} 0 - ? formErrors - : !state.errors - ? [t("auth.login.invalidCredentials")] - : [] - } + errors={failureMessages} title={t("auth.login.failedTitle")} /> )} diff --git a/src/features/dashboard/elements/actions/delete-element.ts b/src/features/dashboard/elements/actions/delete-element.ts index 560fd422..2669c753 100644 --- a/src/features/dashboard/elements/actions/delete-element.ts +++ b/src/features/dashboard/elements/actions/delete-element.ts @@ -1,6 +1,7 @@ "use server"; import {revalidatePath} from "next/cache"; +import {unstable_rethrow} from "next/navigation"; import {APP_PATHS} from "@/lib/app-paths"; import {privateDalDriver} from "@/dal/private/private-dal-driver"; @@ -17,7 +18,8 @@ export async function deleteElement( await privateDalDriver.delete(`/dashboard/elements/${id}`); revalidatePath(APP_PATHS.dashboard.elements.index); return true; - } catch { + } catch (error) { + unstable_rethrow(error); return false; } } diff --git a/src/features/languages/actions/delete-language.ts b/src/features/languages/actions/delete-language.ts index 3149bb91..52687be8 100644 --- a/src/features/languages/actions/delete-language.ts +++ b/src/features/languages/actions/delete-language.ts @@ -1,6 +1,7 @@ "use server"; import {revalidatePath} from "next/cache"; +import {unstable_rethrow} from "next/navigation"; import {deleteLanguage} from "@/dal/private/languages"; import {APP_PATHS} from "@/lib/app-paths"; @@ -17,7 +18,8 @@ export async function deleteLanguageAction( await deleteLanguage(code); revalidatePath(APP_PATHS.dashboard.languages.index); return true; - } catch { + } catch (error) { + unstable_rethrow(error); return false; } } diff --git a/src/features/roles/actions/delete-role.ts b/src/features/roles/actions/delete-role.ts index 8baebf1e..134cd730 100644 --- a/src/features/roles/actions/delete-role.ts +++ b/src/features/roles/actions/delete-role.ts @@ -1,6 +1,7 @@ "use server"; import {revalidatePath} from "next/cache"; +import {unstable_rethrow} from "next/navigation"; import {deleteRole} from "@/dal/private/roles"; import {APP_PATHS} from "@/lib/app-paths"; @@ -16,7 +17,8 @@ export async function deleteRoleAction( await deleteRole(fileId); revalidatePath(APP_PATHS.dashboard.files); return true; - } catch { + } catch (error) { + unstable_rethrow(error); return false; } } diff --git a/src/features/users/actions/delete-user.ts b/src/features/users/actions/delete-user.ts index 00db0d85..e8dd5fe8 100644 --- a/src/features/users/actions/delete-user.ts +++ b/src/features/users/actions/delete-user.ts @@ -1,6 +1,7 @@ "use server"; import {revalidatePath} from "next/cache"; +import {unstable_rethrow} from "next/navigation"; import {APP_PATHS} from "@/lib/app-paths"; import {deleteUser} from "@/dal/private/users"; @@ -16,7 +17,8 @@ export async function deleteUserAction( await deleteUser(userID); revalidatePath(APP_PATHS.dashboard.users.index); return true; - } catch { + } catch (error) { + unstable_rethrow(error); return false; } } diff --git a/src/features/users/actions/upsert-user.ts b/src/features/users/actions/upsert-user.ts index 88eb1090..654a180c 100644 --- a/src/features/users/actions/upsert-user.ts +++ b/src/features/users/actions/upsert-user.ts @@ -22,11 +22,23 @@ export async function upsertUserAction( values[key] = value.toString(); } }); + + const payload: Record = {...values}; + + // A switch posts "on" only when it's checked, so its absence is a real + // `false`; the API takes a JSON boolean, hence the explicit conversion. Only + // the update form carries the switch, and it sends the intent alone: + // the API owns `banned_at`, so it keeps the original date when an already + // banned user is saved again, and clears it when the ban is lifted. + if (values.uuid !== undefined) { + payload.banned = formData.get("banned") !== null; + } + try { if (values.uuid === undefined) { - await createUser(values); + await createUser(payload); } else { - await updateUser(values); + await updateUser(payload); } } catch (error) { const echoed = captureFormValues(formData, {exclude: ["password"]}); diff --git a/src/features/users/components/upsert-user-form/upsert-user-form.tsx b/src/features/users/components/upsert-user-form/upsert-user-form.tsx index 7b27811b..66540888 100644 --- a/src/features/users/components/upsert-user-form/upsert-user-form.tsx +++ b/src/features/users/components/upsert-user-form/upsert-user-form.tsx @@ -8,6 +8,7 @@ import { Group, TextInput, Select, + Switch, Alert, Anchor, Button, @@ -16,6 +17,7 @@ import {UserAvatarInput} from "@/components/user-avatar-input"; import {ValidationErrorsAlert} from "@/components/errors/validation-errors-alert"; import ServerComponentErrorHandler from "@/components/errors/server-component-error-handler"; import {nonFieldErrors} from "@/lib/api/validation-errors"; +import {formatDate} from "@/lib/date-and-time"; import {useTranslations} from "@/i18n/provider"; import type {Language} from "@/dal/public/languages"; import {upsertUserAction} from "../../actions/upsert-user"; @@ -29,6 +31,9 @@ type Props = { defaultUsername: string; defaultEmail: string; defaultLanguageCode: string; + defaultBanned: boolean; + // Empty when the user isn't banned, otherwise the moment the ban started. + defaultBannedAt: string; }>; languages?: Language[]; }; @@ -40,6 +45,7 @@ const USER_UPSERT_FIELDS = [ "password", "avatar", "language_code", + "banned", "uuid", ] as const; @@ -52,6 +58,8 @@ export function UpsertUserForm({userInfo = {}, languages = []}: Props) { defaultEmail, defaultName, defaultLanguageCode, + defaultBanned, + defaultBannedAt, } = userInfo; const [state, dispatch, isPending] = useActionState(upsertUserAction, { success: true, @@ -105,6 +113,26 @@ export function UpsertUserForm({userInfo = {}, languages = []}: Props) { error={state.errors?.password ?? ""} /> )} + {/* Banning is an action on an existing account, so it stays out of + the creation form. The switch sends the intent only — the API + keeps the date the ban started. */} + {userId !== undefined && ( + + )} {userId !== undefined && ( diff --git a/src/i18n/dictionaries/en.json b/src/i18n/dictionaries/en.json index 9261af40..e449226b 100644 --- a/src/i18n/dictionaries/en.json +++ b/src/i18n/dictionaries/en.json @@ -33,6 +33,8 @@ "operationFailed": "The operation failed.", "validationError": "Validation error", "unexpectedError": "Unexpected error", + "bannedTitle": "Account suspended", + "banned": "Your account has been suspended. Please contact support if you believe this is a mistake.", "http": { "badRequest": "The request could not be processed.", "unauthorized": "Please log in to continue.", @@ -328,7 +330,10 @@ "changePasswordLink": "here", "changePasswordSuffix": "", "save": "Save user", - "update": "Update" + "update": "Update", + "banned": "Banned", + "bannedDescription": "When enabled, this user loses access to their account.", + "bannedSince": "Banned since {date}." }, "password": { "newPassword": "Password", diff --git a/src/i18n/dictionaries/fa.json b/src/i18n/dictionaries/fa.json index f477c687..97f3e338 100644 --- a/src/i18n/dictionaries/fa.json +++ b/src/i18n/dictionaries/fa.json @@ -33,6 +33,8 @@ "operationFailed": "عملیات به مشکل خورد.", "validationError": "خطای اعتبارسنجی", "unexpectedError": "خطای غیرمنتظره", + "bannedTitle": "حساب کاربری مسدود شده", + "banned": "حساب کاربری شما مسدود شده است. اگر فکر می‌کنید اشتباهی رخ داده، با پشتیبانی تماس بگیرید.", "http": { "badRequest": "درخواست قابل پردازش نیست.", "unauthorized": "لطفاً برای ادامه وارد شوید.", @@ -328,7 +330,10 @@ "changePasswordLink": "اینجا", "changePasswordSuffix": "اقدام کنید", "save": "ذخیره کردن کاربر", - "update": "بروزرسانی" + "update": "بروزرسانی", + "banned": "مسدود شده", + "bannedDescription": "با فعال کردن این گزینه، دسترسی این کاربر به حسابش گرفته می‌شود.", + "bannedSince": "از {date} مسدود شده است." }, "password": { "newPassword": "کلمه عبور", diff --git a/src/lib/api/banned.ts b/src/lib/api/banned.ts new file mode 100644 index 00000000..9d1a572d --- /dev/null +++ b/src/lib/api/banned.ts @@ -0,0 +1,88 @@ +import {DALDriverError} from "@/dal/dal-driver-error"; + +/** + * A banned account is refused by the API with `403` and a body that both names + * the reason and carries an already-translated message: + * + * {"code": "user_banned", "message": "Your account has been banned."} + * + * The code is what separates a ban from an ordinary permission denial — that is + * a `403` too, and the dashboard already relies on it behaving as it does today + * (a warning, not a sign-out). Several spellings are accepted so the frontend + * doesn't hinge on one exact string. + */ +const BANNED_CODES = new Set([ + "user_banned", + "user_is_banned", + "account_banned", + "banned", +]); + +// Query parameters used to carry a ban across the forced sign-out redirect: the +// API's own message, plus the reason so the login page can fall back to a local +// translation when the API didn't send one. +export const BAN_NOTICE_PARAM = "notice"; +export const BAN_REASON_PARAM = "reason"; +export const BAN_REASON = "banned"; + +const BAN_SIGN_OUT_PATH = "/api/auth/banned"; + +// Clears the session and hands the user to the login page with the explanation. +export function bannedSignOutPath(message?: string): string { + const params = new URLSearchParams(); + if (message) { + params.set(BAN_NOTICE_PARAM, message); + } + const query = params.toString(); + return query ? `${BAN_SIGN_OUT_PATH}?${query}` : BAN_SIGN_OUT_PATH; +} + +// Works on both error shapes the app produces: `DALDriverError` from the server +// drivers, and the raw `AxiosError` from the client driver. +function responseOf(error: unknown): {status?: number; data?: unknown} { + if (error instanceof DALDriverError) { + return {status: error.statusCode, data: error.response?.data}; + } + + const candidate = error as + | {status?: number; response?: {status?: number; data?: unknown}} + | null + | undefined; + + return { + status: candidate?.response?.status ?? candidate?.status, + data: candidate?.response?.data, + }; +} + +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === "object"; +} + +function firstString(values: unknown[]): string { + const found = values.find( + (value) => typeof value === "string" && value.trim().length > 0, + ); + return typeof found === "string" ? found.trim() : ""; +} + +export function isBannedError(error: unknown): boolean { + const {status, data} = responseOf(error); + if (status !== 403 || !isPlainObject(data)) { + return false; + } + + const code = firstString([data.code, data.error, data.reason]); + return BANNED_CODES.has(code.toLowerCase()); +} + +// The message the API sends is already in the user's language, so it is shown +// as-is. Empty when the API sent none — callers fall back to a local string. +export function bannedMessage(error: unknown): string { + const {data} = responseOf(error); + if (!isPlainObject(data)) { + return ""; + } + + return firstString([data.message, data.detail]); +} diff --git a/src/lib/api/validation-errors.ts b/src/lib/api/validation-errors.ts index cfba05e2..a8bb86df 100644 --- a/src/lib/api/validation-errors.ts +++ b/src/lib/api/validation-errors.ts @@ -1,3 +1,4 @@ +import {unstable_rethrow} from "next/navigation"; import {DALDriverError} from "@/dal/dal-driver-error"; export type ValidationErrorMap = Record; @@ -7,6 +8,10 @@ export type ValidationFormState = { success: boolean; errors?: ValidationErrorMap; values?: FormValues; + // A form-level message the API sent about the request as a whole rather than + // about one field (a ban, say). Already translated by the API, so it is shown + // to the user as-is. + message?: string; }; const VALIDATION_STATUS_CODES: readonly number[] = [400, 422]; @@ -14,6 +19,13 @@ const VALIDATION_STATUS_CODES: readonly number[] = [400, 422]; export function extractValidationErrors( err: unknown, ): ValidationErrorMap | null { + // Nearly every `catch` around an API call funnels through here, which makes it + // the one place that can keep those blocks from swallowing Next's control-flow + // errors — `redirect()` and `notFound()` travel as throws, and the DAL + // interceptors raise them (a banned session, a missing resource) from inside + // the very calls being guarded. + unstable_rethrow(err); + if (!isValidationFailure(err)) return null; const raw = err.response?.data?.errors; diff --git a/src/lib/auth/dal/clientBanRedirect.ts b/src/lib/auth/dal/clientBanRedirect.ts new file mode 100644 index 00000000..50e67eac --- /dev/null +++ b/src/lib/auth/dal/clientBanRedirect.ts @@ -0,0 +1,23 @@ +import {AxiosError, AxiosInstance} from "axios"; +import { + bannedMessage, + bannedSignOutPath, + isBannedError, +} from "@/lib/api/banned"; + +// The browser-side counterpart of `ServerBannedInterceptor`: calls made straight +// from components (react-query and friends) get the same treatment, whether or +// not the caller handles the error itself. A full page load, not a router push, +// so nothing rendered for the old session survives. +export function clientBanRedirect(dal: AxiosInstance) { + dal.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (isBannedError(error) && typeof window !== "undefined") { + window.location.assign(bannedSignOutPath(bannedMessage(error))); + } + + return Promise.reject(error); + }, + ); +} diff --git a/src/lib/auth/interception/interceptors/server/ServerBannedInterceptor.ts b/src/lib/auth/interception/interceptors/server/ServerBannedInterceptor.ts new file mode 100644 index 00000000..fc794e17 --- /dev/null +++ b/src/lib/auth/interception/interceptors/server/ServerBannedInterceptor.ts @@ -0,0 +1,27 @@ +import ServerInterceptor from "../ServerInterceptor"; +import { + bannedMessage, + bannedSignOutPath, + isBannedError, +} from "@/lib/api/banned"; + +// A user can be banned in the middle of a session, so every authenticated call — +// a page's data fetch as much as a server action — is a place where the API can +// answer "banned". Wherever it does, the session is over: the user goes through +// the sign-out route, which drops their tokens and lands them on the login page +// with the API's explanation. Ordinary 403s (missing permission) are untouched. +export default class ServerBannedInterceptor extends ServerInterceptor { + add() { + this.dal.interceptors.response.use( + (value) => value, + (error) => { + if (isBannedError(error)) { + // Throws NEXT_REDIRECT, which propagates out of the awaiting caller. + this.redirect(bannedSignOutPath(bannedMessage(error))); + } + + throw error; + }, + ); + } +}