Skip to content
Draft
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
14 changes: 13 additions & 1 deletion src/app/(auth)/[lang]/auth/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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}>;
Expand All @@ -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 <LoginForm callbackUrl={callbackUrl} />;
// Set by the sign-out route after the API refused a banned account.
const banned = searchParams[BAN_REASON_PARAM] === BAN_REASON;

return (
<LoginForm
callbackUrl={callbackUrl}
banned={banned}
bannedNotice={banned ? searchParams[BAN_NOTICE_PARAM] : undefined}
/>
);
}

export default LoginPage;
7 changes: 5 additions & 2 deletions src/app/(dashboard)/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
Expand Down
9 changes: 9 additions & 0 deletions src/app/(dashboard)/dashboard/users/[id]/(index)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
Expand All @@ -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 {
Expand Down Expand Up @@ -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}
/>
Expand Down
30 changes: 30 additions & 0 deletions src/app/api/auth/banned/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions src/components/query-client-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/dal/client/client-dal-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -15,5 +16,6 @@ const clientDalDriver = axios.create({
attachAuthHeaderClient(clientDalDriver);
attachLanguageHeaderClient(clientDalDriver);
clientRefreshOn401(clientDalDriver);
clientBanRedirect(clientDalDriver);

export {clientDalDriver};
4 changes: 4 additions & 0 deletions src/dal/private/private-dal-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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);
8 changes: 6 additions & 2 deletions src/dal/private/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ export async function fetchUser(userId: string, config?: AxiosRequestConfig) {
return response.data;
}

export async function createUser(data: Record<string, string>) {
// Mostly form strings, plus the odd non-string field the API types more
// precisely (e.g. `banned` is a JSON boolean).
type UserPayload = Record<string, string | boolean>;

export async function createUser(data: UserPayload) {
return await privateDalDriver.post("dashboard/users", data);
}

export async function updateUser(data: Record<string, string>) {
export async function updateUser(data: UserPayload) {
return await privateDalDriver.put("dashboard/users", data);
}

Expand Down
4 changes: 3 additions & 1 deletion src/features/articles/actions/delete-article.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,7 +21,8 @@ export async function deleteArticle(
);
revalidatePath(APP_PATHS.dashboard.articles.index);
return true;
} catch {
} catch (error) {
unstable_rethrow(error);
return false;
}
}
13 changes: 13 additions & 0 deletions src/features/auth/actions/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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};
Expand Down
36 changes: 28 additions & 8 deletions src/features/auth/components/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 (
<Box>
Expand All @@ -68,6 +81,19 @@ export function LoginForm({callbackUrl}: Props) {
<Title order={2} ta="center">
{t("auth.login.title")}
</Title>
{/* Shown when the API ended the session mid-use, so the user isn't left
wondering why they were dropped here. */}
{banned === true && (
<Alert
variant="filled"
color="red"
title={t("errors.bannedTitle")}
mt={"md"}
icon={<IconInfoCircle />}
>
{bannedNotice || t("errors.banned")}
</Alert>
)}
<Box component="form" mt={"xl"} action={dispatch}>
<Stack gap={8} mt={"md"}>
<TextInput
Expand Down Expand Up @@ -120,13 +146,7 @@ export function LoginForm({callbackUrl}: Props) {
)}
{state?.success === false && (
<ValidationErrorsAlert
errors={
formErrors.length > 0
? formErrors
: !state.errors
? [t("auth.login.invalidCredentials")]
: []
}
errors={failureMessages}
title={t("auth.login.failedTitle")}
/>
)}
Expand Down
4 changes: 3 additions & 1 deletion src/features/dashboard/elements/actions/delete-element.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}
}
4 changes: 3 additions & 1 deletion src/features/languages/actions/delete-language.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}
}
4 changes: 3 additions & 1 deletion src/features/roles/actions/delete-role.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}
}
4 changes: 3 additions & 1 deletion src/features/users/actions/delete-user.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}
}
16 changes: 14 additions & 2 deletions src/features/users/actions/upsert-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,23 @@ export async function upsertUserAction(
values[key] = value.toString();
}
});

const payload: Record<string, string | boolean> = {...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"]});
Expand Down
Loading
Loading