Admin-controlled public registration toggle

User wants the admin to be able to open or close public self-registration
from inside the app instead of removing the registration page entirely.

Changes:
- Added registration_open boolean column to app_settings (default true)
- Exposed registrationOpen in AppSettings + UpdateAppSettingsBody schemas
  in openapi.yaml; regenerated client + zod
- Backend: POST /api/auth/register now returns 403 "Registration is
  closed" when the flag is off (checks settings row before any other work)
- Admin Site Settings panel now has a switch to toggle public
  registration on/off, with bilingual label + helper text
- Login page hides the "Create account" link when registration is closed
- Register page short-circuits to a friendly "registration closed"
  card with a Back to Login button when the flag is off (and redirects
  if the user lands there directly)
- Added bilingual locale keys: registrationOpen, registrationOpenHint,
  registrationClosed

Verified: GET /api/settings returns the new field; POST /api/auth/register
returns 403 when closed and proceeds normally when open. Both typecheck
suites pass.
This commit is contained in:
Riyadh
2026-04-20 11:33:06 +00:00
parent 9d353ede69
commit 547fa53359
10 changed files with 78 additions and 16 deletions
+7
View File
@@ -6,6 +6,7 @@ import {
usersTable,
userRolesTable,
rolesTable,
appSettingsTable,
} from "@workspace/db";
import { requireAuth, getUserRoles } from "../middlewares/auth";
import { RegisterBody, LoginBody, UpdateLanguageBody } from "@workspace/api-zod";
@@ -34,6 +35,12 @@ router.post("/auth/register", async (req, res): Promise<void> => {
return;
}
const [settings] = await db.select().from(appSettingsTable).where(eq(appSettingsTable.id, 1));
if (settings && !settings.registrationOpen) {
res.status(403).json({ error: "Registration is closed" });
return;
}
const { username, email, password, displayNameAr, displayNameEn, preferredLanguage } = parsed.data;
const existing = await db
+4 -1
View File
@@ -17,6 +17,7 @@
"registerButton": "تسجيل",
"welcomeBack": "مرحباً بعودتك",
"createAccount": "حساب جديد",
"registrationClosed": "إنشاء الحسابات العامة مغلق حالياً. تواصل مع مدير النظام.",
"displayNameAr": "الاسم (عربي)",
"displayNameEn": "الاسم (إنجليزي)"
},
@@ -93,7 +94,9 @@
"active": "نشط",
"siteSettings": "إعدادات الموقع",
"siteNameAr": "اسم الموقع (عربي)",
"siteNameEn": "اسم الموقع (إنجليزي)"
"siteNameEn": "اسم الموقع (إنجليزي)",
"registrationOpen": "السماح بإنشاء حسابات",
"registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد."
},
"notFound": {
"title": "404 — الصفحة غير موجودة",
+4 -1
View File
@@ -17,6 +17,7 @@
"registerButton": "Register",
"welcomeBack": "Welcome Back",
"createAccount": "Create Account",
"registrationClosed": "Public registration is currently closed. Please contact the administrator.",
"displayNameAr": "Name (Arabic)",
"displayNameEn": "Name (English)"
},
@@ -93,7 +94,9 @@
"active": "Active",
"siteSettings": "Site Settings",
"siteNameAr": "Site Name (Arabic)",
"siteNameEn": "Site Name (English)"
"siteNameEn": "Site Name (English)",
"registrationOpen": "Allow public registration",
"registrationOpenHint": "When off, only admins can create new users."
},
"notFound": {
"title": "404 — Page Not Found",
+16 -2
View File
@@ -508,10 +508,14 @@ function SiteSettingsPanel() {
const queryClient = useQueryClient();
const { data } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
const update = useUpdateAppSettings();
const [form, setForm] = useState({ siteNameAr: "", siteNameEn: "" });
const [form, setForm] = useState({ siteNameAr: "", siteNameEn: "", registrationOpen: true });
useEffect(() => {
if (data) setForm({ siteNameAr: data.siteNameAr, siteNameEn: data.siteNameEn });
if (data) setForm({
siteNameAr: data.siteNameAr,
siteNameEn: data.siteNameEn,
registrationOpen: data.registrationOpen,
});
}, [data]);
const handleSave = () => {
@@ -547,6 +551,16 @@ function SiteSettingsPanel() {
dir="ltr"
/>
</div>
<div className="flex items-center justify-between pt-2">
<div>
<Label className="font-medium">{t("admin.registrationOpen")}</Label>
<p className="text-xs text-muted-foreground">{t("admin.registrationOpenHint")}</p>
</div>
<Switch
checked={form.registrationOpen}
onCheckedChange={(v) => setForm({ ...form, registrationOpen: v })}
/>
</div>
<Button onClick={handleSave} disabled={update.isPending} className="w-full">
{update.isPending ? t("common.loading") : t("common.save")}
</Button>
+12 -9
View File
@@ -8,12 +8,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { useAppName } from "@/hooks/use-app-settings";
import { useAppName, useAppSettings } from "@/hooks/use-app-settings";
import i18n from "@/i18n";
export default function LoginPage() {
const { t } = useTranslation();
const appName = useAppName();
const { data: settings } = useAppSettings();
const [, setLocation] = useLocation();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -86,14 +87,16 @@ export default function LoginPage() {
</Button>
</form>
<div className="text-center">
<button
onClick={() => setLocation("/register")}
className="text-sm text-primary hover:underline"
>
{t("auth.register")}
</button>
</div>
{settings?.registrationOpen && (
<div className="text-center">
<button
onClick={() => setLocation("/register")}
className="text-sm text-primary hover:underline"
>
{t("auth.register")}
</button>
</div>
)}
<div className="text-center">
<button
+23 -1
View File
@@ -8,13 +8,21 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { useAppName } from "@/hooks/use-app-settings";
import { useAppName, useAppSettings } from "@/hooks/use-app-settings";
import { useEffect } from "react";
import i18n from "@/i18n";
export default function RegisterPage() {
const { t } = useTranslation();
const appName = useAppName();
const { data: settings, isLoading: settingsLoading } = useAppSettings();
const [, setLocation] = useLocation();
useEffect(() => {
if (settings && !settings.registrationOpen) {
setLocation("/login");
}
}, [settings, setLocation]);
const { toast } = useToast();
const queryClient = useQueryClient();
const [form, setForm] = useState({
@@ -46,6 +54,20 @@ export default function RegisterPage() {
);
};
if (settingsLoading || (settings && !settings.registrationOpen)) {
return (
<div className="min-h-screen os-bg flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-8 w-full max-w-sm text-center space-y-3">
<div className="text-2xl font-bold text-primary">{appName}</div>
<p className="text-sm text-muted-foreground">{t("auth.registrationClosed")}</p>
<Button onClick={() => setLocation("/login")} className="w-full">
{t("auth.loginButton")}
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen os-bg flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-8 w-full max-w-sm space-y-6">
@@ -260,6 +260,7 @@ export interface Notification {
export interface AppSettings {
siteNameAr: string;
siteNameEn: string;
registrationOpen: boolean;
updatedAt?: string;
}
@@ -274,6 +275,7 @@ export interface UpdateAppSettingsBody {
* @maxLength 200
*/
siteNameEn?: string;
registrationOpen?: boolean;
}
export interface RequestUploadUrlBody {
+5 -1
View File
@@ -1179,12 +1179,14 @@ components:
AppSettings:
type: object
required: [siteNameAr, siteNameEn]
required: [siteNameAr, siteNameEn, registrationOpen]
properties:
siteNameAr:
type: string
siteNameEn:
type: string
registrationOpen:
type: boolean
updatedAt:
type: string
format: date-time
@@ -1200,6 +1202,8 @@ components:
type: string
minLength: 1
maxLength: 200
registrationOpen:
type: boolean
RequestUploadUrlBody:
type: object
+3
View File
@@ -203,6 +203,7 @@ export const DeleteAppParams = zod.object({
export const GetAppSettingsResponse = zod.object({
siteNameAr: zod.string(),
siteNameEn: zod.string(),
registrationOpen: zod.boolean(),
updatedAt: zod.coerce.date().optional(),
});
@@ -224,11 +225,13 @@ export const UpdateAppSettingsBody = zod.object({
.min(1)
.max(updateAppSettingsBodySiteNameEnMax)
.optional(),
registrationOpen: zod.boolean().optional(),
});
export const UpdateAppSettingsResponse = zod.object({
siteNameAr: zod.string(),
siteNameEn: zod.string(),
registrationOpen: zod.boolean(),
updatedAt: zod.coerce.date().optional(),
});
+2 -1
View File
@@ -1,4 +1,4 @@
import { pgTable, varchar, integer, timestamp } from "drizzle-orm/pg-core";
import { pgTable, varchar, integer, timestamp, boolean } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
@@ -6,6 +6,7 @@ export const appSettingsTable = pgTable("app_settings", {
id: integer("id").primaryKey().default(1),
siteNameAr: varchar("site_name_ar", { length: 200 }).notNull().default("نظام TeaBoy"),
siteNameEn: varchar("site_name_en", { length: 200 }).notNull().default("TeaBoy OS"),
registrationOpen: boolean("registration_open").notNull().default(true),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
});