diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 4709971b..f29ecb61 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -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) => queryKey: getUpdatesQueryKey(options), }); +export const getRegistrationStatusQueryKey = (options?: Options) => + createQueryKey("getRegistrationStatus", options); + +/** + * Get the current registration status for new users + */ +export const getRegistrationStatusOptions = (options?: Options) => + queryOptions< + GetRegistrationStatusResponse, + DefaultError, + GetRegistrationStatusResponse, + ReturnType + >({ + 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>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions< + SetRegistrationStatusResponse, + DefaultError, + Options + > = { + 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. */ diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index bee3869e..38a8eb3b 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -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, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 97befa52..7e570675 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -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 = (options?: Optio ...options, }); +/** + * Get the current registration status for new users + */ +export const getRegistrationStatus = ( + options?: Options, +) => + (options?.client ?? client).get({ + url: "/api/v1/system/registration-status", + ...options, + }); + +/** + * Update the registration status for new users. Requires global admin role. + */ +export const setRegistrationStatus = ( + options?: Options, +) => + (options?.client ?? client).put({ + 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. */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index cd506b9c..f2666606 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -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; diff --git a/app/client/modules/auth/routes/onboarding.tsx b/app/client/modules/auth/routes/onboarding.tsx index 6290b598..2ff3b754 100644 --- a/app/client/modules/auth/routes/onboarding.tsx +++ b/app/client/modules/auth/routes/onboarding.tsx @@ -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 }); + } } }; diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index f06054da..ae949f8e 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -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 ( + {isAdmin && ( +
+ + + System Settings + + Manage system-wide settings +
+ )} + {isAdmin && ( + +
+
+ +

+ When enabled, new users cannot sign up. Only administrators can create accounts manually. +

+
+ updateRegistrationStatusMutation.mutate({ body: { disabled: checked } })} + disabled={registrationStatusQuery.isLoading || updateRegistrationStatusMutation.isPending} + /> +
+
+ )}
diff --git a/app/context.ts b/app/context.ts index 17a8ce0e..95367d2d 100644 --- a/app/context.ts +++ b/app/context.ts @@ -6,6 +6,7 @@ type User = { username: string; hasDownloadedResticPassword: boolean; twoFactorEnabled?: boolean | null; + role?: string | null | undefined; }; type AppContext = { diff --git a/app/lib/auth-middlewares/only-one-user.ts b/app/lib/auth-middlewares/only-one-user.ts index 92c09a06..0fbc237e 100644 --- a/app/lib/auth-middlewares/only-one-user.ts +++ b/app/lib/auth-middlewares/only-one-user.ts @@ -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."); } }; diff --git a/app/lib/auth.ts b/app/lib/auth.ts index ace02e3d..da324aec 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -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>; @@ -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); }), }, diff --git a/app/server/modules/auth/auth.middleware.ts b/app/server/modules/auth/auth.middleware.ts index 6456f7c3..b211c8b8 100644 --- a/app/server/modules/auth/auth.middleware.ts +++ b/app/server/modules/auth/auth.middleware.ts @@ -10,6 +10,7 @@ declare module "hono" { id: string; username: string; hasDownloadedResticPassword: boolean; + role?: string | null | undefined; }; organizationId: string; } diff --git a/app/server/modules/system/system.controller.ts b/app/server/modules/system/system.controller.ts index cfd5d724..e6123bdc 100644 --- a/app/server/modules/system/system.controller.ts +++ b/app/server/modules/system/system.controller.ts @@ -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(updates, 200); }) + .get("/registration-status", getRegistrationStatusDto, async (c) => { + const disabled = await systemService.isRegistrationDisabled(); + + return c.json({ 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({ disabled: body.disabled }, 200); + }, + ) .post( "/restic-password", requireOrgAdmin, diff --git a/app/server/modules/system/system.dto.ts b/app/server/modules/system/system.dto.ts index 312e53b7..061e9406 100644 --- a/app/server/modules/system/system.dto.ts +++ b/app/server/modules/system/system.dto.ts @@ -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), + }, + }, + }, + }, +}); diff --git a/app/server/modules/system/system.service.ts b/app/server/modules/system/system.service.ts index a89b4c1f..aca5ba41 100644 --- a/app/server/modules/system/system.service.ts +++ b/app/server/modules/system/system.service.ts @@ -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 => { ? [] : 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 => { } }; +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, };