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, getBackupScheduleForVolume,
getMirrorCompatibility, getMirrorCompatibility,
getNotificationDestination, getNotificationDestination,
getRegistrationStatus,
getRepository, getRepository,
getScheduleMirrors, getScheduleMirrors,
getScheduleNotifications, getScheduleNotifications,
@ -44,6 +45,7 @@ import {
restoreSnapshot, restoreSnapshot,
runBackupNow, runBackupNow,
runForget, runForget,
setRegistrationStatus,
stopBackup, stopBackup,
tagSnapshots, tagSnapshots,
testConnection, testConnection,
@ -91,6 +93,8 @@ import type {
GetMirrorCompatibilityResponse, GetMirrorCompatibilityResponse,
GetNotificationDestinationData, GetNotificationDestinationData,
GetNotificationDestinationResponse, GetNotificationDestinationResponse,
GetRegistrationStatusData,
GetRegistrationStatusResponse,
GetRepositoryData, GetRepositoryData,
GetRepositoryResponse, GetRepositoryResponse,
GetScheduleMirrorsData, GetScheduleMirrorsData,
@ -135,6 +139,8 @@ import type {
RunBackupNowResponse, RunBackupNowResponse,
RunForgetData, RunForgetData,
RunForgetResponse, RunForgetResponse,
SetRegistrationStatusData,
SetRegistrationStatusResponse,
StopBackupData, StopBackupData,
StopBackupResponse, StopBackupResponse,
TagSnapshotsData, TagSnapshotsData,
@ -1259,6 +1265,54 @@ export const getUpdatesOptions = (options?: Options<GetUpdatesData>) =>
queryKey: getUpdatesQueryKey(options), 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. * 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, getBackupScheduleForVolume,
getMirrorCompatibility, getMirrorCompatibility,
getNotificationDestination, getNotificationDestination,
getRegistrationStatus,
getRepository, getRepository,
getScheduleMirrors, getScheduleMirrors,
getScheduleNotifications, getScheduleNotifications,
@ -41,6 +42,7 @@ export {
restoreSnapshot, restoreSnapshot,
runBackupNow, runBackupNow,
runForget, runForget,
setRegistrationStatus,
stopBackup, stopBackup,
tagSnapshots, tagSnapshots,
testConnection, testConnection,
@ -108,6 +110,9 @@ export type {
GetNotificationDestinationErrors, GetNotificationDestinationErrors,
GetNotificationDestinationResponse, GetNotificationDestinationResponse,
GetNotificationDestinationResponses, GetNotificationDestinationResponses,
GetRegistrationStatusData,
GetRegistrationStatusResponse,
GetRegistrationStatusResponses,
GetRepositoryData, GetRepositoryData,
GetRepositoryResponse, GetRepositoryResponse,
GetRepositoryResponses, GetRepositoryResponses,
@ -176,6 +181,9 @@ export type {
RunForgetData, RunForgetData,
RunForgetResponse, RunForgetResponse,
RunForgetResponses, RunForgetResponses,
SetRegistrationStatusData,
SetRegistrationStatusResponse,
SetRegistrationStatusResponses,
StopBackupData, StopBackupData,
StopBackupErrors, StopBackupErrors,
StopBackupResponse, StopBackupResponse,

View file

@ -39,6 +39,8 @@ import type {
GetNotificationDestinationData, GetNotificationDestinationData,
GetNotificationDestinationErrors, GetNotificationDestinationErrors,
GetNotificationDestinationResponses, GetNotificationDestinationResponses,
GetRegistrationStatusData,
GetRegistrationStatusResponses,
GetRepositoryData, GetRepositoryData,
GetRepositoryResponses, GetRepositoryResponses,
GetScheduleMirrorsData, GetScheduleMirrorsData,
@ -85,6 +87,8 @@ import type {
RunBackupNowResponses, RunBackupNowResponses,
RunForgetData, RunForgetData,
RunForgetResponses, RunForgetResponses,
SetRegistrationStatusData,
SetRegistrationStatusResponses,
StopBackupData, StopBackupData,
StopBackupErrors, StopBackupErrors,
StopBackupResponses, StopBackupResponses,
@ -707,6 +711,32 @@ export const getUpdates = <ThrowOnError extends boolean = false>(options?: Optio
...options, ...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. * 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 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 = { export type DownloadResticPasswordData = {
body?: { body?: {
password: string; password: string;

View file

@ -86,7 +86,14 @@ export default function OnboardingPage() {
void navigate("/download-recovery-key"); void navigate("/download-recovery-key");
} else if (error) { } else if (error) {
console.error(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 { useMutation, useQuery } from "@tanstack/react-query";
import { Download, KeyRound, User, X } from "lucide-react"; import { Download, KeyRound, User, X, Users } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { toast } from "sonner"; 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 { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { import {
@ -17,6 +21,7 @@ import {
} from "~/client/components/ui/dialog"; } from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input"; import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label"; import { Label } from "~/client/components/ui/label";
import { Switch } from "~/client/components/ui/switch";
import { authClient } from "~/client/lib/auth-client"; import { authClient } from "~/client/lib/auth-client";
import { appContext } from "~/context"; import { appContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section"; import { TwoFactorSection } from "../components/two-factor-section";
@ -50,6 +55,25 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
const [isChangingPassword, setIsChangingPassword] = useState(false); const [isChangingPassword, setIsChangingPassword] = useState(false);
const navigate = useNavigate(); 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 () => { const handleLogout = async () => {
await authClient.signOut({ await authClient.signOut({
@ -145,6 +169,35 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
return ( return (
<Card className="p-0 gap-0"> <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"> <div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<User className="size-5" /> <User className="size-5" />

View file

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

View file

@ -1,17 +1,25 @@
import { db } from "~/server/db/db"; import { db } from "~/server/db/db";
import type { AuthMiddlewareContext } from "../auth"; import type { AuthMiddlewareContext } from "../auth";
import { logger } from "~/server/utils/logger"; 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) => { export const ensureOnlyOneUser = async (ctx: AuthMiddlewareContext) => {
const { path } = ctx; const { path } = ctx;
const existingUser = await db.query.usersTable.findFirst();
if (path !== "/sign-up/email") { if (path !== "/sign-up/email") {
return; return;
} }
const existingUser = await db.query.usersTable.findFirst(); const result = await db.query.appMetadataTable.findFirst({
if (existingUser) { where: eq(appMetadataTable.key, REGISTRATION_DISABLED_KEY),
logger.error("Attempt to create a second administrator account blocked."); });
throw new Error("An administrator account already exists");
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 { db } from "../server/db/db";
import { cryptoUtils } from "../server/utils/crypto"; import { cryptoUtils } from "../server/utils/crypto";
import { organization as organizationTable, member } from "../server/db/schema"; import { organization as organizationTable, member } from "../server/db/schema";
import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>; export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
@ -22,7 +23,7 @@ const createBetterAuth = (secret: string) =>
trustedOrigins: config.trustedOrigins ?? ["*"], trustedOrigins: config.trustedOrigins ?? ["*"],
hooks: { hooks: {
before: createAuthMiddleware(async (ctx) => { before: createAuthMiddleware(async (ctx) => {
// await ensureOnlyOneUser(ctx); await ensureOnlyOneUser(ctx);
await convertLegacyUserOnFirstLogin(ctx); await convertLegacyUserOnFirstLogin(ctx);
}), }),
}, },

View file

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

View file

@ -7,6 +7,10 @@ import {
systemInfoDto, systemInfoDto,
type SystemInfoDto, type SystemInfoDto,
type UpdateInfoDto, type UpdateInfoDto,
setRegistrationStatusDto,
getRegistrationStatusDto,
registrationStatusBody,
type RegistrationStatusDto,
} from "./system.dto"; } from "./system.dto";
import { systemService } from "./system.service"; import { systemService } from "./system.service";
import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware"; import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware";
@ -15,6 +19,17 @@ import { organization, usersTable } from "../../db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { verifyUserPassword } from "../auth/helpers"; import { verifyUserPassword } from "../auth/helpers";
import { cryptoUtils } from "../../utils/crypto"; 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() export const systemController = new Hono()
.use(requireAuth) .use(requireAuth)
@ -28,6 +43,24 @@ export const systemController = new Hono()
return c.json<UpdateInfoDto>(updates, 200); 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( .post(
"/restic-password", "/restic-password",
requireOrgAdmin, 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 semver from "semver";
import { cache } from "../../utils/cache"; import { cache } from "../../utils/cache";
import { logger } from "~/server/utils/logger"; 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 CACHE_TTL = 60 * 60;
const REGISTRATION_DISABLED_KEY = "registrationsDisabled";
const getSystemInfo = async () => { const getSystemInfo = async () => {
return { return {
@ -69,12 +73,7 @@ const getUpdates = async (): Promise<UpdateInfoDto> => {
? [] ? []
: formattedReleases.filter((r) => !!(semver.valid(r.version) && semver.gt(r.version, currentVersion))); : formattedReleases.filter((r) => !!(semver.valid(r.version) && semver.gt(r.version, currentVersion)));
const data: UpdateInfoDto = { const data = { currentVersion, latestVersion, hasUpdate, missedReleases };
currentVersion,
latestVersion,
hasUpdate,
missedReleases,
};
cache.set(CACHE_KEY, data, CACHE_TTL); 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 = { export const systemService = {
getSystemInfo, getSystemInfo,
getUpdates, getUpdates,
isRegistrationDisabled,
setRegistrationDisabled,
}; };