feat: disable registrations

This commit is contained in:
Nicolas Meienberger 2026-01-17 23:55:12 +01:00
parent 23acae1121
commit a2432c2e58
13 changed files with 311 additions and 15 deletions

View file

@ -21,6 +21,7 @@ import {
getBackupScheduleForVolume,
getMirrorCompatibility,
getNotificationDestination,
getRegistrationStatus,
getRepository,
getScheduleMirrors,
getScheduleNotifications,
@ -44,6 +45,7 @@ import {
restoreSnapshot,
runBackupNow,
runForget,
setRegistrationStatus,
stopBackup,
tagSnapshots,
testConnection,
@ -91,6 +93,8 @@ import type {
GetMirrorCompatibilityResponse,
GetNotificationDestinationData,
GetNotificationDestinationResponse,
GetRegistrationStatusData,
GetRegistrationStatusResponse,
GetRepositoryData,
GetRepositoryResponse,
GetScheduleMirrorsData,
@ -135,6 +139,8 @@ import type {
RunBackupNowResponse,
RunForgetData,
RunForgetResponse,
SetRegistrationStatusData,
SetRegistrationStatusResponse,
StopBackupData,
StopBackupResponse,
TagSnapshotsData,
@ -1259,6 +1265,54 @@ export const getUpdatesOptions = (options?: Options<GetUpdatesData>) =>
queryKey: getUpdatesQueryKey(options),
});
export const getRegistrationStatusQueryKey = (options?: Options<GetRegistrationStatusData>) =>
createQueryKey("getRegistrationStatus", options);
/**
* Get the current registration status for new users
*/
export const getRegistrationStatusOptions = (options?: Options<GetRegistrationStatusData>) =>
queryOptions<
GetRegistrationStatusResponse,
DefaultError,
GetRegistrationStatusResponse,
ReturnType<typeof getRegistrationStatusQueryKey>
>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getRegistrationStatus({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getRegistrationStatusQueryKey(options),
});
/**
* Update the registration status for new users. Requires global admin role.
*/
export const setRegistrationStatusMutation = (
options?: Partial<Options<SetRegistrationStatusData>>,
): UseMutationOptions<SetRegistrationStatusResponse, DefaultError, Options<SetRegistrationStatusData>> => {
const mutationOptions: UseMutationOptions<
SetRegistrationStatusResponse,
DefaultError,
Options<SetRegistrationStatusData>
> = {
mutationFn: async (fnOptions) => {
const { data } = await setRegistrationStatus({
...options,
...fnOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/**
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
*/

View file

@ -18,6 +18,7 @@ export {
getBackupScheduleForVolume,
getMirrorCompatibility,
getNotificationDestination,
getRegistrationStatus,
getRepository,
getScheduleMirrors,
getScheduleNotifications,
@ -41,6 +42,7 @@ export {
restoreSnapshot,
runBackupNow,
runForget,
setRegistrationStatus,
stopBackup,
tagSnapshots,
testConnection,
@ -108,6 +110,9 @@ export type {
GetNotificationDestinationErrors,
GetNotificationDestinationResponse,
GetNotificationDestinationResponses,
GetRegistrationStatusData,
GetRegistrationStatusResponse,
GetRegistrationStatusResponses,
GetRepositoryData,
GetRepositoryResponse,
GetRepositoryResponses,
@ -176,6 +181,9 @@ export type {
RunForgetData,
RunForgetResponse,
RunForgetResponses,
SetRegistrationStatusData,
SetRegistrationStatusResponse,
SetRegistrationStatusResponses,
StopBackupData,
StopBackupErrors,
StopBackupResponse,

View file

@ -39,6 +39,8 @@ import type {
GetNotificationDestinationData,
GetNotificationDestinationErrors,
GetNotificationDestinationResponses,
GetRegistrationStatusData,
GetRegistrationStatusResponses,
GetRepositoryData,
GetRepositoryResponses,
GetScheduleMirrorsData,
@ -85,6 +87,8 @@ import type {
RunBackupNowResponses,
RunForgetData,
RunForgetResponses,
SetRegistrationStatusData,
SetRegistrationStatusResponses,
StopBackupData,
StopBackupErrors,
StopBackupResponses,
@ -707,6 +711,32 @@ export const getUpdates = <ThrowOnError extends boolean = false>(options?: Optio
...options,
});
/**
* Get the current registration status for new users
*/
export const getRegistrationStatus = <ThrowOnError extends boolean = false>(
options?: Options<GetRegistrationStatusData, ThrowOnError>,
) =>
(options?.client ?? client).get<GetRegistrationStatusResponses, unknown, ThrowOnError>({
url: "/api/v1/system/registration-status",
...options,
});
/**
* Update the registration status for new users. Requires global admin role.
*/
export const setRegistrationStatus = <ThrowOnError extends boolean = false>(
options?: Options<SetRegistrationStatusData, ThrowOnError>,
) =>
(options?.client ?? client).put<SetRegistrationStatusResponses, unknown, ThrowOnError>({
url: "/api/v1/system/registration-status",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
/**
* Download the organization's Restic password for backup recovery. Requires organization owner or admin role and password re-authentication.
*/

View file

@ -4135,6 +4135,44 @@ export type GetUpdatesResponses = {
export type GetUpdatesResponse = GetUpdatesResponses[keyof GetUpdatesResponses];
export type GetRegistrationStatusData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/system/registration-status";
};
export type GetRegistrationStatusResponses = {
/**
* Registration status
*/
200: {
disabled: boolean;
};
};
export type GetRegistrationStatusResponse = GetRegistrationStatusResponses[keyof GetRegistrationStatusResponses];
export type SetRegistrationStatusData = {
body?: {
disabled: boolean;
};
path?: never;
query?: never;
url: "/api/v1/system/registration-status";
};
export type SetRegistrationStatusResponses = {
/**
* Registration status updated
*/
200: {
disabled: boolean;
};
};
export type SetRegistrationStatusResponse = SetRegistrationStatusResponses[keyof SetRegistrationStatusResponses];
export type DownloadResticPasswordData = {
body?: {
password: string;

View file

@ -86,7 +86,14 @@ export default function OnboardingPage() {
void navigate("/download-recovery-key");
} else if (error) {
console.error(error);
toast.error("Failed to create admin user", { description: error.message });
const errorMessage = error.message ?? "Unknown error";
if (errorMessage.includes("User registrations are currently disabled")) {
toast.error("User registrations are currently disabled", {
description: "Please contact an administrator for access.",
});
} else {
toast.error("Failed to create admin user", { description: errorMessage });
}
}
};

View file

@ -1,9 +1,13 @@
import { useMutation } from "@tanstack/react-query";
import { Download, KeyRound, User, X } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Download, KeyRound, User, X, Users } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import {
downloadResticPasswordMutation,
setRegistrationStatusMutation,
getRegistrationStatusOptions,
} from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import {
@ -17,6 +21,7 @@ import {
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Switch } from "~/client/components/ui/switch";
import { authClient } from "~/client/lib/auth-client";
import { appContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
@ -50,6 +55,25 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
const [isChangingPassword, setIsChangingPassword] = useState(false);
const navigate = useNavigate();
const isAdmin = loaderData.user?.role === "admin";
const registrationStatusQuery = useQuery({
...getRegistrationStatusOptions(),
enabled: isAdmin,
});
const updateRegistrationStatusMutation = useMutation({
...setRegistrationStatusMutation(),
onSuccess: () => {
toast.success("Registration settings updated");
void registrationStatusQuery.refetch();
},
onError: (error) => {
toast.error("Failed to update registration settings", {
description: error.message,
});
},
});
const handleLogout = async () => {
await authClient.signOut({
@ -145,6 +169,35 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
return (
<Card className="p-0 gap-0">
{isAdmin && (
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Users className="size-5" />
System Settings
</CardTitle>
<CardDescription className="mt-1.5">Manage system-wide settings</CardDescription>
</div>
)}
{isAdmin && (
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="disable-registrations" className="text-base">
Disable new user registrations
</Label>
<p className="text-sm text-muted-foreground max-w-2xl">
When enabled, new users cannot sign up. Only administrators can create accounts manually.
</p>
</div>
<Switch
id="disable-registrations"
checked={registrationStatusQuery.data?.disabled ?? false}
onCheckedChange={(checked) => updateRegistrationStatusMutation.mutate({ body: { disabled: checked } })}
disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending}
/>
</div>
</CardContent>
)}
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<User className="size-5" />

View file

@ -6,6 +6,7 @@ type User = {
username: string;
hasDownloadedResticPassword: boolean;
twoFactorEnabled?: boolean | null;
role?: string | null | undefined;
};
type AppContext = {

View file

@ -1,17 +1,25 @@
import { db } from "~/server/db/db";
import type { AuthMiddlewareContext } from "../auth";
import { logger } from "~/server/utils/logger";
import { eq } from "drizzle-orm";
import { appMetadataTable } from "~/server/db/schema";
export const REGISTRATION_DISABLED_KEY = "registrationsDisabled";
export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
const { path } = ctx;
const existingUser = await db.query.usersTable.findFirst();
if (path !== "/sign-up/email") {
return;
}
const existingUser = await db.query.usersTable.findFirst();
if (existingUser) {
logger.error("Attempt to create a second administrator account blocked.");
throw new Error("An administrator account already exists");
const result = await db.query.appMetadataTable.findFirst({
where: eq(appMetadataTable.key, REGISTRATION_DISABLED_KEY),
});
if (result?.value === "true" && existingUser) {
logger.info("User registration attempt blocked: registrations are disabled.");
throw new Error("User registrations are currently disabled. Please contact an administrator for access.");
}
};

View file

@ -13,6 +13,7 @@ import { config } from "../server/core/config";
import { db } from "../server/db/db";
import { cryptoUtils } from "../server/utils/crypto";
import { organization as organizationTable, member } from "../server/db/schema";
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
@ -22,7 +23,7 @@ const createBetterAuth = (secret: string) =>
trustedOrigins: config.trustedOrigins ?? ["*"],
hooks: {
before: createAuthMiddleware(async (ctx) => {
// await ensureOnlyOneUser(ctx);
await ensureOnlyOneUser(ctx);
await convertLegacyUserOnFirstLogin(ctx);
}),
},

View file

@ -10,6 +10,7 @@ declare module "hono" {
id: string;
username: string;
hasDownloadedResticPassword: boolean;
role?: string | null | undefined;
};
organizationId: string;
}

View file

@ -7,6 +7,10 @@ import {
systemInfoDto,
type SystemInfoDto,
type UpdateInfoDto,
setRegistrationStatusDto,
getRegistrationStatusDto,
registrationStatusBody,
type RegistrationStatusDto,
} from "./system.dto";
import { systemService } from "./system.service";
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
@ -15,6 +19,17 @@ import { organization, usersTable } from "../../db/schema";
import { eq } from "drizzle-orm";
import { verifyUserPassword } from "../auth/helpers";
import { cryptoUtils } from "../../utils/crypto";
import { createMiddleware } from "hono/factory";
const requireGlobalAdmin = createMiddleware(async (c, next) => {
const user = c.get("user");
if (!user || user.role !== "admin") {
return c.json({ message: "Forbidden" }, 403);
}
await next();
});
export const systemController = new Hono()
.use(requireAuth)
@ -28,6 +43,24 @@ export const systemController = new Hono()
return c.json<UpdateInfoDto>(updates, 200);
})
.get("/registration-status", getRegistrationStatusDto, async (c) => {
const disabled = await systemService.isRegistrationDisabled();
return c.json<RegistrationStatusDto>({ disabled }, 200);
})
.put(
"/registration-status",
requireGlobalAdmin,
setRegistrationStatusDto,
validator("json", registrationStatusBody),
async (c) => {
const body = c.req.valid("json");
await systemService.setRegistrationDisabled(body.disabled);
return c.json<RegistrationStatusDto>({ disabled: body.disabled }, 200);
},
)
.post(
"/restic-password",
requireOrgAdmin,

View file

@ -79,3 +79,45 @@ export const downloadResticPasswordDto = describeRoute({
},
},
});
export const registrationStatusResponse = type({
disabled: "boolean",
});
export type RegistrationStatusDto = typeof registrationStatusResponse.infer;
export const registrationStatusBody = type({
disabled: "boolean",
});
export const getRegistrationStatusDto = describeRoute({
description: "Get the current registration status for new users",
tags: ["System"],
operationId: "getRegistrationStatus",
responses: {
200: {
description: "Registration status",
content: {
"application/json": {
schema: resolver(registrationStatusResponse),
},
},
},
},
});
export const setRegistrationStatusDto = describeRoute({
description: "Update the registration status for new users. Requires global admin role.",
tags: ["System"],
operationId: "setRegistrationStatus",
responses: {
200: {
description: "Registration status updated",
content: {
"application/json": {
schema: resolver(registrationStatusResponse),
},
},
},
},
});

View file

@ -4,8 +4,12 @@ import type { UpdateInfoDto } from "./system.dto";
import semver from "semver";
import { cache } from "../../utils/cache";
import { logger } from "~/server/utils/logger";
import { db } from "../../db/db";
import { appMetadataTable } from "../../db/schema";
import { eq } from "drizzle-orm";
const CACHE_TTL = 60 * 60;
const REGISTRATION_DISABLED_KEY = "registrationsDisabled";
const getSystemInfo = async () => {
return {
@ -69,12 +73,7 @@ const getUpdates = async (): Promise<UpdateInfoDto> => {
? []
: formattedReleases.filter((r) => !!(semver.valid(r.version) && semver.gt(r.version, currentVersion)));
const data: UpdateInfoDto = {
currentVersion,
latestVersion,
hasUpdate,
missedReleases,
};
const data = { currentVersion, latestVersion, hasUpdate, missedReleases };
cache.set(CACHE_KEY, data, CACHE_TTL);
@ -90,7 +89,28 @@ const getUpdates = async (): Promise<UpdateInfoDto> => {
}
};
const isRegistrationDisabled = async () => {
const result = await db.query.appMetadataTable.findFirst({
where: eq(appMetadataTable.key, REGISTRATION_DISABLED_KEY),
});
return result?.value === "true";
};
const setRegistrationDisabled = async (disabled: boolean) => {
const now = Date.now();
await db
.insert(appMetadataTable)
.values({ key: REGISTRATION_DISABLED_KEY, value: JSON.stringify(disabled), createdAt: now, updatedAt: now })
.onConflictDoUpdate({ target: appMetadataTable.key, set: { value: JSON.stringify(disabled), updatedAt: now } });
logger.info(`Registration disabled set to: ${disabled}`);
};
export const systemService = {
getSystemInfo,
getUpdates,
isRegistrationDisabled,
setRegistrationDisabled,
};