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; + }, + ); + } +}