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