feat: disable registrations
This commit is contained in:
parent
23acae1121
commit
a2432c2e58
13 changed files with 311 additions and 15 deletions
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ type User = {
|
|||
username: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
twoFactorEnabled?: boolean | null;
|
||||
role?: string | null | undefined;
|
||||
};
|
||||
|
||||
type AppContext = {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ declare module "hono" {
|
|||
id: string;
|
||||
username: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
role?: string | null | undefined;
|
||||
};
|
||||
organizationId: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue