Self-Hosting Update (#842)

This commit is contained in:
Luke Vella 2023-09-11 15:34:55 +01:00 committed by GitHub
parent 3e616d1e41
commit 7a5f9ae474
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 945 additions and 781 deletions

View file

@ -37,3 +37,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ github.ref_name }}
SELF_HOSTED=true

View file

@ -39,3 +39,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APP_VERSION=${{ github.ref_name }}
SELF_HOSTED=true

View file

@ -6,18 +6,15 @@ description: Help fund this project and keep it ad-free
## Why should I donate?
> Rallly is available free and without ads.
> But free software doesn't mean it's without cost.
> Someone has to develop, maintain, and support it.
> With the help of generous people like you, we can keep Rallly ad-free for everyone and continue to make it even better.
>
> -- [Luke Vella](https://twitter.com/imlukevella), Creator of Rallly
Most users of Rallly use it for free, and that's great!
However, running Rallly costs money.
By donating, you can help keep Rallly running and ad-free.
## How can I donate?
You can donate using any of the following methods:
<CardGroup cols={2}>
<CardGroup cols={3}>
<Card
title="Donate with PayPal"
icon="paypal"

View file

@ -12,13 +12,13 @@
"anchors": { "from": "#878AFA", "to": "#4347F1" }
},
"topbarCtaButton": {
"name": "Get Started",
"url": "https://rallly.co/new"
"name": "Go to App",
"url": "https://app.rallly.co"
},
"topbarLinks": [
{
"name": "Contact",
"url": "/contact/support"
"name": "Home",
"url": "https://rallly.co"
}
],
"anchors": [
@ -63,6 +63,7 @@
"group": "Self-Hosting",
"pages": [
"self-hosting/introduction",
"self-hosting/pricing",
"self-hosting/managed-hosting",
"self-hosting/configuration-options"
]

View file

@ -1,4 +1,5 @@
---
icon: gear
title: Configuration Options
description: Using environment variable to configure a self-hosted instance of Rallly.
---
@ -12,33 +13,39 @@ This page lists all environment variables supported by Rallly.
These variables need to be configured for Rallly to run and function properly.
<ParamField path="DATABASE_URL" default="">
<ParamField path="DATABASE_URL" required>
Postgres database connection string
</ParamField>
<ParamField path="NEXT_PUBLIC_BASE_URL" default="http://localhost:3000">
<ParamField path="NEXT_PUBLIC_BASE_URL" required>
The base url where this instance is accessible, including the scheme (eg.
`http://` or `https://`), the domain name, and optionally a port.
</ParamField>
<ParamField path="SECRET_PASSWORD">
<ParamField path="SECRET_PASSWORD" required>
A random 32-character secret key used to encrypt user sessions
</ParamField>
<ParamField path="ALLOWED_EMAILS">
Comma separated list of email addresses that are allowed to register and
login. Wildcard characters are supported. Example: Setting it to
`*@example.com` to allow anyone with a `@example.com` email address.
</ParamField>
### Email Configuration
These variables need to be configured to let Rallly send out transactional emails.
<ParamField path="NOREPLY_EMAIL" default="">
<ParamField path="NOREPLY_EMAIL">
This email is used as the sender for all transactional emails. If not set,
`SUPPORT_EMAIL` will be used instead.
</ParamField>
<ParamField path="SUPPORT_EMAIL" default="">
<ParamField path="SUPPORT_EMAIL" required>
This email will be shown as the contact email for support queries.
</ParamField>
<ParamField path="SMTP_HOST" default="localhost">
<ParamField path="SMTP_HOST" required>
The host address of your SMTP server
</ParamField>
@ -46,7 +53,7 @@ These variables need to be configured to let Rallly send out transactional email
The port of your SMTP server
</ParamField>
<ParamField path="SMTP_SECURE" default={"false"}>
<ParamField path="SMTP_SECURE" default="false">
Set to "true" if SSL is enabled for your SMTP connection
</ParamField>
@ -61,21 +68,3 @@ These variables need to be configured to let Rallly send out transactional email
<ParamField path="SMTP_TLS_ENABLED" default={"false"}>
Enable TLS for your SMTP connection
</ParamField>
### Custom Configuration
These variables allow you to change Rallly's default behavior.
<ParamField path="ALLOWED_EMAILS" default="">
Comma separated list of email addresses that are allowed to register and
login. Wildcard characters are supported. Example: `*@yourcompany.com`
</ParamField>
<ParamField path="AUTH_REQUIRED" default={"false"}>
Set to `true` to require authentication for creating new polls and accessing
admin pages
</ParamField>
<ParamField path="DISABLE_LANDING_PAGE" default={"false"}>
Whether or not to disable the landing page
</ParamField>

View file

@ -1,13 +1,29 @@
---
icon: server
title: Introduction
description: How to run your own instance of Rallly.
description: How to Self-Host Rallly
---
## What is Self-Hosting?
Rallly is 100% open-source and available under the [GNU Affero General Public License v3.0 (AGPL-3.0)](https://github.com/lukevella/rallly/blob/main/LICENSE)
which allows you to run your own instance of Rallly for free for both personal and commercial use.
Self-hosting refers to the practice of running your own instance of Rallly on your own server.
## Official Docker Image
The main advantage of self-hosting is the autonomy it affords, but it also requires more technical knowledge and maintenance, as well as the potential security risks of managing your own infrastructure.
The best way to self-host Rallly is using the [official Docker image](https://hub.docker.com/r/lukevella/rallly).
This image contains a build that is specifically intended for self-hosting.
It is updated regularly but it is _not_ guaranteed to be up-to-date with the latest version of Rallly.
If you want to have access to the latest features and bug fixes, you should consider using the [official managed service](https://rallly.co).
<Note>
Though it is technically possible to build and run Rallly from its
[source-code](https://github.com/lukevella/rallly), it is not recommended and
we do not provide support for this.
</Note>
## Pricing
Rallly is **completely free** to self-host but for users who wish to contribute to the project,
please check out the [pricing](/self-hosting/pricing) page.
## Get Started
@ -22,7 +38,7 @@ Depending on how comfortable you are with technical things, you can either run R
Host your own instance of Rallly on your own server using Docker.
</Card>
<Card
icon="server"
icon="cloud"
title="Using a hosting provider"
href="/self-hosting/managed-hosting"
>

View file

@ -1,4 +1,5 @@
---
icon: cloud
title: Managed Hosting
description: Using a managed hosting service to self-host Rallly.
---

View file

@ -0,0 +1,55 @@
---
icon: coins
title: Pricing
description: How much does it cost to self-host Rallly?
---
Rallly is **completely free** to self-host.
But free software doesnt mean its without cost.
Someone has to develop, maintain, and support it.
If you find Rallly useful you can help support further development by paying a one-time fee.
## Suggested Price
We believe $42 USD is a very fair price for the time and effort that's gone into making this product.
It's also the cost of a yearly subscription to the [official managed service](https://rallly.co).
If you find Rallly useful and can afford to pay, please consider paying this amount.
<CardGroup cols={1}>
<Card
title="Pay $42 USD"
icon="cart-shopping"
href="https://buy.stripe.com/9AQ0411zW9BZ5nqaEK"
>
One-time payment for Rallly Self-Hosted
</Card>
</CardGroup>
## Pay What You Want
Alternatively, you can choose to pay **any amount** you want. Your support is appreciated regardless of how much you are able to pay.
<CardGroup cols={3}>
<Card
icon="dollar-sign"
title="Pay in USD"
href="https://buy.stripe.com/5kAdURdiEeWj5nqeV4"
>
One-time payment
</Card>
<Card
icon="euro-sign"
title="Pay in EUR"
href="https://buy.stripe.com/aEU2c92E0aG33fi7sA"
>
One-time payment
</Card>
<Card
icon="sterling-sign"
title="Pay in GBP"
href="https://buy.stripe.com/fZeeYV2E0bK7g246ox"
>
One-time payment
</Card>
</CardGroup>

View file

@ -64,10 +64,6 @@ declare global {
* Example: "user@example.com, *@example.com, *@*.example.com"
*/
ALLOWED_EMAILS?: string;
/**
* "true" to require authentication for creating new polls and accessing admin pages
*/
AUTH_REQUIRED?: string;
/**
* Determines what email provider to use. "smtp" or "ses"
*/

View file

@ -36,8 +36,7 @@
"react-i18next": "^12.1.4",
"react-use": "^17.4.0",
"remark": "^14.0.3",
"remark-html": "^15.0.2",
"typescript": "^4.9.4"
"remark-html": "^15.0.2"
},
"devDependencies": {
"@next/bundle-analyzer": "^12.3.4",
@ -50,11 +49,8 @@
"@types/react-dom": "^18.0.11",
"@types/react-linkify": "^1.0.1",
"@types/smoothscroll-polyfill": "^0.3.1",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.50.0",
"cheerio": "^1.0.0-rc.12",
"cross-env": "^7.0.3",
"eslint": "^7.26.0",
"eslint-config-next": "^13.0.1",
"eslint-config-turbo": "^0.0.9",
"eslint-import-resolver-typescript": "^2.7.0",

View file

@ -21,6 +21,9 @@ RUN yarn db:generate
ARG APP_VERSION
ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION
ARG SELF_HOSTED
ENV NEXT_PUBLIC_SELF_HOSTED=$SELF_HOSTED
RUN yarn build
FROM node:18 AS runner
@ -48,4 +51,7 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
ARG SELF_HOSTED
ENV NEXT_PUBLIC_SELF_HOSTED=$SELF_HOSTED
CMD ["./docker-start.sh"]

View file

@ -33,10 +33,6 @@ declare global {
* Crisp website ID
*/
NEXT_PUBLIC_CRISP_WEBSITE_ID?: string;
/**
* When `true` it will show the feedback button and pull the changelog from featurebase
*/
NEXT_PUBLIC_FEEDBACK_ENABLED?: string;
/**
* Users of your instance will see this as their support email
*/
@ -68,10 +64,6 @@ declare global {
* Example: "user@example.com, *@example.com, *@*.example.com"
*/
ALLOWED_EMAILS?: string;
/**
* "true" to require authentication for creating new polls and accessing admin pages
*/
AUTH_REQUIRED?: string;
/**
* Determines what email provider to use. "smtp" or "ses"
*/

View file

@ -33,6 +33,7 @@
"@tailwindcss/typography": "^0.5.9",
"@tanstack/react-table": "^8.9.1",
"@vercel/analytics": "^0.1.8",
"@vercel/og": "^0.5.13",
"accept-language-parser": "^1.5.0",
"autoprefixer": "^10.4.13",
"class-variance-authority": "^0.6.0",
@ -67,7 +68,6 @@
"spacetime": "^7.4.7",
"superjson": "^1.12.2",
"timezone-soft": "^1.4.1",
"typescript": "^4.9.4",
"zod": "^3.20.2"
},
"devDependencies": {
@ -81,11 +81,8 @@
"@types/react-dom": "^18.0.11",
"@types/react-linkify": "^1.0.1",
"@types/smoothscroll-polyfill": "^0.3.1",
"@typescript-eslint/eslint-plugin": "^5.21.0",
"@typescript-eslint/parser": "^5.50.0",
"cheerio": "^1.0.0-rc.12",
"cross-env": "^7.0.3",
"eslint": "^7.26.0",
"eslint-config-next": "^13.0.1",
"eslint-config-turbo": "^0.0.9",
"eslint-import-resolver-typescript": "^2.7.0",

View file

@ -103,7 +103,6 @@
"addComment": "Add Comment",
"profile": "Profile",
"polls": "Polls",
"showLess": "Show less…",
"showMore": "Show more…",
"timeZoneSelect__defaultValue": "Select time zone…",
"timeZoneSelect__noOption": "No option found",
@ -205,14 +204,8 @@
"duplicateTitleDescription": "Hint: Give your new poll a unique title",
"proFeature": "Pro Feature",
"upgradeOverlaySubtitle2": "Please upgrade to a paid plan to use this feature. This is how we keep the lights on :)",
"savePercent": "Save {percent}%",
"priceIncreaseSoon": "Price increase soon.",
"lockPrice": "Upgrade today to keep this price forever.",
"noAds": "No ads",
"upgrade": "Upgrade",
"notToday": "Not Today",
"supportProject": "Support this project",
"features": "Get access to all current and future Pro features!",
"continueAsGuest": "Continue as Guest",
"scrollLeft": "Scroll Left",
"scrollRight": "Scroll Right",
@ -231,5 +224,12 @@
"accessAllFeatures": "Access all features",
"earlyAccess": "Get early access to new features",
"earlyAdopterDescription": "As an early adopter, you'll lock in your subscription rate and won't be affected by future price increases.",
"upgradeNowSaveLater": "Upgrade now, save later"
"upgradeNowSaveLater": "Upgrade now, save later",
"savePercent": "Save {percent}%",
"priceIncreaseSoon": "Price increase soon.",
"lockPrice": "Upgrade today to keep this price forever.",
"features": "Get access to all current and future Pro features!",
"noAds": "No ads",
"supportProject": "Support this project",
"pricing": "Pricing"
}

View file

@ -1,25 +1,21 @@
import Image from "next/image";
import Link from "next/link";
import { Trans } from "next-i18next";
import React from "react";
import { Logo } from "@/components/logo";
import { IfCloudHosted } from "@/contexts/environment";
export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
return (
<div className="h-full p-3 sm:p-8">
<div className="mx-auto max-w-lg">
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
<div className="bg-pattern border-t-primary-600 border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8">
<Image
src="/static/logo.svg"
height={30}
width={150}
alt="Rallly"
className="text-primary-600 inline-block h-7"
/>
<div className="bg-pattern border-t-primary-600 flex justify-center border-b border-t-4 bg-gray-500/5 p-4 text-center sm:p-8">
<Logo />
</div>
<div className="p-4 sm:p-6">{children}</div>
</div>
{process.env.AUTH_REQUIRED === "true" ? null : (
<IfCloudHosted>
<p className="mt-8 text-center">
<Link
href="/polls"
@ -28,7 +24,7 @@ export const AuthLayout = ({ children }: { children?: React.ReactNode }) => {
<Trans i18nKey="continueAsGuest" defaults="Continue as Guest" />
</Link>
</p>
)}
</IfCloudHosted>
</div>
</div>
);

View file

@ -141,7 +141,7 @@ export const BillingPlans = () => {
</div>
</Tabs>
<div className="rounded-md border border-cyan-200 bg-cyan-50 px-4 py-3 text-cyan-800">
<div className="mb-2 flex items-start justify-between">
<div className="mb-2">
<TrendingUpIcon className="text-indigo mr-2 mt-0.5 h-6 w-6 shrink-0" />
</div>
<div className="mb-2 flex items-center gap-x-2">

View file

@ -13,7 +13,7 @@ const FeaturebaseScript = () => (
<Script src="https://do.featurebase.app/js/sdk.js" id="featurebase-sdk" />
);
export const Changelog = ({ className }: { className?: string }) => {
export const FeaturebaseChangelog = ({ className }: { className?: string }) => {
React.useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window as any;
@ -37,7 +37,7 @@ export const Changelog = ({ className }: { className?: string }) => {
<>
<FeaturebaseScript />
<Tooltip>
<TooltipTrigger>
<TooltipTrigger asChild>
<Button
className={cn(
"hidden sm:inline-flex [&>*]:pointer-events-none",

View file

@ -1,16 +1,26 @@
import { CreditCardIcon, Settings2Icon, UserIcon } from "@rallly/icons";
import {
CreditCardIcon,
MenuIcon,
Settings2Icon,
UserIcon,
} from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import { Card } from "@rallly/ui/card";
import clsx from "clsx";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
import { Trans } from "react-i18next";
import { useToggle } from "react-use";
import { Container } from "@/components/container";
import { StandardLayout } from "@/components/layouts/standard-layout";
import { IfCloudHosted } from "@/contexts/environment";
import { Plan } from "@/contexts/plan";
import { IconComponent, NextPageWithLayout } from "../../types";
import { IfAuthenticated } from "../user-provider";
import { useUser } from "../user-provider";
const MenuItem = (props: {
icon: IconComponent;
@ -21,40 +31,69 @@ const MenuItem = (props: {
return (
<Link
className={clsx(
"flex items-center gap-x-2.5 text-sm font-medium",
"flex min-w-0 items-center gap-x-2.5 px-2.5 py-1.5 text-sm font-medium",
router.asPath === props.href
? "text-foreground"
: "text-gray-500 hover:text-gray-800",
? "bg-gray-200"
: "text-gray-500 hover:bg-gray-100 hover:text-gray-800",
)}
href={props.href}
>
<props.icon className="h-5 w-5" />
{props.children}
<props.icon className="h-4 w-4 shrink-0" />
<span className="truncate">{props.children}</span>
</Link>
);
};
export const ProfileLayout = ({ children }: React.PropsWithChildren) => {
const { user } = useUser();
// reset toggle whenever route changes
const router = useRouter();
const [isMenuOpen, toggle] = useToggle(false);
React.useEffect(() => {
toggle(false);
}, [router.asPath, toggle]);
return (
<div>
<Container className="p-2 sm:py-8">
<Card className="mx-auto max-w-4xl overflow-hidden">
<div className="flex gap-4 gap-x-6 border-b bg-gray-50 px-3 py-4 md:px-4">
<IfAuthenticated>
<MenuItem href="/settings/profile" icon={UserIcon}>
<Trans i18nKey="profile" defaults="Profile" />
</MenuItem>
</IfAuthenticated>
<MenuItem href="/settings/preferences" icon={Settings2Icon}>
<Trans i18nKey="preferences" defaults="Preferences" />
</MenuItem>
<IfAuthenticated>
<MenuItem href="/settings/billing" icon={CreditCardIcon}>
<Trans i18nKey="billing" defaults="Billing" />
</MenuItem>
</IfAuthenticated>
<Card className="mx-auto flex flex-col overflow-hidden md:min-h-[600px]">
<div className="border-b bg-gray-50 p-3 md:hidden">
<Button onClick={toggle} icon={MenuIcon} />
</div>
<div className="relative flex grow md:divide-x">
<div
className={cn(
"absolute inset-0 z-10 grow bg-gray-50 md:static md:block md:shrink-0 md:grow-0 md:basis-56 md:px-5 md:py-4",
{
hidden: !isMenuOpen,
},
)}
>
<div className="grid gap-1">
<div className="flex items-center justify-between gap-x-2.5 gap-y-2 p-3">
<div className="truncate text-sm font-semibold">
{user.name}
</div>
<Plan />
</div>
<MenuItem href="/settings/profile" icon={UserIcon}>
<Trans i18nKey="profile" defaults="Profile" />
</MenuItem>
<MenuItem href="/settings/preferences" icon={Settings2Icon}>
<Trans i18nKey="preferences" defaults="Preferences" />
</MenuItem>
<IfCloudHosted>
<MenuItem href="/settings/billing" icon={CreditCardIcon}>
<Trans i18nKey="billing" defaults="Billing" />
</MenuItem>
</IfCloudHosted>
</div>
</div>
<div className="max-w-2xl grow">{children}</div>
</div>
{children}
</Card>
</Container>
</div>

View file

@ -1,9 +1,8 @@
import { ListIcon, LogInIcon, SparklesIcon } from "@rallly/icons";
import { ListIcon, SparklesIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Button } from "@rallly/ui/button";
import clsx from "clsx";
import { AnimatePresence, m } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
@ -11,13 +10,18 @@ import { Toaster } from "react-hot-toast";
import { Clock, ClockPreferences } from "@/components/clock";
import { Container } from "@/components/container";
import { Changelog, FeaturebaseIdentify } from "@/components/featurebase";
import {
FeaturebaseChangelog,
FeaturebaseIdentify,
} from "@/components/featurebase";
import FeedbackButton from "@/components/feedback";
import { Logo } from "@/components/logo";
import { Spinner } from "@/components/spinner";
import { Trans } from "@/components/trans";
import { UserDropdown } from "@/components/user-dropdown";
import { IfCloudHosted } from "@/contexts/environment";
import { IfFreeUser } from "@/contexts/plan";
import { isFeedbackEnabled } from "@/utils/constants";
import { appVersion, isFeedbackEnabled } from "@/utils/constants";
import { DayjsProvider } from "@/utils/dayjs";
import { IconComponent, NextPageWithLayout } from "../../types";
@ -73,7 +77,7 @@ const Upgrade = () => {
);
};
const Logo = () => {
const LogoArea = () => {
const router = useRouter();
const [isBusy, setIsBusy] = React.useState(false);
React.useEffect(() => {
@ -87,20 +91,14 @@ const Logo = () => {
};
}, [router.events]);
return (
<div className="relative flex items-center justify-center gap-4">
<div className="relative flex items-center justify-center gap-x-4">
<Link
href="/polls"
className={clsx(
"inline-block transition-transform active:translate-y-1",
)}
>
<Image
priority={true}
src="/static/logo.svg"
width={120}
height={22}
alt="Rallly"
/>
<Logo size="sm" />
</Link>
<div
className={cn(
@ -114,6 +112,10 @@ const Logo = () => {
);
};
const Changelog = () => {
return <FeaturebaseChangelog />;
};
const MainNav = () => {
return (
<m.div
@ -127,8 +129,8 @@ const MainNav = () => {
className="border-b bg-gray-50/50"
>
<Container className="flex h-14 items-center justify-between gap-x-2.5">
<div className="flex shrink-0">
<Logo />
<div className="flex shrink-0 gap-x-4">
<LogoArea />
<nav className="hidden gap-x-2 sm:flex">
<NavMenuItem
icon={ListIcon}
@ -137,11 +139,13 @@ const MainNav = () => {
/>
</nav>
</div>
<div className="flex items-center gap-x-4">
<div className="flex items-center gap-x-2.5">
<nav className="flex items-center gap-x-1 sm:gap-x-1.5">
<IfFreeUser>
<Upgrade />
</IfFreeUser>
<IfCloudHosted>
<IfFreeUser>
<Upgrade />
</IfFreeUser>
</IfCloudHosted>
<IfGuest>
<Button
size="sm"
@ -150,12 +154,13 @@ const MainNav = () => {
className="hidden sm:flex"
>
<Link href="/login">
<LogInIcon className="h-4 w-4" />
<Trans i18nKey="login" defaults="Login" />
</Link>
</Button>
</IfGuest>
<Changelog />
<IfCloudHosted>
<Changelog />
</IfCloudHosted>
<ClockPreferences>
<Button size="sm" variant="ghost">
<Clock />
@ -198,6 +203,17 @@ export const StandardLayout: React.FunctionComponent<{
{children}
</m.div>
</AnimatePresence>
{appVersion ? (
<div className="fixed bottom-0 right-0 z-50 rounded-tl-md bg-gray-200/90">
<Link
className="px-2 py-1 text-xs tabular-nums tracking-tight"
target="_blank"
href={`https://github.com/lukevella/rallly/releases/${appVersion}`}
>
{`${appVersion}`}
</Link>
</div>
) : null}
</div>
{isFeedbackEnabled ? (
<>

View file

@ -1,25 +1,25 @@
import clsx from "clsx";
import Image from "next/image";
export const Logo = (props: { className?: string; color?: boolean }) => {
const { color = true } = props;
const sizes = {
sm: {
width: 120,
height: 22,
},
md: {
width: 150,
height: 30,
},
};
export const Logo = ({ size = "md" }: { size?: keyof typeof sizes }) => {
return (
<span className="inline-flex select-none items-center">
<span
className={clsx(
"font-semibold uppercase tracking-widest",
{
"text-primary-600": color,
},
props.className,
)}
>
Rallly
</span>
{process.env.NEXT_PUBLIC_BETA === "1" ? (
<span className="ml-2 inline-block rounded bg-rose-500 px-1 text-xs lowercase tracking-tight text-gray-50">
beta
</span>
) : null}
</span>
<Image
priority={true}
className="mx"
src="/static/logo.svg"
width={sizes[size].width}
height={sizes[size].height}
alt="Rallly"
/>
);
};

View file

@ -1,70 +0,0 @@
import { Logo } from "./logo";
const OpenBeta = () => {
return (
<div>
<div className="bg-pattern border-b px-4 py-8 text-center text-2xl">
<Logo />
</div>
<div className="max-w-3xl p-3 sm:p-6">
<div>
<p className="mb-4">
The open beta allows you to test out new features before they are
officially released to the general public. By participating you,
will have the opportunity to provide feedback and help shape the
future of Rallly!
</p>
</div>
<div>
<h2 className="mb-4">Feedback</h2>
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2">
<a
href="https://github.com/lukevella/rallly/issues/new?assignees=&labels=bug&template=---bug-report.md&title="
className="hover:text-primary-600 rounded border p-3"
>
🐞 Submit a bug report
</a>
<a
href="https://github.com/lukevella/rallly/discussions/new/choose"
className="hover:text-primary-600 rounded border p-3"
>
📢 Open a discussion with the community
</a>
<a
href="https://discord.gg/uzg4ZcHbuM"
className="hover:text-primary-600 rounded border p-3"
>
💬 Chat on Discord
</a>
<a
href="mailto:feedback@rallly.co"
className="hover:text-primary-600 rounded border p-3"
>
Send an email
</a>
</ul>
<div className="mt-4 rounded border bg-gray-50 p-4">
<h2 className="mb-4 text-gray-800">Important</h2>
<p className="mb-4">
<strong>
You should not rely on the beta for any important data or
information.
</strong>
</p>
<p className="mb-4">
The beta should be used exclusively for testing purposes.
Features, polls, accounts, or data may be removed at any time
without prior notice.
</p>
<p className="m-0">
Any data or information saved on the beta website cannot be
accessed on the production website and vice versa.
</p>
</div>
</div>
</div>
</div>
);
};
export default OpenBeta;

View file

@ -1,15 +0,0 @@
export const SettingsSection = (props: {
title: React.ReactNode;
description: React.ReactNode;
children: React.ReactNode;
}) => {
return (
<div className="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-4 p-3 sm:p-6 md:grid-cols-3">
<div>
<h2 className="text-base font-semibold">{props.title}</h2>
<p className="mt-1 text-sm text-gray-500">{props.description}</p>
</div>
<div className="md:col-span-2">{props.children}</div>
</div>
);
};

View file

@ -0,0 +1,70 @@
import { InfoIcon } from "@rallly/icons";
import { cn } from "@rallly/ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip";
export const Settings = ({ children }: React.PropsWithChildren) => {
return <div className="px-4 py-3 md:p-6">{children}</div>;
};
export const SettingsHeader = ({ children }: React.PropsWithChildren) => {
return (
<div className="mb-4 md:mb-8">
<h2>{children}</h2>
</div>
);
};
export const SettingsContent = ({ children }: React.PropsWithChildren) => {
return <div className="space-y-8">{children}</div>;
};
export const SettingsSection = (props: {
title: React.ReactNode;
description: React.ReactNode;
children: React.ReactNode;
}) => {
return (
<div className="grid gap-3 md:gap-4">
<div>
<h2 className="mb-1 text-base font-semibold">{props.title}</h2>
<p className="text-muted-foreground text-sm">{props.description}</p>
</div>
<div>{props.children}</div>
</div>
);
};
export const SettingsGroup = ({ children }: React.PropsWithChildren) => {
return <dl className="grid gap-3 md:gap-6">{children}</dl>;
};
export const SettingsItem = ({ children }: React.PropsWithChildren) => {
return <div className="grid gap-1.5">{children}</div>;
};
export const SettingsItemTitle = ({
children,
hint,
}: React.PropsWithChildren<{ hint?: React.ReactNode }>) => {
return (
<div className="flex items-center gap-x-2">
<dt className="text-sm font-medium text-gray-500">{children}</dt>
{hint ? (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="inline-block h-4 w-4 text-gray-500" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-sm">
{hint}
</TooltipContent>
</Tooltip>
) : null}
</div>
);
};
export const SettingsItemValue = ({
children,
className,
}: React.PropsWithChildren<{ className?: string }>) => {
return <dd className={cn("text-sm text-gray-900", className)}>{children}</dd>;
};

View file

@ -1,39 +0,0 @@
import React from "react";
import { Trans } from "@/components/trans";
export const TextSummary = ({
max = Infinity,
text,
}: {
text: string;
max?: number;
}) => {
const [isExpanded, setExpanded] = React.useState(false);
if (text.length <= max) {
return <p>{text}</p>;
}
const summary = text.substring(0, text.lastIndexOf(" ", max)) + "…";
return (
<>
<p className="leading-relaxed text-gray-600">
{isExpanded ? <>{text}</> : <>{summary}</>}
</p>
{isExpanded ? (
<button
className="mt-2 border px-1 text-sm font-medium tracking-tight hover:text-gray-800 hover:underline active:text-gray-900"
onClick={() => setExpanded(false)}
>
<Trans defaults="Show less…" i18nKey="showLess" />
</button>
) : (
<button
className="mt-2 border px-1 text-sm font-medium tracking-tight hover:text-gray-800 hover:underline active:text-gray-900"
onClick={() => setExpanded(true)}
>
<Trans defaults="Show more…" i18nKey="showMore" />
</button>
)}
</>
);
};

View file

@ -12,20 +12,36 @@ export const UpgradeButton = ({
const posthog = usePostHog();
return (
<Button
className="w-full"
variant="primary"
asChild
onClick={() => {
posthog?.capture("click upgrade button");
}}
>
<Link
href={`/api/stripe/checkout?period=${
annual ? "yearly" : "monthly"
}&return_path=${encodeURIComponent(window.location.pathname)}`}
<form method="POST" action="/api/stripe/checkout">
<input
type="hidden"
name="period"
value={annual ? "yearly" : "monthly"}
/>
<input
type="hidden"
name="return_path"
value={encodeURIComponent(window.location.pathname)}
/>
<Button
className="w-full"
type="submit"
variant="primary"
onClick={() => {
posthog?.capture("click upgrade button");
}}
>
{children || <Trans i18nKey="upgrade" defaults="Upgrade" />}
</Button>
</form>
);
};
export const UpgradeLink = ({}) => {
return (
<Button variant="primary" asChild>
<Link href="/settings/billing">
<Trans i18nKey="upgrade" defaults="Upgrade" />
</Link>
</Button>
);

View file

@ -1,6 +1,7 @@
import {
ChevronDown,
CreditCardIcon,
GemIcon,
LifeBuoyIcon,
ListIcon,
LogInIcon,
@ -24,6 +25,7 @@ import Link from "next/link";
import { Trans } from "@/components/trans";
import { CurrentUserAvatar } from "@/components/user";
import { IfCloudHosted, IfSelfHosted } from "@/contexts/environment";
import { Plan } from "@/contexts/plan";
import { isFeedbackEnabled } from "@/utils/constants";
@ -34,7 +36,7 @@ export const UserDropdown = () => {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild className="group">
<Button className="rounded-full">
<Button variant="ghost" className="rounded-full">
<CurrentUserAvatar size="sm" className="-ml-1" />
<ChevronDown className="h-4 w-4" />
</Button>
@ -52,17 +54,18 @@ export const UserDropdown = () => {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<IfAuthenticated>
<DropdownMenuItem asChild={true}>
<Link
href="/settings/profile"
className="flex items-center gap-x-2"
>
<UserIcon className="h-4 w-4" />
<Trans i18nKey="profile" defaults="Profile" />
</Link>
</DropdownMenuItem>
</IfAuthenticated>
<DropdownMenuItem asChild={true}>
<Link href="/polls" className="flex items-center gap-x-2 sm:hidden">
<ListIcon className="h-4 w-4" />
<Trans i18nKey="polls" defaults="Polls" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild={true}>
<Link href="/settings/profile" className="flex items-center gap-x-2">
<UserIcon className="h-4 w-4" />
<Trans i18nKey="profile" defaults="Profile" />
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild={true}>
<Link
href="/settings/preferences"
@ -72,7 +75,7 @@ export const UserDropdown = () => {
<Trans i18nKey="preferences" defaults="Preferences" />
</Link>
</DropdownMenuItem>
<IfAuthenticated>
<IfCloudHosted>
<DropdownMenuItem asChild={true}>
<Link
href="/settings/billing"
@ -82,13 +85,7 @@ export const UserDropdown = () => {
<Trans i18nKey="Billing" defaults="Billing" />
</Link>
</DropdownMenuItem>
</IfAuthenticated>
<DropdownMenuItem asChild={true}>
<Link href="/polls" className="flex items-center gap-x-2 sm:hidden">
<ListIcon className="h-4 w-4" />
<Trans i18nKey="polls" defaults="Polls" />
</Link>
</DropdownMenuItem>
</IfCloudHosted>
<DropdownMenuSeparator />
<DropdownMenuItem asChild={true}>
<Link
@ -100,6 +97,18 @@ export const UserDropdown = () => {
<Trans i18nKey="support" defaults="Support" />
</Link>
</DropdownMenuItem>
<IfSelfHosted>
<DropdownMenuItem asChild={true}>
<Link
target="_blank"
href="https://support.rallly.co/self-hosting/pricing"
className="flex items-center gap-x-2"
>
<GemIcon className="h-4 w-4" />
<Trans i18nKey="pricing" defaults="Pricing" />
</Link>
</DropdownMenuItem>
</IfSelfHosted>
{isFeedbackEnabled ? (
<DropdownMenuItem asChild={true}>
<Link

View file

@ -8,7 +8,7 @@ import { useWhoAmI } from "@/contexts/whoami";
import { useRequiredContext } from "./use-required-context";
export const UserContext = React.createContext<{
user: UserSession & { shortName: string };
user: UserSession & { name: string };
refresh: () => void;
ownsObject: (obj: { userId: string | null }) => boolean;
} | null>(null);
@ -50,23 +50,22 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
const queryClient = trpc.useContext();
const user = useWhoAmI();
const subscriptionQuery = trpc.user.subscription.useQuery();
const { data: userPreferences } = trpc.userPreferences.get.useQuery();
const shortName = user
const name = user
? user.isGuest === false
? user.name.split(" ")[0]
? user.name
: user.id.substring(0, 10)
: t("guest");
if (!user || userPreferences === undefined || !subscriptionQuery.isFetched) {
if (!user || userPreferences === undefined) {
return null;
}
return (
<UserContext.Provider
value={{
user: { ...user, shortName },
user: { ...user, name },
refresh: () => {
return queryClient.whoami.invalidate();
},

View file

@ -0,0 +1,9 @@
export const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === "true";
export const IfSelfHosted = ({ children }: React.PropsWithChildren) => {
return isSelfHosted ? <>{children}</> : null;
};
export const IfCloudHosted = ({ children }: React.PropsWithChildren) => {
return isSelfHosted ? null : <>{children}</>;
};

View file

@ -3,9 +3,33 @@ import { Badge } from "@rallly/ui/badge";
import React from "react";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { isSelfHosted } from "@/utils/constants";
export const useSubscription = () => {
const { user } = useUser();
const { data } = trpc.user.subscription.useQuery(undefined, {
enabled: !isSelfHosted && user.isGuest === false,
});
if (isSelfHosted) {
return {
active: true,
};
}
if (user.isGuest) {
return {
active: false,
};
}
return data;
};
export const usePlan = () => {
const { data } = trpc.user.subscription.useQuery();
const data = useSubscription();
const isPaid = data?.active === true;
@ -34,6 +58,7 @@ export const Plan = () => {
</Badge>
);
}
return (
<Badge variant="secondary">
<Trans i18nKey="planFree" defaults="Free" />

View file

@ -10,26 +10,37 @@ const publicPaths = ["/login", "/register", "/invite", "/auth"];
// these paths always require authentication
const protectedPaths = ["/settings/billing", "/settings/profile"];
const checkLoginRequirements = async (req: NextRequest, res: NextResponse) => {
const session = await getSession(req, res);
const isGuest = session.user?.isGuest !== false;
if (!isGuest) {
// already logged in
return false;
}
// TODO (Luke Vella) [2023-09-11]: We should handle this on the client-side
if (process.env.NEXT_PUBLIC_SELF_HOSTED === "true") {
// when self-hosting, only public paths don't require login
return !publicPaths.some((publicPath) =>
req.nextUrl.pathname.startsWith(publicPath),
);
} else {
// when using the hosted version, only protected paths require login
return protectedPaths.some((protectedPath) =>
req.nextUrl.pathname.includes(protectedPath),
);
}
};
export async function middleware(req: NextRequest) {
const { headers, cookies, nextUrl } = req;
const newUrl = nextUrl.clone();
const res = NextResponse.next();
const session = await getSession(req, res);
// a protected path is one that requires to be logged in
const isProtectedPath = protectedPaths.some((protectedPath) =>
req.nextUrl.pathname.includes(protectedPath),
);
const isLoginRequired = await checkLoginRequirements(req, res);
const isProtectedPathDueToRequiredAuth =
process.env.AUTH_REQUIRED === "true" &&
!publicPaths.some((publicPath) =>
req.nextUrl.pathname.startsWith(publicPath),
);
const isGuest = session.user?.isGuest !== false;
if (isGuest && (isProtectedPathDueToRequiredAuth || isProtectedPath)) {
if (isLoginRequired) {
newUrl.pathname = "/login";
newUrl.searchParams.set("redirect", req.nextUrl.pathname);
return NextResponse.redirect(newUrl);

View file

@ -59,7 +59,7 @@ const Page: NextPageWithLayout<{ userId: string; pollId: string }> = ({
<ArrowRightIcon className="text-muted-foreground h-4 w-4" />
<div className="flex items-center gap-x-2.5">
<CurrentUserAvatar />
<div>{user.shortName}</div>
<div>{user.name}</div>
</div>
</div>
</PageDialogContent>

View file

@ -20,17 +20,19 @@ export default async function handler(
res: NextApiResponse,
) {
const userSession = await getSession(req, res);
const { period = "monthly", return_path } = inputSchema.parse(req.body);
if (userSession.user?.isGuest !== false) {
// You need to be logged in to subscribe
return res
.status(403)
.redirect(
`/login${req.url ? `?redirect=${encodeURIComponent(req.url)}` : ""}`,
);
}
res.redirect(
303,
`/login${
return_path ? `?redirect=${encodeURIComponent(return_path)}` : ""
}`,
);
const { period = "monthly", return_path } = inputSchema.parse(req.query);
return;
}
const user = await prisma.user.findUnique({
where: {
@ -48,12 +50,14 @@ export default async function handler(
});
if (!user) {
return res.status(403).redirect("/logout");
res.redirect(303, "/logout");
return;
}
if (user.subscription?.active === true) {
// User already has an active subscription. Take them to customer portal
return res.redirect("/api/stripe/portal");
res.redirect(303, "/api/stripe/portal");
return;
}
const session = await stripe.checkout.sessions.create({
@ -99,10 +103,11 @@ export default async function handler(
if (session.url) {
// redirect to checkout session
return res.status(303).redirect(session.url);
res.redirect(303, session.url);
return;
}
return res
res
.status(500)
.json({ error: "Something went wrong while creating a checkout session" });
}

View file

@ -18,11 +18,12 @@ export default async function handler(
if (userSession.user?.isGuest !== false) {
// You need to be logged in to subscribe
return res
res
.status(403)
.redirect(
`/login${req.url ? `?redirect=${encodeURIComponent(req.url)}` : ""}`,
);
return;
}
const user = await prisma.user.findUnique({
@ -36,7 +37,8 @@ export default async function handler(
});
if (!user) {
return res.status(403).redirect("/logout");
res.status(403).redirect("/logout");
return;
}
const { session_id: sessionId, return_path } = inputSchema.parse(req.query);

View file

@ -35,15 +35,18 @@ export default async function handler(
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).end();
res.status(405).end();
return;
}
if (!endpointSecret) {
return res.status(400).send("No endpoint secret");
res.status(400).send("No endpoint secret");
return;
}
const event = await validatedWebhook(req);
if (!event) {
return res.status(400).send("Invalid signature");
res.status(400).send("Invalid signature");
return;
}
switch (event.type) {
@ -52,7 +55,8 @@ export default async function handler(
const { userId } = metadataSchema.parse(checkoutSession.metadata);
if (!userId) {
return res.status(400).send("Missing client reference ID");
res.status(400).send("Missing client reference ID");
return;
}
await prisma.user.update({

View file

@ -1,19 +1,28 @@
import { trpc } from "@rallly/backend";
import { ArrowUpRight, CreditCardIcon } from "@rallly/icons";
import { ArrowUpRight, CreditCardIcon, SendIcon } from "@rallly/icons";
import { Button } from "@rallly/ui/button";
import { Card } from "@rallly/ui/card";
import { Label } from "@rallly/ui/label";
import dayjs from "dayjs";
import { GetStaticProps } from "next";
import Head from "next/head";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { BillingPlans } from "@/components/billing/billing-plans";
import { getProfileLayout } from "@/components/layouts/profile-layout";
import { SettingsSection } from "@/components/settings/settings-section";
import {
Settings,
SettingsContent,
SettingsGroup,
SettingsHeader,
SettingsItem,
SettingsItemTitle,
SettingsSection,
} from "@/components/settings/settings";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { Plan } from "@/contexts/plan";
import { useSubscription } from "@/contexts/plan";
import { isSelfHosted } from "@/utils/constants";
import { NextPageWithLayout } from "../../types";
import { getStaticTranslations } from "../../utils/with-page-translations";
@ -28,9 +37,13 @@ declare global {
const BillingPortal = () => {
return (
<div>
<Label className="mb-2">
<Trans i18nKey="billingPortal" />
</Label>
<SettingsGroup>
<SettingsItem>
<SettingsItemTitle>
<Trans i18nKey="billingStatus" />
</SettingsItemTitle>
</SettingsItem>
</SettingsGroup>
<p className="text-sm">
<Trans
i18nKey="activeSubscription"
@ -40,10 +53,12 @@ const BillingPortal = () => {
<div className="mt-6">
<Button asChild>
<Link
target="_blank"
href={`/api/stripe/portal?return_path=${encodeURIComponent(
window.location.pathname,
)}`}
>
<CreditCardIcon className="h-4 w-4" />
<span>
<Trans i18nKey="billingPortal" defaults="Billing Portal" />
</span>
@ -62,28 +77,14 @@ export const proPlanIdYearly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
const SubscriptionStatus = () => {
const { user } = useUser();
const { data } = trpc.user.subscription.useQuery();
const data = useSubscription();
if (!data) {
return null;
}
if (user.isGuest) {
return <>You need to be logged in.</>;
}
return (
<div className="space-y-6">
<div>
<Label className="mb-2">
<Trans i18nKey="currentPlan" />
</Label>
<div>
<Plan />
</div>
</div>
{!data.active ? (
<div>
<Label className="mb-4">
@ -214,54 +215,89 @@ const LegacyBilling = () => {
);
};
<SettingsSection
title={<Trans i18nKey="support" defaults="Support" />}
description={
<Trans i18nKey="supportDescription" defaults="Need help with anything?" />
}
>
<div className="space-y-6">
<p className="text-sm">
<Trans
i18nKey="supportBilling"
defaults="Please reach out if you need any assistance."
/>
</p>
<Button asChild>
<Link href="mailto:support@rallly.co">
<SendIcon className="h-4 w-4" />
<Trans i18nKey="contactSupport" defaults="Contact Support" />
</Link>
</Button>
</div>
</SettingsSection>;
const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<div className="divide-y">
<Settings>
<SettingsHeader>
<Trans i18nKey="billing" />
</SettingsHeader>
<Head>
<title>{t("billing")}</title>
</Head>
<SettingsSection
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
description={
<Trans
i18nKey="billingStatusDescription"
defaults="Manage your subscription and billing details."
/>
}
>
<SubscriptionStatus />
</SettingsSection>
<SettingsSection
title={<Trans i18nKey="support" defaults="Support" />}
description={
<Trans
i18nKey="supportDescription"
defaults="Need help with anything?"
/>
}
>
<div className="space-y-6">
<p className="text-sm">
<SettingsContent>
<SettingsSection
title={<Trans i18nKey="billingStatus" defaults="Billing Status" />}
description={
<Trans
i18nKey="supportBilling"
defaults="Please reach out if you need any assistance."
i18nKey="billingStatusDescription"
defaults="Manage your subscription and billing details."
/>
</p>
<Button asChild>
<Link href="mailto:support@rallly.co">
<Trans i18nKey="contactSupport" defaults="Contact Support" />
</Link>
</Button>
</div>
</SettingsSection>
</div>
}
>
<SubscriptionStatus />
</SettingsSection>
<SettingsSection
title={<Trans i18nKey="support" defaults="Support" />}
description={
<Trans
i18nKey="supportDescription"
defaults="Need help with anything?"
/>
}
>
<div className="space-y-6">
<p className="text-sm">
<Trans
i18nKey="supportBilling"
defaults="Please reach out if you need any assistance."
/>
</p>
<Button asChild>
<Link href="mailto:support@rallly.co">
<SendIcon className="h-4 w-4" />
<Trans i18nKey="contactSupport" defaults="Contact Support" />
</Link>
</Button>
</div>
</SettingsSection>
</SettingsContent>
</Settings>
);
};
Page.getLayout = getProfileLayout;
export const getStaticProps = getStaticTranslations;
export const getStaticProps: GetStaticProps = async (ctx) => {
if (isSelfHosted) {
return {
notFound: true,
};
}
return await getStaticTranslations(ctx);
};
export default Page;

View file

@ -4,7 +4,12 @@ import { useTranslation } from "next-i18next";
import { getProfileLayout } from "@/components/layouts/profile-layout";
import { DateTimePreferences } from "@/components/settings/date-time-preferences";
import { LanguagePreference } from "@/components/settings/language-preference";
import { SettingsSection } from "@/components/settings/settings-section";
import {
Settings,
SettingsContent,
SettingsHeader,
SettingsSection,
} from "@/components/settings/settings";
import { Trans } from "@/components/trans";
import { NextPageWithLayout } from "../../types";
@ -14,33 +19,38 @@ const Page: NextPageWithLayout = () => {
const { t } = useTranslation();
return (
<div className="divide-y">
<Head>
<title>{t("settings")}</title>
</Head>
<SettingsSection
title={<Trans i18nKey="language" defaults="Language" />}
description={
<Trans
i18nKey="languageDescription"
defaults="Change your preferred language"
/>
}
>
<LanguagePreference />
</SettingsSection>
<SettingsSection
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
description={
<Trans
i18nKey="dateAndTimeDescription"
defaults="Change your preferred date and time settings"
/>
}
>
<DateTimePreferences />
</SettingsSection>
</div>
<Settings>
<SettingsHeader>
<Trans i18nKey="preferences" />
</SettingsHeader>
<SettingsContent>
<Head>
<title>{t("settings")}</title>
</Head>
<SettingsSection
title={<Trans i18nKey="language" defaults="Language" />}
description={
<Trans
i18nKey="languageDescription"
defaults="Change your preferred language"
/>
}
>
<LanguagePreference />
</SettingsSection>
<SettingsSection
title={<Trans i18nKey="dateAndTime" defaults="Date & Time" />}
description={
<Trans
i18nKey="dateAndTimeDescription"
defaults="Change your preferred date and time settings"
/>
}
>
<DateTimePreferences />
</SettingsSection>
</SettingsContent>
</Settings>
);
};

View file

@ -3,7 +3,11 @@ import { useTranslation } from "next-i18next";
import { getProfileLayout } from "@/components/layouts/profile-layout";
import { ProfileSettings } from "@/components/settings/profile-settings";
import { SettingsSection } from "@/components/settings/settings-section";
import {
Settings,
SettingsHeader,
SettingsSection,
} from "@/components/settings/settings";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
@ -18,10 +22,13 @@ const Page: NextPageWithLayout = () => {
return null;
}
return (
<div className="divide-y">
<Settings>
<Head>
<title>{t("settings")}</title>
<title>{t("profile")}</title>
</Head>
<SettingsHeader>
<Trans i18nKey="profile" />
</SettingsHeader>
<SettingsSection
title={<Trans i18nKey="profile" defaults="Profile" />}
description={
@ -59,7 +66,7 @@ const Page: NextPageWithLayout = () => {
<Trans i18nKey="deleteMyAccount" defaults="Yes, delete my account" />
</Button>
</SettingsSection> */}
</div>
</Settings>
);
};

View file

@ -4,8 +4,11 @@ export const planIdMonthly = process.env
export const planIdYearly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
export const isFeedbackEnabled =
process.env.NEXT_PUBLIC_FEEDBACK_ENABLED === "true";
export const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === "true";
export const isFeedbackEnabled = !isSelfHosted;
export const monthlyPriceUsd = 7;
export const annualPriceUsd = 42;
export const appVersion = process.env.NEXT_PUBLIC_APP_VERSION;

View file

@ -15,6 +15,8 @@ services:
rallly:
build:
args:
- SELF_HOSTED=true
context: .
dockerfile: ./apps/web/Dockerfile
restart: always

View file

@ -28,10 +28,14 @@
"dependencies": {
"next": "^13.2.4",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"typescript": "^5.2.2"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"dotenv-cli": "^7.1.0",
"eslint": "^8.48.0",
"prettier": "^2.8.4",
"turbo": "^1.10.7"
},

View file

@ -2,7 +2,7 @@ export const sessionConfig = {
password: process.env.SECRET_PASSWORD ?? "",
cookieName: "rallly-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
secure: process.env.NEXT_PUBLIC_BASE_URL?.startsWith("https://") ?? false,
},
ttl: 60 * 60 * 24 * 30, // 30 days
};

View file

@ -19,7 +19,13 @@ export async function createContext(
opts.req.session.user = user;
await opts.req.session.save();
}
return { user, session: opts.req.session, req: opts.req, res: opts.res };
return {
user,
session: opts.req.session,
req: opts.req,
res: opts.res,
isSelfHosted: process.env.NEXT_PUBLIC_SELF_HOSTED === "true",
};
}
export type Context = trpc.inferAsyncReturnType<typeof createContext>;

View file

@ -9,7 +9,6 @@ import utc from "dayjs/plugin/utc";
import * as ics from "ics";
import { z } from "zod";
import { getSubscriptionStatus } from "../../utils/auth";
import { getTimeZoneAbbreviation } from "../../utils/date";
import { nanoid } from "../../utils/nanoid";
import {
@ -74,8 +73,6 @@ export const polls = router({
const participantUrlId = nanoid();
const pollId = nanoid();
const { active: isPro } = await getSubscriptionStatus(ctx.user.id);
const poll = await prisma.poll.create({
select: {
adminUrlId: true,
@ -117,13 +114,9 @@ export const polls = router({
})),
},
},
...(isPro
? {
hideParticipants: input.hideParticipants,
disableComments: input.disableComments,
hideScores: input.hideScores,
}
: undefined),
hideParticipants: input.hideParticipants,
disableComments: input.disableComments,
hideScores: input.hideScores,
},
});
@ -171,11 +164,9 @@ export const polls = router({
hideScores: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
.mutation(async ({ input }) => {
const pollId = await getPollIdFromAdminUrlId(input.urlId);
const { active: isPro } = await getSubscriptionStatus(ctx.user.id);
if (input.optionsToDelete && input.optionsToDelete.length > 0) {
await prisma.option.deleteMany({
where: {
@ -218,13 +209,9 @@ export const polls = router({
description: input.description,
timeZone: input.timeZone,
closed: input.closed,
...(isPro
? {
hideScores: input.hideScores,
hideParticipants: input.hideParticipants,
disableComments: input.disableComments,
}
: undefined),
hideScores: input.hideScores,
hideParticipants: input.hideParticipants,
disableComments: input.disableComments,
},
});
}),

View file

@ -19,7 +19,8 @@ export const middleware = t.middleware;
export const possiblyPublicProcedure = t.procedure.use(
middleware(async ({ ctx, next }) => {
if (process.env.AUTH_REQUIRED === "true" && ctx.user.isGuest) {
// On self-hosted instances, these procedures require login
if (ctx.isSelfHosted && ctx.user.isGuest) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Login is required",
@ -38,6 +39,11 @@ export const proProcedure = t.procedure.use(
});
}
if (ctx.isSelfHosted) {
// Self-hosted instances don't have paid subscriptions
return next();
}
const { active: isPro } = await getSubscriptionStatus(ctx.user.id);
if (!isPro) {

View file

@ -18,8 +18,8 @@ export const getSubscriptionStatus = async (userId: string) => {
if (user?.subscription?.active === true) {
return {
active: true,
expiresAt: user.subscription.periodEnd,
};
legacy: false,
} as const;
}
const userPaymentData = await prisma.userPaymentData.findFirst({
@ -34,18 +34,14 @@ export const getSubscriptionStatus = async (userId: string) => {
},
});
if (
userPaymentData?.endDate &&
userPaymentData.endDate.getTime() > Date.now()
) {
if (userPaymentData) {
return {
active: true,
legacy: true,
expiresAt: userPaymentData.endDate,
};
} as const;
}
return {
active: false,
};
} as const;
};

View file

@ -19,7 +19,6 @@
"@rallly/tsconfig": "*",
"@types/node": "^18.15.10",
"prisma": "^5.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.0.2"
"ts-node": "^10.9.1"
}
}

View file

@ -68,10 +68,6 @@ declare global {
* Example: "user@example.com, *@example.com, *@*.example.com"
*/
ALLOWED_EMAILS?: string;
/**
* "true" to require authentication for creating new polls and accessing admin pages
*/
AUTH_REQUIRED?: string;
/**
* Determines what email provider to use. "smtp" or "ses"
*/

View file

@ -26,12 +26,8 @@ SMTP_PWD=
# OPTIONAL CONFIG
# Set to `true` to require authentication for creating new polls and accessing admin pages
AUTH_REQUIRED=false
# Comma separated list of email addresses that are allowed to register and login.
# You can use wildcard syntax to match a range of email addresses.
# Example: "john@example.com,jane@example.com" or "*@example.com"
ALLOWED_EMAILS=
# Whether or not to disable the landing page
DISABLE_LANDING_PAGE=false

View file

@ -1,7 +1,5 @@
#!/bin/sh
set -e
export NEXT_PUBLIC_APP_VERSION=v$(node -p "require('./package.json').version")
echo "Set NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION"
yarn prisma generate
yarn build
# Deploy migration using direct database connection (no connection pool)

View file

@ -51,7 +51,6 @@
"ALLOWED_EMAILS",
"ANALYZE",
"API_SECRET",
"AUTH_REQUIRED",
"AWS_ACCESS_KEY_ID",
"AWS_REGION",
"AWS_SECRET_ACCESS_KEY",
@ -65,12 +64,12 @@
"NEXT_PUBLIC_CRISP_WEBSITE_ID",
"NEXT_PUBLIC_ENABLE_ANALYTICS",
"NEXT_PUBLIC_ENABLE_FINALIZATION",
"NEXT_PUBLIC_FEEDBACK_ENABLED",
"NEXT_PUBLIC_LANDING_PAGE_URL",
"NEXT_PUBLIC_MAINTENANCE_MODE",
"NEXT_PUBLIC_PADDLE_SANDBOX",
"NEXT_PUBLIC_PADDLE_VENDOR_ID",
"NEXT_PUBLIC_POSTHOG_API_HOST",
"NEXT_PUBLIC_SELF_HOSTED",
"NEXT_PUBLIC_POSTHOG_API_KEY",
"NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY",
"NEXT_PUBLIC_PRO_PLAN_ID_YEARLY",

592
yarn.lock

File diff suppressed because it is too large Load diff