refactor: enable user registration vs disabling it

This commit is contained in:
Nicolas Meienberger 2026-01-20 21:21:45 +01:00
parent 343c00cb33
commit b35fdae5cd
9 changed files with 34 additions and 51 deletions

View file

@ -4147,7 +4147,7 @@ export type GetRegistrationStatusResponses = {
* Registration status * Registration status
*/ */
200: { 200: {
disabled: boolean; enabled: boolean;
}; };
}; };
@ -4155,7 +4155,7 @@ export type GetRegistrationStatusResponse = GetRegistrationStatusResponses[keyof
export type SetRegistrationStatusData = { export type SetRegistrationStatusData = {
body?: { body?: {
disabled: boolean; enabled: boolean;
}; };
path?: never; path?: never;
query?: never; query?: never;
@ -4167,7 +4167,7 @@ export type SetRegistrationStatusResponses = {
* Registration status updated * Registration status updated
*/ */
200: { 200: {
disabled: boolean; enabled: boolean;
}; };
}; };

View file

@ -1 +1,2 @@
export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories"; export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories";
export const REGISTRATION_ENABLED_KEY = "registrations_enabled";

View file

@ -182,17 +182,15 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="disable-registrations" className="text-base"> <Label htmlFor="enable-registrations" className="text-base">
Disable new user registrations Enable new user registrations
</Label> </Label>
<p className="text-sm text-muted-foreground max-w-2xl"> <p className="text-sm text-muted-foreground max-w-2xl">When enabled, new users can sign up</p>
When enabled, new users cannot sign up. Only administrators can create accounts manually.
</p>
</div> </div>
<Switch <Switch
id="disable-registrations" id="enable-registrations"
checked={registrationStatusQuery.data?.disabled ?? false} checked={registrationStatusQuery.data?.enabled ?? false}
onCheckedChange={(checked) => updateRegistrationStatusMutation.mutate({ body: { disabled: checked } })} onCheckedChange={(checked) => updateRegistrationStatusMutation.mutate({ body: { enabled: checked } })}
disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending} disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending}
/> />
</div> </div>

View file

@ -4,8 +4,7 @@ import { logger } from "~/server/utils/logger";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { appMetadataTable } from "~/server/db/schema"; import { appMetadataTable } from "~/server/db/schema";
import { ForbiddenError } from "http-errors-enhanced"; import { ForbiddenError } from "http-errors-enhanced";
import { REGISTRATION_ENABLED_KEY } from "~/client/lib/constants";
export const REGISTRATION_DISABLED_KEY = "registrationsDisabled";
export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => { export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
const { path } = ctx; const { path } = ctx;
@ -16,11 +15,11 @@ export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
} }
const result = await db.query.appMetadataTable.findFirst({ const result = await db.query.appMetadataTable.findFirst({
where: eq(appMetadataTable.key, REGISTRATION_DISABLED_KEY), where: eq(appMetadataTable.key, REGISTRATION_ENABLED_KEY),
}); });
if (result?.value === "true" && existingUser) { if (result?.value !== "true" && existingUser) {
logger.info("User registration attempt blocked: registrations are disabled."); logger.info("User registration attempt blocked: registrations are not enabled.");
throw new ForbiddenError("User registrations are currently disabled. Please contact an administrator for access."); throw new ForbiddenError("User registrations are currently disabled. Please contact an administrator for access.");
} }
}; };

View file

@ -82,15 +82,3 @@ const parseConfig = (env: unknown) => {
}; };
export const config = parseConfig(process.env); export const config = parseConfig(process.env);
export const getAppSecret = (): string => {
if (!config.appSecret) {
throw new Error("ZEROBYTE_APP_SECRET is not configured");
}
if (config.appSecret.length < 32) {
throw new Error("ZEROBYTE_APP_SECRET must be at least 32 characters long");
}
return config.appSecret;
};

View file

@ -45,9 +45,9 @@ export const systemController = new Hono()
return c.json<UpdateInfoDto>(updates, 200); return c.json<UpdateInfoDto>(updates, 200);
}) })
.get("/registration-status", getRegistrationStatusDto, async (c) => { .get("/registration-status", getRegistrationStatusDto, async (c) => {
const disabled = await systemService.isRegistrationDisabled(); const enabled = await systemService.isRegistrationEnabled();
return c.json<RegistrationStatusDto>({ disabled }, 200); return c.json<RegistrationStatusDto>({ enabled }, 200);
}) })
.put( .put(
"/registration-status", "/registration-status",
@ -57,9 +57,9 @@ export const systemController = new Hono()
async (c) => { async (c) => {
const body = c.req.valid("json"); const body = c.req.valid("json");
await systemService.setRegistrationDisabled(body.disabled); await systemService.setRegistrationEnabled(body.enabled);
return c.json<RegistrationStatusDto>({ disabled: body.disabled }, 200); return c.json<RegistrationStatusDto>({ enabled: body.enabled }, 200);
}, },
) )
.post( .post(

View file

@ -65,7 +65,8 @@ export const downloadResticPasswordBodySchema = type({
}); });
export const downloadResticPasswordDto = describeRoute({ export const downloadResticPasswordDto = describeRoute({
description: "Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.", description:
"Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.",
tags: ["System"], tags: ["System"],
operationId: "downloadResticPassword", operationId: "downloadResticPassword",
responses: { responses: {
@ -81,13 +82,13 @@ export const downloadResticPasswordDto = describeRoute({
}); });
export const registrationStatusResponse = type({ export const registrationStatusResponse = type({
disabled: "boolean", enabled: "boolean",
}); });
export type RegistrationStatusDto = typeof registrationStatusResponse.infer; export type RegistrationStatusDto = typeof registrationStatusResponse.infer;
export const registrationStatusBody = type({ export const registrationStatusBody = type({
disabled: "boolean", enabled: "boolean",
}); });
export const getRegistrationStatusDto = describeRoute({ export const getRegistrationStatusDto = describeRoute({

View file

@ -7,9 +7,9 @@ import { logger } from "~/server/utils/logger";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { appMetadataTable } from "../../db/schema"; import { appMetadataTable } from "../../db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { REGISTRATION_ENABLED_KEY } from "~/client/lib/constants";
const CACHE_TTL = 60 * 60; const CACHE_TTL = 60 * 60;
const REGISTRATION_DISABLED_KEY = "registrationsDisabled";
const getSystemInfo = async () => { const getSystemInfo = async () => {
return { return {
@ -89,28 +89,28 @@ const getUpdates = async (): Promise<UpdateInfoDto> => {
} }
}; };
const isRegistrationDisabled = async () => { const isRegistrationEnabled = async () => {
const result = await db.query.appMetadataTable.findFirst({ const result = await db.query.appMetadataTable.findFirst({
where: eq(appMetadataTable.key, REGISTRATION_DISABLED_KEY), where: eq(appMetadataTable.key, REGISTRATION_ENABLED_KEY),
}); });
return result?.value === "true"; return result?.value === "true";
}; };
const setRegistrationDisabled = async (disabled: boolean) => { const setRegistrationEnabled = async (enabled: boolean) => {
const now = Date.now(); const now = Date.now();
await db await db
.insert(appMetadataTable) .insert(appMetadataTable)
.values({ key: REGISTRATION_DISABLED_KEY, value: JSON.stringify(disabled), createdAt: now, updatedAt: now }) .values({ key: REGISTRATION_ENABLED_KEY, value: JSON.stringify(enabled), createdAt: now, updatedAt: now })
.onConflictDoUpdate({ target: appMetadataTable.key, set: { value: JSON.stringify(disabled), updatedAt: now } }); .onConflictDoUpdate({ target: appMetadataTable.key, set: { value: JSON.stringify(enabled), updatedAt: now } });
logger.info(`Registration disabled set to: ${disabled}`); logger.info(`Registration enabled set to: ${enabled}`);
}; };
export const systemService = { export const systemService = {
getSystemInfo, getSystemInfo,
getUpdates, getUpdates,
isRegistrationDisabled, isRegistrationEnabled,
setRegistrationDisabled, setRegistrationEnabled,
}; };

View file

@ -1,7 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { config, getAppSecret } from "../core/config"; import { config } from "../core/config";
import { isNodeJSErrnoException } from "./fs"; import { isNodeJSErrnoException } from "./fs";
import { promisify } from "node:util"; import { promisify } from "node:util";
@ -96,10 +96,8 @@ const encrypt = async (data: string) => {
return data; return data;
} }
const secret = getAppSecret();
const salt = crypto.randomBytes(16); const salt = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256"); const key = crypto.pbkdf2Sync(config.appSecret, salt, 100000, keyLength, "sha256");
const iv = crypto.randomBytes(12); const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(algorithm, key, iv); const cipher = crypto.createCipheriv(algorithm, key, iv);
@ -118,13 +116,11 @@ const decrypt = async (encryptedData: string) => {
return encryptedData; return encryptedData;
} }
const secret = getAppSecret();
const parts = encryptedData.split(":").slice(1); // Remove prefix const parts = encryptedData.split(":").slice(1); // Remove prefix
const saltHex = parts.shift() as string; const saltHex = parts.shift() as string;
const salt = Buffer.from(saltHex, "hex"); const salt = Buffer.from(saltHex, "hex");
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256"); const key = crypto.pbkdf2Sync(config.appSecret, salt, 100000, keyLength, "sha256");
const iv = Buffer.from(parts.shift() as string, "hex"); const iv = Buffer.from(parts.shift() as string, "hex");
const encrypted = Buffer.from(parts.shift() as string, "hex"); const encrypted = Buffer.from(parts.shift() as string, "hex");