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,
|
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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue