mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-12 22:17:28 +02:00
✨ Add instance settings and option to disable user registration (#1745)
This commit is contained in:
parent
9e1f3c616e
commit
3c2e008579
31 changed files with 552 additions and 153 deletions
3
apps/web/declarations/next-auth.d.ts
vendored
3
apps/web/declarations/next-auth.d.ts
vendored
|
@ -1,4 +1,4 @@
|
|||
import type { TimeFormat } from "@rallly/database";
|
||||
import type { TimeFormat, UserRole } from "@rallly/database";
|
||||
import type { DefaultSession, DefaultUser } from "next-auth";
|
||||
import type { DefaultJWT } from "next-auth/jwt";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
@ -23,6 +23,7 @@ declare module "next-auth" {
|
|||
timeFormat?: TimeFormat | null;
|
||||
weekStart?: number | null;
|
||||
banned?: boolean | null;
|
||||
role?: UserRole | null;
|
||||
}
|
||||
|
||||
interface NextAuthRequest extends NextRequest {
|
||||
|
|
|
@ -386,5 +386,14 @@
|
|||
"licenseKeyErrorRateLimitExceeded": "Rate limit exceeded",
|
||||
"licenseKeyErrorInvalidLicenseKey": "Invalid license key",
|
||||
"licenseKeyGenericError": "An error occurred while validating the license key",
|
||||
"activate": "Activate"
|
||||
"activate": "Activate",
|
||||
"authErrorsRegistrationDisabled": "Registration is currently disabled. Please try again later.",
|
||||
"authErrorsEmailNotVerified": "Your email address is not verified. Please verify your email before logging in.",
|
||||
"authErrorsUserBanned": "This account has been banned. Please contact support if you believe this is an error.",
|
||||
"authErrorsEmailBlocked": "This email address is not allowed. Please use a different email or contact support.",
|
||||
"authErrorsUserNotFound": "No account found with this email address. Please check the email or register for a new account.",
|
||||
"disableUserRegistration": "Disable User Registration",
|
||||
"disableUserRegistrationDescription": "Prevent new users from registering an account.",
|
||||
"authenticationAndSecurity": "Authentication & Security",
|
||||
"authenticationAndSecurityDescription": "Manage authentication and security settings"
|
||||
}
|
||||
|
|
|
@ -6,7 +6,8 @@ export function AuthErrors() {
|
|||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
const error = searchParams?.get("error");
|
||||
if (error === "OAuthAccountNotLinked") {
|
||||
switch (error) {
|
||||
case "OAuthAccountNotLinked":
|
||||
return (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("accountNotLinkedDescription", {
|
||||
|
@ -15,7 +16,52 @@ export function AuthErrors() {
|
|||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
case "RegistrationDisabled":
|
||||
return (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("authErrorsRegistrationDisabled", {
|
||||
defaultValue:
|
||||
"Registration is currently disabled. Please try again later.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
case "EmailNotVerified":
|
||||
return (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("authErrorsEmailNotVerified", {
|
||||
defaultValue:
|
||||
"Your email address is not verified. Please verify your email before logging in.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
case "Banned":
|
||||
return (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("authErrorsUserBanned", {
|
||||
defaultValue:
|
||||
"This account has been banned. Please contact support if you believe this is an error.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
case "EmailBlocked":
|
||||
return (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("authErrorsEmailBlocked", {
|
||||
defaultValue:
|
||||
"This email address is not allowed. Please use a different email or contact support.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
case "UserNotFound":
|
||||
return (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("authErrorsUserNotFound", {
|
||||
defaultValue:
|
||||
"No account found with this email address. Please check the email or register for a new account.",
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { MicrosoftProvider } from "@/auth/providers/microsoft";
|
|||
import { OIDCProvider } from "@/auth/providers/oidc";
|
||||
import { getTranslation } from "@/i18n/server";
|
||||
|
||||
import { getInstanceSettings } from "@/features/instance-settings/queries";
|
||||
import {
|
||||
AuthPageContainer,
|
||||
AuthPageContent,
|
||||
|
@ -20,19 +21,33 @@ import { LoginWithOIDC } from "./components/login-with-oidc";
|
|||
import { OrDivider } from "./components/or-divider";
|
||||
import { SSOProvider } from "./components/sso-provider";
|
||||
|
||||
async function loadData() {
|
||||
const [instanceSettings, { t }] = await Promise.all([
|
||||
getInstanceSettings(),
|
||||
getTranslation(),
|
||||
]);
|
||||
|
||||
return {
|
||||
instanceSettings,
|
||||
t,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LoginPage(props: {
|
||||
searchParams?: Promise<{
|
||||
redirectTo?: string;
|
||||
}>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const { t } = await getTranslation();
|
||||
|
||||
const { instanceSettings, t } = await loadData();
|
||||
const oidcProvider = OIDCProvider();
|
||||
const socialProviders = [GoogleProvider(), MicrosoftProvider()].filter(
|
||||
Boolean,
|
||||
);
|
||||
const hasAlternateLoginMethods = socialProviders.length > 0 || !!oidcProvider;
|
||||
const hasAlternateLoginMethods = [...socialProviders, oidcProvider].some(
|
||||
Boolean,
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthPageContainer>
|
||||
|
@ -74,6 +89,7 @@ export default async function LoginPage(props: {
|
|||
) : null}
|
||||
</AuthPageContent>
|
||||
<AuthErrors />
|
||||
{!instanceSettings?.disableUserRegistration ? (
|
||||
<AuthPageExternal>
|
||||
<Trans
|
||||
t={t}
|
||||
|
@ -84,6 +100,7 @@ export default async function LoginPage(props: {
|
|||
}}
|
||||
/>
|
||||
</AuthPageExternal>
|
||||
) : null}
|
||||
</AuthPageContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ import { Trans } from "react-i18next/TransWithoutContext";
|
|||
|
||||
import { getTranslation } from "@/i18n/server";
|
||||
|
||||
import { getInstanceSettings } from "@/features/instance-settings/queries";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
AuthPageContainer,
|
||||
AuthPageContent,
|
||||
|
@ -18,6 +20,11 @@ export default async function Register(props: {
|
|||
}) {
|
||||
const params = await props.params;
|
||||
const { t } = await getTranslation(params.locale);
|
||||
const instanceSettings = await getInstanceSettings();
|
||||
|
||||
if (instanceSettings?.disableUserRegistration) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPageContainer>
|
||||
|
|
|
@ -49,7 +49,7 @@ export default async function Layout({
|
|||
</TopBar>
|
||||
<LicenseLimitWarning />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4 md:p-8">{children}</div>
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
<ActionBar />
|
||||
</SidebarInset>
|
||||
|
|
|
@ -17,9 +17,7 @@ export default async function AdminLayout({
|
|||
<ControlPanelSidebar />
|
||||
<SidebarInset>
|
||||
<LicenseLimitWarning />
|
||||
<div className="min-w-0 p-4 md:p-8 flex-1 flex-col flex">
|
||||
{children}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex-col flex">{children}</div>
|
||||
</SidebarInset>
|
||||
</ControlPanelSidebarProvider>
|
||||
);
|
||||
|
|
|
@ -11,7 +11,12 @@ import { getLicense } from "@/features/licensing/queries";
|
|||
import { prisma } from "@rallly/database";
|
||||
import { cn } from "@rallly/ui";
|
||||
import { Tile, TileGrid, TileTitle } from "@rallly/ui/tile";
|
||||
import { GaugeIcon, KeySquareIcon, UsersIcon } from "lucide-react";
|
||||
import {
|
||||
GaugeIcon,
|
||||
KeySquareIcon,
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
async function loadData() {
|
||||
|
@ -47,6 +52,7 @@ export default async function AdminPage() {
|
|||
<Trans i18nKey="homeNavTitle" defaults="Navigation" />
|
||||
</h2>
|
||||
<TileGrid>
|
||||
{/* USERS */}
|
||||
<Tile asChild>
|
||||
<Link href="/control-panel/users">
|
||||
<div className="flex justify-between">
|
||||
|
@ -79,6 +85,7 @@ export default async function AdminPage() {
|
|||
</div>
|
||||
</Link>
|
||||
</Tile>
|
||||
{/* LICENSE */}
|
||||
<Tile asChild>
|
||||
<Link href="/control-panel/license">
|
||||
<div className="flex justify-between">
|
||||
|
@ -100,6 +107,19 @@ export default async function AdminPage() {
|
|||
</TileTitle>
|
||||
</Link>
|
||||
</Tile>
|
||||
{/* INSTANCE SETTINGS */}
|
||||
<Tile asChild>
|
||||
<Link href="/control-panel/settings">
|
||||
<div className="flex justify-between">
|
||||
<PageIcon color="darkGray">
|
||||
<SettingsIcon />
|
||||
</PageIcon>
|
||||
</div>
|
||||
<TileTitle>
|
||||
<Trans i18nKey="settings" defaults="Settings" />
|
||||
</TileTitle>
|
||||
</Link>
|
||||
</Tile>
|
||||
</TileGrid>
|
||||
</div>
|
||||
</PageContent>
|
||||
|
|
21
apps/web/src/app/[locale]/control-panel/settings/actions.ts
Normal file
21
apps/web/src/app/[locale]/control-panel/settings/actions.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
"use server";
|
||||
|
||||
import { requireAdmin } from "@/auth/queries";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
export async function setDisableUserRegistration({
|
||||
disableUserRegistration,
|
||||
}: {
|
||||
disableUserRegistration: boolean;
|
||||
}) {
|
||||
await requireAdmin();
|
||||
|
||||
await prisma.instanceSettings.update({
|
||||
where: {
|
||||
id: 1,
|
||||
},
|
||||
data: {
|
||||
disableUserRegistration,
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { Label } from "@rallly/ui/label";
|
||||
import { Switch } from "@rallly/ui/switch";
|
||||
import { setDisableUserRegistration } from "./actions";
|
||||
|
||||
export function DisableUserRegistration({
|
||||
defaultValue,
|
||||
}: { defaultValue: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="disable-user-registration"
|
||||
onCheckedChange={(checked) => {
|
||||
setDisableUserRegistration({ disableUserRegistration: checked });
|
||||
}}
|
||||
defaultChecked={defaultValue}
|
||||
/>
|
||||
<Label htmlFor="disable-user-registration">
|
||||
<Trans
|
||||
i18nKey="disableUserRegistration"
|
||||
defaults="Disable User Registration"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm mt-2 text-muted-foreground">
|
||||
<Trans
|
||||
i18nKey="disableUserRegistrationDescription"
|
||||
defaults="Prevent new users from registering an account."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
62
apps/web/src/app/[locale]/control-panel/settings/page.tsx
Normal file
62
apps/web/src/app/[locale]/control-panel/settings/page.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { PageIcon } from "@/app/components/page-icons";
|
||||
import {
|
||||
FullWidthLayout,
|
||||
FullWidthLayoutContent,
|
||||
FullWidthLayoutHeader,
|
||||
FullWidthLayoutTitle,
|
||||
} from "@/components/full-width-layout";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { getInstanceSettings } from "@/features/instance-settings/queries";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { DisableUserRegistration } from "./disable-user-registration";
|
||||
|
||||
async function loadData() {
|
||||
const instanceSettings = await getInstanceSettings();
|
||||
|
||||
return {
|
||||
instanceSettings,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const { instanceSettings } = await loadData();
|
||||
|
||||
return (
|
||||
<FullWidthLayout>
|
||||
<FullWidthLayoutHeader>
|
||||
<FullWidthLayoutTitle
|
||||
icon={
|
||||
<PageIcon size="sm" color="darkGray">
|
||||
<SettingsIcon />
|
||||
</PageIcon>
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="settings" defaults="Settings" />
|
||||
</FullWidthLayoutTitle>
|
||||
</FullWidthLayoutHeader>
|
||||
<FullWidthLayoutContent>
|
||||
<div className="flex flex-col lg:flex-row p-6 gap-6 rounded-lg border">
|
||||
<div className="lg:w-1/2">
|
||||
<h2 className="text-base font-semibold">
|
||||
<Trans
|
||||
i18nKey="authenticationAndSecurity"
|
||||
defaults="Authentication & Security"
|
||||
/>
|
||||
</h2>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans
|
||||
i18nKey="authenticationAndSecurityDescription"
|
||||
defaults="Manage authentication and security settings"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<DisableUserRegistration
|
||||
defaultValue={instanceSettings?.disableUserRegistration}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FullWidthLayoutContent>
|
||||
</FullWidthLayout>
|
||||
);
|
||||
}
|
|
@ -10,6 +10,7 @@ import {
|
|||
ArrowLeftIcon,
|
||||
HomeIcon,
|
||||
KeySquareIcon,
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
@ -49,6 +50,10 @@ export async function ControlPanelSidebar({
|
|||
<KeySquareIcon className="size-4" />
|
||||
<Trans i18nKey="license" defaults="License" />
|
||||
</NavItem>
|
||||
<NavItem href="/control-panel/settings">
|
||||
<SettingsIcon className="size-4" />
|
||||
<Trans i18nKey="settings" defaults="Settings" />
|
||||
</NavItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import "../../style.css";
|
||||
|
||||
import { defaultLocale, supportedLngs } from "@rallly/languages";
|
||||
import { supportedLngs } from "@rallly/languages";
|
||||
import { PostHogProvider, posthog } from "@rallly/posthog/client";
|
||||
import { Toaster } from "@rallly/ui/toaster";
|
||||
import { TooltipProvider } from "@rallly/ui/tooltip";
|
||||
|
@ -15,7 +15,7 @@ import { PreferencesProvider } from "@/contexts/preferences";
|
|||
import { TimezoneProvider } from "@/features/timezone/client/context";
|
||||
import { I18nProvider } from "@/i18n/client";
|
||||
import { getLocale } from "@/i18n/server/get-locale";
|
||||
import { auth, getUserId } from "@/next-auth";
|
||||
import { auth } from "@/next-auth";
|
||||
import { TRPCProvider } from "@/trpc/client/provider";
|
||||
import { ConnectedDayjsProvider } from "@/utils/dayjs";
|
||||
|
||||
|
@ -34,25 +34,35 @@ export const viewport: Viewport = {
|
|||
initialScale: 1,
|
||||
};
|
||||
|
||||
async function loadData() {
|
||||
const [session, locale] = await Promise.all([auth(), getLocale()]);
|
||||
|
||||
const userId = session?.user?.email ? session.user.id : undefined;
|
||||
|
||||
const user = userId ? await getUser(userId) : null;
|
||||
|
||||
return {
|
||||
session,
|
||||
locale,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Root({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const { session, locale: fallbackLocale, user } = await loadData();
|
||||
|
||||
let locale = await getLocale();
|
||||
|
||||
const userId = await getUserId();
|
||||
|
||||
const user = userId ? await getUser(userId) : null;
|
||||
let locale = fallbackLocale;
|
||||
|
||||
if (user?.locale) {
|
||||
locale = user.locale;
|
||||
}
|
||||
|
||||
if (!supportedLngs.includes(locale)) {
|
||||
locale = defaultLocale;
|
||||
locale = fallbackLocale;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -8,10 +8,12 @@ import { UserDropdown } from "@/components/user-dropdown";
|
|||
import { getTranslation } from "@/i18n/server";
|
||||
import { getLoggedIn } from "@/next-auth";
|
||||
|
||||
import { getInstanceSettings } from "@/features/instance-settings/queries";
|
||||
import { BackButton } from "./back-button";
|
||||
|
||||
export default async function Page() {
|
||||
const isLoggedIn = await getLoggedIn();
|
||||
const instanceSettings = await getInstanceSettings();
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -42,6 +44,7 @@ export default async function Page() {
|
|||
<Trans i18nKey="login" defaults="Login" />
|
||||
</Link>
|
||||
</Button>
|
||||
{instanceSettings?.disableUserRegistration ? null : (
|
||||
<Button variant="primary" asChild>
|
||||
<Link
|
||||
href={`/register?redirectTo=${encodeURIComponent("/new")}`}
|
||||
|
@ -49,6 +52,7 @@ export default async function Page() {
|
|||
<Trans i18nKey="signUp" defaults="Sign up" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -29,7 +29,7 @@ const pageIconVariants = cva("inline-flex items-center justify-center", {
|
|||
purple: "bg-purple-500 text-white",
|
||||
},
|
||||
size: {
|
||||
sm: "size-6 [&_svg]:size-3 rounded-md",
|
||||
sm: "size-7 [&_svg]:size-4 rounded-md",
|
||||
md: "size-8 [&_svg]:size-5 rounded-lg",
|
||||
lg: "size-9 [&_svg]:size-5 rounded-lg",
|
||||
xl: "size-10 [&_svg]:size-5 rounded-lg",
|
||||
|
|
|
@ -8,7 +8,9 @@ export function PageContainer({
|
|||
className,
|
||||
}: React.PropsWithChildren<{ className?: string }>) {
|
||||
return (
|
||||
<div className={cn("mx-auto w-full max-w-7xl", className)}>{children}</div>
|
||||
<div className={cn("mx-auto w-full p-4 md:p-8 max-w-7xl", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
31
apps/web/src/components/full-width-layout.tsx
Normal file
31
apps/web/src/components/full-width-layout.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
export function FullWidthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
export function FullWidthLayoutHeader({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return (
|
||||
<header className="py-4 rounded-t-lg bg-background/90 backdrop-blur-sm sticky top-0 z-10 px-6 border-b">
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullWidthLayoutContent({
|
||||
children,
|
||||
}: { children: React.ReactNode }) {
|
||||
return <main className="p-6">{children}</main>;
|
||||
}
|
||||
|
||||
export function FullWidthLayoutTitle({
|
||||
children,
|
||||
icon,
|
||||
}: { children: React.ReactNode; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h1 className="text-xl font-semibold">{children}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,17 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Feature, FeatureFlagConfig } from "./types";
|
||||
|
||||
interface Features {
|
||||
storage: boolean;
|
||||
}
|
||||
|
||||
const FeatureFlagsContext = React.createContext<Features | undefined>(
|
||||
const FeatureFlagsContext = React.createContext<FeatureFlagConfig | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface FeatureFlagsProviderProps {
|
||||
value: Features;
|
||||
value: FeatureFlagConfig;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
@ -26,7 +23,7 @@ export function FeatureFlagsProvider({
|
|||
);
|
||||
}
|
||||
|
||||
export function useFeatureFlag(featureName: keyof Features): boolean {
|
||||
export function useFeatureFlag(featureName: Feature): boolean {
|
||||
const context = React.useContext(FeatureFlagsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
|
@ -35,3 +32,14 @@ export function useFeatureFlag(featureName: keyof Features): boolean {
|
|||
}
|
||||
return context[featureName] ?? false;
|
||||
}
|
||||
|
||||
export function IfFeatureEnabled({
|
||||
feature,
|
||||
children,
|
||||
}: {
|
||||
feature: Feature;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const featureEnabled = useFeatureFlag(feature);
|
||||
return featureEnabled ? children : null;
|
||||
}
|
||||
|
|
5
apps/web/src/features/feature-flags/types.ts
Normal file
5
apps/web/src/features/feature-flags/types.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface FeatureFlagConfig {
|
||||
storage: boolean;
|
||||
}
|
||||
|
||||
export type Feature = keyof FeatureFlagConfig;
|
18
apps/web/src/features/instance-settings/queries.ts
Normal file
18
apps/web/src/features/instance-settings/queries.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
"server-only";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { cache } from "react";
|
||||
|
||||
export const getInstanceSettings = cache(async () => {
|
||||
const instanceSettings = await prisma.instanceSettings.findUnique({
|
||||
where: {
|
||||
id: 1,
|
||||
},
|
||||
select: {
|
||||
disableUserRegistration: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
disableUserRegistration: instanceSettings?.disableUserRegistration ?? false,
|
||||
};
|
||||
});
|
|
@ -13,6 +13,7 @@ import {
|
|||
ArrowRightIcon,
|
||||
KeySquareIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
@ -155,6 +156,18 @@ export function CommandMenu() {
|
|||
})}
|
||||
/>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => handleSelect("/control-panel/settings")}
|
||||
>
|
||||
<PageIcon size="sm">
|
||||
<SettingsIcon />
|
||||
</PageIcon>
|
||||
<NavigationCommandLabel
|
||||
label={t("settings", {
|
||||
defaultValue: "Settings",
|
||||
})}
|
||||
/>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
|
|
|
@ -1 +1,4 @@
|
|||
export const isQuickCreateEnabled = process.env.QUICK_CREATE_ENABLED === "true";
|
||||
import { isSelfHosted } from "@/utils/constants";
|
||||
|
||||
export const isQuickCreateEnabled =
|
||||
!isSelfHosted && process.env.QUICK_CREATE_ENABLED === "true";
|
||||
|
|
|
@ -14,6 +14,7 @@ import { GuestProvider } from "./auth/providers/guest";
|
|||
import { MicrosoftProvider } from "./auth/providers/microsoft";
|
||||
import { OIDCProvider } from "./auth/providers/oidc";
|
||||
import { RegistrationTokenProvider } from "./auth/providers/registration-token";
|
||||
import { getInstanceSettings } from "./features/instance-settings/queries";
|
||||
import { nextAuthConfig } from "./next-auth.config";
|
||||
|
||||
const {
|
||||
|
@ -94,40 +95,7 @@ const {
|
|||
},
|
||||
callbacks: {
|
||||
...nextAuthConfig.callbacks,
|
||||
async signIn({ user, email, profile }) {
|
||||
const distinctId = user.id;
|
||||
// prevent sign in if email is not verified
|
||||
if (
|
||||
profile &&
|
||||
"email_verified" in profile &&
|
||||
profile.email_verified === false &&
|
||||
distinctId
|
||||
) {
|
||||
posthog?.capture({
|
||||
distinctId,
|
||||
event: "login failed",
|
||||
properties: {
|
||||
reason: "email not verified",
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.banned) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make sure email is allowed
|
||||
if (user.email) {
|
||||
if (isEmailBlocked(user.email) || (await isEmailBanned(user.email))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For now, we don't allow users to login unless they have
|
||||
// registered an account. This is just because we need a name
|
||||
// to display on the dashboard. The flow can be modified so that
|
||||
// the name is requested after the user has logged in.
|
||||
async signIn({ user, email, profile, account }) {
|
||||
if (email?.verificationRequest) {
|
||||
const isRegisteredUser =
|
||||
(await prisma.user.count({
|
||||
|
@ -135,19 +103,39 @@ const {
|
|||
email: user.email as string,
|
||||
},
|
||||
})) > 0;
|
||||
|
||||
return isRegisteredUser;
|
||||
if (!isRegisteredUser) {
|
||||
return "/login?error=EmailNotVerified";
|
||||
}
|
||||
}
|
||||
|
||||
// when we login with a social account for the first time, the user is not created yet
|
||||
// and the user id will be the same as the provider account id
|
||||
// we handle this case the the prisma adapter when we link accounts
|
||||
const isInitialSocialLogin = user.id === profile?.sub;
|
||||
if (user.banned) {
|
||||
return "/login?error=Banned";
|
||||
}
|
||||
|
||||
if (!isInitialSocialLogin) {
|
||||
// Make sure email is allowed
|
||||
const emailToTest = user.email || profile?.email;
|
||||
if (emailToTest) {
|
||||
if (isEmailBlocked(emailToTest) || (await isEmailBanned(emailToTest))) {
|
||||
return "/login?error=EmailBlocked";
|
||||
}
|
||||
}
|
||||
|
||||
const isNewUser = !user.role && profile;
|
||||
// Check for new user login with OAuth provider
|
||||
if (isNewUser) {
|
||||
// If role isn't set than the user doesn't exist yet
|
||||
// This can happen if logging in with an OAuth provider
|
||||
const instanceSettings = await getInstanceSettings();
|
||||
|
||||
if (instanceSettings?.disableUserRegistration) {
|
||||
return "/login?error=RegistrationDisabled";
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNewUser && user.id) {
|
||||
// merge guest user into newly logged in user
|
||||
const session = await auth();
|
||||
if (user.id && session?.user && !session.user.email) {
|
||||
if (session?.user && !session.user.email) {
|
||||
await mergeGuestsIntoUser(user.id, [session.user.id]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ import { getEmailClient } from "@/utils/emails";
|
|||
import { isValidName } from "@/utils/is-valid-name";
|
||||
import { createToken, decryptToken } from "@/utils/session";
|
||||
|
||||
import { getInstanceSettings } from "@/features/instance-settings/queries";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createRateLimitMiddleware, publicProcedure, router } from "../trpc";
|
||||
import type { RegistrationTokenPayload } from "../types";
|
||||
|
||||
|
@ -52,6 +54,14 @@ export const auth = router({
|
|||
| "temporaryEmailNotAllowed";
|
||||
}
|
||||
> => {
|
||||
const instanceSettings = await getInstanceSettings();
|
||||
if (instanceSettings.disableUserRegistration) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "User registration is disabled",
|
||||
});
|
||||
}
|
||||
|
||||
if (isEmailBlocked?.(input.email)) {
|
||||
return { ok: false, reason: "emailNotAllowed" };
|
||||
}
|
||||
|
|
|
@ -36,12 +36,12 @@ test.describe("Admin Setup Page Access", () => {
|
|||
test("should allow access if user is the designated initial admin (and not yet admin role)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await createUserInDb(
|
||||
INITIAL_ADMIN_TEST_EMAIL,
|
||||
"Initial Admin User",
|
||||
"user",
|
||||
);
|
||||
await loginWithEmail(page, INITIAL_ADMIN_TEST_EMAIL);
|
||||
await createUserInDb({
|
||||
email: INITIAL_ADMIN_TEST_EMAIL,
|
||||
name: "Initial Admin User",
|
||||
role: "user",
|
||||
});
|
||||
await loginWithEmail(page, { email: INITIAL_ADMIN_TEST_EMAIL });
|
||||
|
||||
await page.goto("/admin-setup");
|
||||
await expect(page).toHaveURL(/.*\/admin-setup/);
|
||||
|
@ -54,8 +54,12 @@ test.describe("Admin Setup Page Access", () => {
|
|||
test("should show 'not found' for a regular user (not initial admin, not admin role)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await createUserInDb(REGULAR_USER_EMAIL, "Regular User", "user");
|
||||
await loginWithEmail(page, REGULAR_USER_EMAIL);
|
||||
await createUserInDb({
|
||||
email: REGULAR_USER_EMAIL,
|
||||
name: "Regular User",
|
||||
role: "user",
|
||||
});
|
||||
await loginWithEmail(page, { email: REGULAR_USER_EMAIL });
|
||||
|
||||
await page.goto("/admin-setup");
|
||||
await expect(page.getByText("404 not found")).toBeVisible();
|
||||
|
@ -64,8 +68,12 @@ test.describe("Admin Setup Page Access", () => {
|
|||
test("should redirect an existing admin user to control-panel", async ({
|
||||
page,
|
||||
}) => {
|
||||
await createUserInDb(SUBSEQUENT_ADMIN_EMAIL, "Existing Admin", "admin");
|
||||
await loginWithEmail(page, SUBSEQUENT_ADMIN_EMAIL);
|
||||
await createUserInDb({
|
||||
email: SUBSEQUENT_ADMIN_EMAIL,
|
||||
name: "Existing Admin",
|
||||
role: "admin",
|
||||
});
|
||||
await loginWithEmail(page, { email: SUBSEQUENT_ADMIN_EMAIL });
|
||||
|
||||
await page.goto("/admin-setup");
|
||||
await expect(page).toHaveURL(/.*\/control-panel/);
|
||||
|
@ -74,8 +82,12 @@ test.describe("Admin Setup Page Access", () => {
|
|||
test("should show 'not found' if INITIAL_ADMIN_EMAIL in env is different from user's email", async ({
|
||||
page,
|
||||
}) => {
|
||||
await createUserInDb(OTHER_USER_EMAIL, "Other User", "user");
|
||||
await loginWithEmail(page, OTHER_USER_EMAIL);
|
||||
await createUserInDb({
|
||||
email: OTHER_USER_EMAIL,
|
||||
name: "Other User",
|
||||
role: "user",
|
||||
});
|
||||
await loginWithEmail(page, { email: OTHER_USER_EMAIL });
|
||||
|
||||
await page.goto("/admin-setup");
|
||||
await expect(page.getByText("404 not found")).toBeVisible();
|
||||
|
@ -84,12 +96,12 @@ test.describe("Admin Setup Page Access", () => {
|
|||
test("initial admin can make themselves admin using the button", async ({
|
||||
page,
|
||||
}) => {
|
||||
await createUserInDb(
|
||||
INITIAL_ADMIN_TEST_EMAIL,
|
||||
"Initial Admin To Be",
|
||||
"user",
|
||||
);
|
||||
await loginWithEmail(page, INITIAL_ADMIN_TEST_EMAIL);
|
||||
await createUserInDb({
|
||||
email: INITIAL_ADMIN_TEST_EMAIL,
|
||||
name: "Initial Admin To Be",
|
||||
role: "user",
|
||||
});
|
||||
await loginWithEmail(page, { email: INITIAL_ADMIN_TEST_EMAIL });
|
||||
|
||||
await page.goto("/admin-setup");
|
||||
await expect(page.getByText("Are you the admin?")).toBeVisible();
|
||||
|
|
|
@ -4,21 +4,21 @@ import { load } from "cheerio";
|
|||
|
||||
import { captureEmailHTML } from "./mailpit/mailpit";
|
||||
import { RegisterPage } from "./register-page";
|
||||
import { createUserInDb, loginWithEmail } from "./test-utils";
|
||||
import { getCode } from "./utils";
|
||||
|
||||
const testUserEmail = "test@example.com";
|
||||
const testExistingUserEmail = "existing-user-for-disabled-test@example.com";
|
||||
|
||||
test.describe.serial(() => {
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
email: testUserEmail,
|
||||
email: {
|
||||
in: [testUserEmail, testExistingUserEmail],
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// User doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("new user", () => {
|
||||
|
@ -140,4 +140,36 @@ test.describe.serial(() => {
|
|||
await expect(page.getByText("Test User")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("when user registration is disabled", () => {
|
||||
test.beforeAll(async () => {
|
||||
await prisma.instanceSettings.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
disableUserRegistration: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await prisma.instanceSettings.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
disableUserRegistration: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("allows existing user to log in via email", async ({ page }) => {
|
||||
await createUserInDb({
|
||||
email: testExistingUserEmail,
|
||||
name: "Existing User",
|
||||
role: "user",
|
||||
});
|
||||
|
||||
await loginWithEmail(page, { email: testExistingUserEmail });
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { prisma } from "@rallly/database";
|
||||
import { type UserRole, prisma } from "@rallly/database";
|
||||
import { LoginPage } from "./login-page";
|
||||
|
||||
export async function createUserInDb(
|
||||
email: string,
|
||||
name: string,
|
||||
role: "user" | "admin" = "user",
|
||||
) {
|
||||
export async function createUserInDb({
|
||||
email,
|
||||
name,
|
||||
role = "user",
|
||||
}: {
|
||||
email: string;
|
||||
name: string;
|
||||
role?: UserRole;
|
||||
}) {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
|
@ -19,7 +23,7 @@ export async function createUserInDb(
|
|||
});
|
||||
}
|
||||
|
||||
export async function loginWithEmail(page: Page, email: string) {
|
||||
export async function loginWithEmail(page: Page, { email }: { email: string }) {
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login({
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "instance_settings" (
|
||||
"id" INTEGER NOT NULL DEFAULT 1,
|
||||
"disable_user_registration" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "instance_settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Create default instance settings
|
||||
INSERT INTO "instance_settings" ("id", "disable_user_registration") VALUES (1, false);
|
|
@ -0,0 +1,16 @@
|
|||
ALTER TABLE "instance_settings"
|
||||
ADD CONSTRAINT instance_settings_singleton CHECK (id = 1);
|
||||
|
||||
CREATE OR REPLACE FUNCTION prevent_delete_instance_settings()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.id = 1 THEN
|
||||
RAISE EXCEPTION 'Deleting the instance_settings record (id=1) is not permitted.';
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_prevent_instance_settings_deletion
|
||||
BEFORE DELETE ON instance_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_delete_instance_settings();
|
10
packages/database/prisma/models/instance-settings.prisma
Normal file
10
packages/database/prisma/models/instance-settings.prisma
Normal file
|
@ -0,0 +1,10 @@
|
|||
model InstanceSettings {
|
||||
id Int @id @default(1)
|
||||
// Authentication & Security
|
||||
disableUserRegistration Boolean @default(false) @map("disable_user_registration")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
|
||||
@@map("instance_settings")
|
||||
}
|
|
@ -38,7 +38,6 @@ model LicenseValidation {
|
|||
@@map("license_validations")
|
||||
}
|
||||
|
||||
|
||||
model InstanceLicense {
|
||||
id String @id @default(cuid())
|
||||
licenseKey String @unique @map("license_key")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue