Add user ban/activate feature

Add admin endpoints to ban and activate users, block banned users
from all auth flows (OAuth, token login, bearer auth), and invalidate
existing sessions on next request. Includes frontend translation and
user detail page ban/activate buttons.
This commit is contained in:
tdurieux
2026-05-07 05:41:12 +03:00
parent 48256e743c
commit 8fc7ac5175
8 changed files with 77 additions and 2 deletions
+1
View File
@@ -8,6 +8,7 @@
"not_authorized": "You do not have permission to perform this action.",
"unable_to_connect_user": "Unable to connect your account. Please try again later.",
"user_not_found": "The requested user could not be found.",
"user_banned": "Your account has been banned. Contact the admin for more information.",
"repo_access_limited": "GitHub blocked access because the repository's organization restricts third-party OAuth apps. Ask an org owner to approve Anonymous GitHub under Settings → Third-party Access → OAuth app policy, or anonymize a personal fork instead.",
"repo_not_found": "The repository was not found on GitHub. Check the URL and spelling, make sure you are signed in to the account that can see it, and confirm the repo isn't hidden under an org that restricts third-party app access.",
"repo_empty": "The selected branch has no commits on GitHub. Push at least one commit, or pick a different branch, then retry.",
+4
View File
@@ -22,6 +22,10 @@
</span>
<span class="type-badge type-repo" ng-if="userInfo.isAdmin">Admin</span>
</h1>
<div class="user-actions" style="margin-top: 4px;">
<button class="btn btn-sm text-danger" ng-if="userInfo.status !== 'banned'" ng-click="banUser()"><i class="fas fa-ban"></i> Ban</button>
<button class="btn btn-sm" ng-if="userInfo.status === 'banned' || userInfo.status === 'removed'" ng-click="activateUser()"><i class="fas fa-check-circle"></i> Activate</button>
</div>
</div>
</div>
+12
View File
@@ -519,6 +519,18 @@ angular
getUser($routeParams.username);
getUserRepositories($routeParams.username);
$scope.banUser = () => {
if (!confirm(`Ban user ${$routeParams.username}?`)) return;
$http
.post(`/api/admin/users/${$routeParams.username}/ban`)
.then(() => getUser($routeParams.username), (err) => console.error(err));
};
$scope.activateUser = () => {
$http
.post(`/api/admin/users/${$routeParams.username}/activate`)
.then(() => getUser($routeParams.username), (err) => console.error(err));
};
$scope.tokens = [];
$scope.tokenForm = { name: "", plaintext: null };
+1 -1
View File
@@ -38,7 +38,7 @@ export interface IUser {
page: string | null;
};
};
status?: "active" | "removed";
status?: "active" | "removed" | "banned";
dateOfEntry?: Date;
lastUpdated?: Date;
}
+36
View File
@@ -806,6 +806,42 @@ router.get(
}
}
);
router.post(
"/users/:username/ban",
async (req: express.Request, res: express.Response) => {
try {
const result = await UserModel.updateOne(
{ username: req.params.username },
{ $set: { status: "banned" } }
);
if (result.matchedCount === 0) {
throw new AnonymousError("user_not_found", { httpStatus: 404 });
}
res.json({ ok: true });
} catch (error) {
handleError(error, res, req);
}
}
);
router.post(
"/users/:username/activate",
async (req: express.Request, res: express.Response) => {
try {
const result = await UserModel.updateOne(
{ username: req.params.username },
{ $set: { status: "active" } }
);
if (result.matchedCount === 0) {
throw new AnonymousError("user_not_found", { httpStatus: 404 });
}
res.json({ ok: true });
} catch (error) {
handleError(error, res, req);
}
}
);
router.get("/conferences", async (req, res) => {
const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 10, 1000);
+9
View File
@@ -92,6 +92,14 @@ const verify = async (
await user.save();
}
}
if (user!.status === "banned") {
done(
new AnonymousError("user_banned", {
httpStatus: 403,
})
);
return;
}
done(null, {
username: profile.username,
accessToken,
@@ -197,6 +205,7 @@ router.all(
"apiTokens.tokenHash": hashToken(token),
});
if (!model) return res.status(401).json({ error: "invalid_token" });
if (model.status === "banned") return res.status(403).json({ error: "user_banned" });
const synthUser = {
username: model.username,
accessToken: model.accessTokens?.github,
+13 -1
View File
@@ -216,7 +216,9 @@ export function handleError(
status = 401;
}
if (res && !res.headersSent) {
res.status(status).json({ error: errorCode });
const safeCode =
error instanceof AnonymousError ? errorCode : "internal_error";
res.status(status).json({ error: safeCode });
}
return;
}
@@ -244,5 +246,15 @@ export async function getUser(req: express.Request) {
if (!model) {
notConnected();
}
if (model.status === "banned") {
req.logout((error) => {
if (error) {
logger.error("logout failed", serializeError(error));
}
});
throw new AnonymousError("user_banned", {
httpStatus: 403,
});
}
return new User(model);
}
+1
View File
@@ -29,6 +29,7 @@ export async function bearerTokenAuth(
try {
const model = await UserModel.findOne({ "apiTokens.tokenHash": tokenHash });
if (!model) return next();
if (model.status === "banned") return next();
// Mirror the shape produced by passport's verify() in connection.ts
// so existing getUser()/route code works unchanged.