Remove admin functionality related code
The admin functionality related code has been removed which includes various user and organization functionalities like delete, update, ban etc. This includes action logic, UI components and supportive utility functions. Notable deletions include the server action files, dialog components for actions like banning and deleting, and related utility functions. This massive cleanup is aimed at simplifying the codebase and the commit reflects adherence to project restructuring.
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { PersonalAccountCheckoutForm } from '~/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PersonalAccountCheckoutForm } from './components/personal-account-checkout-form';
|
||||
|
||||
function PersonalAccountBillingPage() {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// We reuse the page from the billing module
|
||||
// as there is no need to create a new one.
|
||||
export * from '../return/page';
|
||||
@@ -82,6 +82,10 @@ export async function createBillingPortalSession() {
|
||||
const customerId = await getCustomerIdFromAccountId(accountId);
|
||||
const returnUrl = getBillingPortalReturnUrl();
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
const { url } = await service.createBillingPortalSession({
|
||||
customerId,
|
||||
returnUrl,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Page } from '@kit/ui/page';
|
||||
|
||||
import { HomeSidebar } from '~/(dashboard)/home/components/home-sidebar';
|
||||
import { HomeSidebar } from '~/(dashboard)/home/_components/home-sidebar';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
function UserHomeLayout({ children }: React.PropsWithChildren) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PageHeader } from '@kit/ui/page';
|
||||
|
||||
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/(components)/mobile-app-navigation';
|
||||
import { MobileAppNavigation } from '~/(dashboard)/home/[account]/_components/mobile-app-navigation';
|
||||
|
||||
export function AppHeader({
|
||||
children,
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
|
||||
import { ArrowLeftCircleIcon, ArrowRightCircleIcon } from 'lucide-react';
|
||||
|
||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
|
||||
import { ProfileDropdownContainer } from '~/(dashboard)/home/_components/personal-account-dropdown';
|
||||
import featureFlagsConfig from '~/config/feature-flags.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
@@ -36,6 +38,7 @@ export function AppSidebar(props: {
|
||||
account: string;
|
||||
accounts: AccountModel[];
|
||||
collapsed: boolean;
|
||||
session: Session | null;
|
||||
}) {
|
||||
return (
|
||||
<Sidebar collapsed={props.collapsed}>
|
||||
@@ -45,6 +48,7 @@ export function AppSidebar(props: {
|
||||
setCollapsed={setCollapsed}
|
||||
account={props.account}
|
||||
accounts={props.accounts}
|
||||
session={props.session}
|
||||
/>
|
||||
)}
|
||||
</Sidebar>
|
||||
@@ -54,6 +58,7 @@ export function AppSidebar(props: {
|
||||
function SidebarContainer(props: {
|
||||
account: string;
|
||||
accounts: AccountModel[];
|
||||
session: Session | null;
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
}) {
|
||||
@@ -84,7 +89,10 @@ function SidebarContainer(props: {
|
||||
|
||||
<div className={'absolute bottom-4 left-0 w-full'}>
|
||||
<SidebarContent>
|
||||
<ProfileDropdownContainer collapsed={props.collapsed} />
|
||||
<ProfileDropdownContainer
|
||||
session={props.session}
|
||||
collapsed={props.collapsed}
|
||||
/>
|
||||
|
||||
<AppSidebarFooterMenu
|
||||
collapsed={props.collapsed}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import { createBillingPortalSession } from '../server-actions';
|
||||
|
||||
export function BillingPortalForm(props: { accountId: string }) {
|
||||
return (
|
||||
<div className={'mx-auto w-full max-w-2xl'}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Manage your Team Plan</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
You can change your plan at any time.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form action={createBillingPortalSession}>
|
||||
<input type={'hidden'} name={'accountId'} value={props.accountId} />
|
||||
|
||||
<Button>Manage your Billing Settings</Button>
|
||||
|
||||
<span>
|
||||
Visit the billing portal to manage your subscription (update
|
||||
payment method, cancel subscription, etc.)
|
||||
</span>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { EmbeddedCheckout, PlanPicker } from '@kit/billing-gateway/components';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
import { createTeamAccountCheckoutSession } from '../server-actions';
|
||||
|
||||
export function TeamAccountCheckoutForm(params: { accountId: string }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [checkoutToken, setCheckoutToken] = useState<string | null>(null);
|
||||
|
||||
// If the checkout token is set, render the embedded checkout component
|
||||
if (checkoutToken) {
|
||||
return (
|
||||
<EmbeddedCheckout
|
||||
checkoutToken={checkoutToken}
|
||||
provider={billingConfig.provider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render the plan picker component
|
||||
return (
|
||||
<div className={'mx-auto w-full max-w-2xl'}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Manage your Team Plan</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
You can change your plan at any time.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<PlanPicker
|
||||
pending={pending}
|
||||
config={billingConfig}
|
||||
onSubmit={({ planId }) => {
|
||||
startTransition(async () => {
|
||||
const { checkoutToken } =
|
||||
await createTeamAccountCheckoutSession({
|
||||
planId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
setCheckoutToken(checkoutToken);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,25 @@
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/_lib/load-workspace';
|
||||
import { BillingPortalForm } from '~/(dashboard)/home/[account]/billing/_components/billing-portal-form';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
function OrganizationAccountBillingPage() {
|
||||
import { TeamAccountCheckoutForm } from './_components/team-account-checkout-form';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
account: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function OrganizationAccountBillingPage({ params }: Params) {
|
||||
const workspace = await loadOrganizationWorkspace(params.account);
|
||||
const accountId = workspace.account.id;
|
||||
const customerId = await loadCustomerIdFromAccount(accountId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -11,9 +27,31 @@ function OrganizationAccountBillingPage() {
|
||||
description={<Trans i18nKey={'common:billingTabDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody></PageBody>
|
||||
<PageBody>
|
||||
<TeamAccountCheckoutForm accountId={accountId} />
|
||||
|
||||
<If condition={customerId}>
|
||||
<BillingPortalForm accountId={accountId} />
|
||||
</If>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(OrganizationAccountBillingPage);
|
||||
|
||||
async function loadCustomerIdFromAccount(accountId: string) {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const { data, error } = await client
|
||||
.from('billing_customers')
|
||||
.select('customer_id')
|
||||
.eq('account_id', accountId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data?.customer_id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { BillingSessionStatus } from '@kit/billing-gateway/components';
|
||||
import { requireAuth } from '@kit/supabase/require-auth';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
interface SessionPageProps {
|
||||
searchParams: {
|
||||
session_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const LazyEmbeddedCheckout = dynamic(async () => {
|
||||
const { EmbeddedCheckout } = await import('@kit/billing-gateway/components');
|
||||
|
||||
return EmbeddedCheckout;
|
||||
});
|
||||
|
||||
async function ReturnStripeSessionPage({ searchParams }: SessionPageProps) {
|
||||
const { customerEmail, checkoutToken } = await loadCheckoutSession(
|
||||
searchParams.session_id,
|
||||
);
|
||||
|
||||
if (checkoutToken) {
|
||||
return (
|
||||
<LazyEmbeddedCheckout
|
||||
checkoutToken={checkoutToken}
|
||||
provider={billingConfig.provider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'fixed left-0 top-48 z-50 mx-auto w-full'}>
|
||||
<BillingSessionStatus
|
||||
customerEmail={customerEmail ?? ''}
|
||||
redirectPath={pathsConfig.app.home}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'fixed left-0 top-0 w-full bg-background/30 backdrop-blur-sm' +
|
||||
' !m-0 h-full'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(ReturnStripeSessionPage);
|
||||
|
||||
export async function loadCheckoutSession(sessionId: string) {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
await requireAuth(client);
|
||||
|
||||
const gateway = await getBillingGatewayProvider(client);
|
||||
|
||||
const session = await gateway.retrieveCheckoutSession({
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const checkoutToken = session.isSessionOpen ? session.checkoutToken : null;
|
||||
|
||||
// otherwise - we show the user the return page
|
||||
// and display the details of the session
|
||||
return {
|
||||
status: session.status,
|
||||
customerEmail: session.customer.email,
|
||||
checkoutToken,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getProductPlanPairFromId } from '@kit/billing';
|
||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||
import { requireAuth } from '@kit/supabase/require-auth';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
/**
|
||||
* Creates a checkout session for a team account.
|
||||
*
|
||||
* @param {object} params - The parameters for creating the checkout session.
|
||||
* @param {string} params.planId - The ID of the plan to be associated with the account.
|
||||
*/
|
||||
export async function createTeamAccountCheckoutSession(params: {
|
||||
planId: string;
|
||||
accountId: string;
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
// we parse the plan ID from the parameters
|
||||
// no need in continuing if the plan ID is not valid
|
||||
const planId = z.string().min(1).parse(params.planId);
|
||||
|
||||
// we require the user to be authenticated
|
||||
const { data: session } = await requireAuth(client);
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
const accountId = params.accountId;
|
||||
|
||||
const hasPermission = await getPermissionsForAccountId(userId, accountId);
|
||||
|
||||
// if the user does not have permission to manage billing for the account
|
||||
// then we should not proceed
|
||||
if (!hasPermission) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
// here we have confirmed that the user has permission to manage billing for the account
|
||||
// so we go on and create a checkout session
|
||||
const service = await getBillingGatewayProvider(client);
|
||||
const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
|
||||
|
||||
if (!productPlanPairFromId) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
// the return URL for the checkout session
|
||||
const returnUrl = getCheckoutSessionReturnUrl();
|
||||
|
||||
// find the customer ID for the account if it exists
|
||||
// (eg. if the account has been billed before)
|
||||
const customerId = await getCustomerIdFromAccountId(client, accountId);
|
||||
const customerEmail = session.user.email;
|
||||
|
||||
// retrieve the product and plan from the billing configuration
|
||||
const { product, plan } = productPlanPairFromId;
|
||||
|
||||
// call the payment gateway to create the checkout session
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
accountId,
|
||||
returnUrl,
|
||||
planId,
|
||||
customerEmail,
|
||||
customerId,
|
||||
paymentType: product.paymentType,
|
||||
trialPeriodDays: plan.trialPeriodDays,
|
||||
});
|
||||
|
||||
// return the checkout token to the client
|
||||
// so we can call the payment gateway to complete the checkout
|
||||
return {
|
||||
checkoutToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBillingPortalSession(data: FormData) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
const accountId = z
|
||||
.object({
|
||||
accountId: z.string().min(1),
|
||||
})
|
||||
.parse(Object.fromEntries(data)).accountId;
|
||||
|
||||
const { data: session, error } = await requireAuth(client);
|
||||
|
||||
if (error ?? !session) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
// we require the user to have permissions to manage billing for the account
|
||||
const hasPermission = await getPermissionsForAccountId(userId, accountId);
|
||||
|
||||
// if the user does not have permission to manage billing for the account
|
||||
// then we should not proceed
|
||||
if (!hasPermission) {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
|
||||
const service = await getBillingGatewayProvider(client);
|
||||
const customerId = await getCustomerIdFromAccountId(client, accountId);
|
||||
const returnUrl = getBillingPortalReturnUrl();
|
||||
|
||||
if (!customerId) {
|
||||
throw new Error('Customer not found');
|
||||
}
|
||||
|
||||
const { url } = await service.createBillingPortalSession({
|
||||
customerId,
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
// redirect the user to the billing portal
|
||||
return redirect(url);
|
||||
}
|
||||
|
||||
function getCheckoutSessionReturnUrl() {
|
||||
const origin = headers().get('origin')!;
|
||||
|
||||
return new URL(pathsConfig.app.accountBillingReturn, origin).toString();
|
||||
}
|
||||
|
||||
function getBillingPortalReturnUrl() {
|
||||
const origin = headers().get('origin')!;
|
||||
|
||||
return new URL(pathsConfig.app.accountBilling, origin).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the permissions for a user on an account for managing billing.
|
||||
* @param userId
|
||||
* @param accountId
|
||||
*/
|
||||
async function getPermissionsForAccountId(userId: string, accountId: string) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
|
||||
const { data, error } = await client.rpc('has_permission', {
|
||||
account_id: accountId,
|
||||
user_id: userId,
|
||||
permission_name: 'billing.manage',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getCustomerIdFromAccountId(
|
||||
client: ReturnType<typeof getSupabaseServerActionClient>,
|
||||
accountId: string,
|
||||
) {
|
||||
const { data, error } = await client
|
||||
.from('billing_customers')
|
||||
.select('customer_id')
|
||||
.eq('account_id', accountId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data?.customer_id;
|
||||
}
|
||||
@@ -1,37 +1,45 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { parseSidebarStateCookie } from '@kit/shared/cookies/sidebar-state.cookie';
|
||||
import { parseThemeCookie } from '@kit/shared/cookies/theme.cookie';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import { Page } from '@kit/ui/page';
|
||||
|
||||
import { AppSidebar } from '~/(dashboard)/home/[account]/_components/app-sidebar';
|
||||
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/_lib/load-workspace';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { AppSidebar } from './(components)/app-sidebar';
|
||||
import { loadOrganizationWorkspace } from './(lib)/load-workspace';
|
||||
|
||||
interface Params {
|
||||
account: string;
|
||||
}
|
||||
|
||||
async function OrganizationWorkspaceLayout({
|
||||
function OrganizationWorkspaceLayout({
|
||||
children,
|
||||
params,
|
||||
}: React.PropsWithChildren<{
|
||||
params: Params;
|
||||
}>) {
|
||||
const data = await loadOrganizationWorkspace(params.account);
|
||||
const [data, session] = use(
|
||||
Promise.all([loadOrganizationWorkspace(params.account), loadSession()]),
|
||||
);
|
||||
|
||||
const ui = getUIStateCookies();
|
||||
const sidebarCollapsed = ui.sidebarState === 'collapsed';
|
||||
|
||||
const accounts = data.accounts.map(({ name, slug, picture_url }) => ({
|
||||
label: name,
|
||||
value: slug,
|
||||
image: picture_url,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Page
|
||||
sidebar={
|
||||
<AppSidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
account={params.account}
|
||||
accounts={data.accounts.map(({ name, slug, picture_url }) => ({
|
||||
label: name,
|
||||
value: slug,
|
||||
image: picture_url,
|
||||
}))}
|
||||
session={session}
|
||||
accounts={accounts}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -48,3 +56,18 @@ function getUIStateCookies() {
|
||||
sidebarState: parseSidebarStateCookie(),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await client.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlusCircledIcon } from '@radix-ui/react-icons';
|
||||
import { PlusCircleIcon } from 'lucide-react';
|
||||
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import {
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/(lib)/load-workspace';
|
||||
import { loadOrganizationWorkspace } from '~/(dashboard)/home/[account]/_lib/load-workspace';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
interface Params {
|
||||
@@ -99,7 +99,7 @@ async function OrganizationAccountMembersPage({ params }: Params) {
|
||||
|
||||
<InviteMembersDialogContainer account={account.slug}>
|
||||
<Button size={'sm'}>
|
||||
<PlusCircledIcon className={'mr-2 w-4'} />
|
||||
<PlusCircleIcon className={'mr-2 w-4'} />
|
||||
<span>Add Member</span>
|
||||
</Button>
|
||||
</InviteMembersDialogContainer>
|
||||
|
||||
@@ -7,11 +7,11 @@ import { PageBody } from '@kit/ui/page';
|
||||
import Spinner from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AppHeader } from '~/(dashboard)/home/[account]/(components)/app-header';
|
||||
import { AppHeader } from '~/(dashboard)/home/[account]/_components/app-header';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
const DashboardDemo = loadDynamic(
|
||||
() => import('~/(dashboard)/home/[account]/(components)/dashboard-demo'),
|
||||
() => import('~/(dashboard)/home/[account]/_components/dashboard-demo'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import type { Stripe } from 'stripe';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
/**
|
||||
* Retrieves the session status for a Stripe checkout session.
|
||||
* Since we should only arrive here for a successful checkout, we only check
|
||||
* for the `paid` status.
|
||||
*
|
||||
* @param {Stripe.Checkout.Session['status']} status - The status of the Stripe checkout session.
|
||||
* @param {string} customerEmail - The email address of the customer associated with the session.
|
||||
*
|
||||
* @returns {ReactElement} - The component to render based on the session status.
|
||||
*/
|
||||
export function BillingSessionStatus({
|
||||
customerEmail,
|
||||
}: React.PropsWithChildren<{
|
||||
status: Stripe.Checkout.Session['status'];
|
||||
customerEmail: string;
|
||||
}>) {
|
||||
return <SuccessSessionStatus customerEmail={customerEmail} />;
|
||||
}
|
||||
|
||||
function SuccessSessionStatus({
|
||||
customerEmail,
|
||||
}: React.PropsWithChildren<{
|
||||
customerEmail: string;
|
||||
}>) {
|
||||
return (
|
||||
<section
|
||||
data-test={'payment-return-success'}
|
||||
className={
|
||||
'mx-auto max-w-xl rounded-xl border p-16 fade-in xl:drop-shadow-sm' +
|
||||
' dark:border-dark-800 border-gray-100' +
|
||||
' bg-background ease-out animate-in slide-in-from-bottom-8' +
|
||||
' duration-1000 zoom-in-50 dark:shadow-2xl dark:shadow-primary/40'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center space-y-4 text-center'
|
||||
}
|
||||
>
|
||||
<CheckIcon
|
||||
className={
|
||||
'w-16 rounded-full bg-green-500 p-1 text-white ring-8' +
|
||||
' ring-green-500/30 dark:ring-green-500/50'
|
||||
}
|
||||
/>
|
||||
|
||||
<Heading level={3}>
|
||||
<span className={'mr-4 font-semibold'}>
|
||||
<Trans i18nKey={'subscription:checkoutSuccessTitle'} />
|
||||
</span>
|
||||
🎉
|
||||
</Heading>
|
||||
|
||||
<div
|
||||
className={'flex flex-col space-y-4 text-gray-500 dark:text-gray-400'}
|
||||
>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={'subscription:checkoutSuccessDescription'}
|
||||
values={{ customerEmail }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button data-test={'checkout-success-back-button'} variant={'outline'}>
|
||||
<Link href={pathsConfig.app.home}>
|
||||
<span className={'flex items-center space-x-2.5'}>
|
||||
<span>
|
||||
<Trans i18nKey={'subscription:checkoutSuccessBackButton'} />
|
||||
</span>
|
||||
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
const EmbeddedStripeCheckout = dynamic(
|
||||
() => {
|
||||
return import('../../components/embedded-stripe-checkout');
|
||||
},
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
function RecoverCheckout({ clientSecret }: { clientSecret: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<EmbeddedStripeCheckout
|
||||
clientSecret={clientSecret}
|
||||
onClose={() => {
|
||||
return router.replace('/settings/subscription');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RecoverCheckout;
|
||||
@@ -1,81 +0,0 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import requireSession from '@/lib/user/require-session';
|
||||
|
||||
import { withI18n } from '@packages/i18n/with-i18n';
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import createStripeClient from '@kit/stripe/get-stripe';
|
||||
|
||||
import { BillingSessionStatus } from './components/billing-session-status';
|
||||
import RecoverCheckout from './components/recover-checkout';
|
||||
|
||||
interface SessionPageProps {
|
||||
searchParams: {
|
||||
session_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function ReturnStripeSessionPage({ searchParams }: SessionPageProps) {
|
||||
const { status, customerEmail, clientSecret } = await loadStripeSession(
|
||||
searchParams.session_id,
|
||||
);
|
||||
|
||||
if (clientSecret) {
|
||||
return <RecoverCheckout clientSecret={clientSecret} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'fixed left-0 top-48 z-50 mx-auto w-full'}>
|
||||
<BillingSessionStatus
|
||||
status={status}
|
||||
customerEmail={customerEmail ?? ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'fixed left-0 top-0 w-full bg-background/30 backdrop-blur-sm' +
|
||||
' !m-0 h-full'
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(ReturnStripeSessionPage);
|
||||
|
||||
export async function loadStripeSession(sessionId: string) {
|
||||
await requireSession(getSupabaseServerComponentClient());
|
||||
|
||||
// now we fetch the session from Stripe
|
||||
// and check if it's still open
|
||||
const stripe = await createStripeClient();
|
||||
|
||||
const session = await stripe.checkout.sessions
|
||||
.retrieve(sessionId)
|
||||
.catch(() => undefined);
|
||||
|
||||
if (!session) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isSessionOpen = session.status === 'open';
|
||||
const clientSecret = isSessionOpen ? session.client_secret : null;
|
||||
const isEmbeddedMode = session.ui_mode === 'embedded';
|
||||
|
||||
// if the session is still open, we redirect the user to the checkout page
|
||||
// in Stripe self hosted mode
|
||||
if (isSessionOpen && !isEmbeddedMode && session.url) {
|
||||
redirect(session.url);
|
||||
}
|
||||
|
||||
// otherwise - we show the user the return page
|
||||
// and display the details of the session
|
||||
return {
|
||||
status: session.status,
|
||||
customerEmail: session.customer_details?.email,
|
||||
clientSecret,
|
||||
};
|
||||
}
|
||||
@@ -5,13 +5,16 @@ import { cookies } from 'next/headers';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import { Sidebar, SidebarContent, SidebarNavigation } from '@kit/ui/sidebar';
|
||||
|
||||
import { HomeSidebarAccountSelector } from '~/(dashboard)/home/components/home-sidebar-account-selector';
|
||||
import { ProfileDropdownContainer } from '~/(dashboard)/home/components/personal-account-dropdown';
|
||||
import { HomeSidebarAccountSelector } from '~/(dashboard)/home/_components/home-sidebar-account-selector';
|
||||
import { ProfileDropdownContainer } from '~/(dashboard)/home/_components/personal-account-dropdown';
|
||||
import { personalAccountSidebarConfig } from '~/config/personal-account-sidebar.config';
|
||||
|
||||
export function HomeSidebar() {
|
||||
const collapsed = getSidebarCollapsed();
|
||||
const accounts = use(loadUserAccounts());
|
||||
|
||||
const [accounts, session] = use(
|
||||
Promise.all([loadUserAccounts(), loadSession()]),
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar collapsed={collapsed}>
|
||||
@@ -25,7 +28,7 @@ export function HomeSidebar() {
|
||||
|
||||
<div className={'absolute bottom-4 left-0 w-full'}>
|
||||
<SidebarContent>
|
||||
<ProfileDropdownContainer collapsed={collapsed} />
|
||||
<ProfileDropdownContainer session={session} collapsed={collapsed} />
|
||||
</SidebarContent>
|
||||
</div>
|
||||
</Sidebar>
|
||||
@@ -36,12 +39,27 @@ function getSidebarCollapsed() {
|
||||
return cookies().get('sidebar-collapsed')?.value === 'true';
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await client.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async function loadUserAccounts() {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const { data: accounts, error } = await client
|
||||
.from('user_accounts')
|
||||
.select('*');
|
||||
.select(`name, slug, picture_url`);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
@@ -1,15 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export function ProfileDropdownContainer(props: { collapsed: boolean }) {
|
||||
const userSession = useUserSession();
|
||||
export function ProfileDropdownContainer(props: {
|
||||
collapsed: boolean;
|
||||
session: Session | null;
|
||||
}) {
|
||||
const signOut = useSignOut();
|
||||
const session = userSession?.data ?? undefined;
|
||||
|
||||
return (
|
||||
<div className={props.collapsed ? '' : 'w-full'}>
|
||||
@@ -19,7 +21,7 @@ export function ProfileDropdownContainer(props: { collapsed: boolean }) {
|
||||
}}
|
||||
className={'w-full'}
|
||||
showProfileName={!props.collapsed}
|
||||
session={session}
|
||||
session={props.session}
|
||||
signOutRequested={() => signOut.mutateAsync()}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export function SiteHeaderAccountSection(
|
||||
props: React.PropsWithChildren<{
|
||||
session: Session | null;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<Suspense fallback={<AuthButtons />}>
|
||||
<SuspendedPersonalAccountDropdown session={props.session} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SuspendedPersonalAccountDropdown(props: { session: Session | null }) {
|
||||
const signOut = useSignOut();
|
||||
const userSession = useUserSession(props.session);
|
||||
|
||||
return (
|
||||
<If condition={userSession.data} fallback={<AuthButtons />}>
|
||||
{(session) => (
|
||||
<PersonalAccountDropdown
|
||||
paths={{
|
||||
home: pathsConfig.app.home,
|
||||
}}
|
||||
session={session}
|
||||
signOutRequested={() => signOut.mutateAsync()}
|
||||
/>
|
||||
)}
|
||||
</If>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthButtons() {
|
||||
return (
|
||||
<div className={'hidden space-x-2 lg:flex'}>
|
||||
<Button variant={'link'}>
|
||||
<Link href={pathsConfig.auth.signIn}>
|
||||
<Trans i18nKey={'auth:signIn'} />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Button className={'rounded-full'}>
|
||||
<Trans i18nKey={'auth:signUp'} />
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { SiteHeaderAccountSection } from '~/(marketing)/components/site-header-account-section';
|
||||
import { SiteNavigation } from '~/(marketing)/components/site-navigation';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
|
||||
import { SiteHeaderAccountSection } from '~/(marketing)/_components/site-header-account-section';
|
||||
import { SiteNavigation } from '~/(marketing)/_components/site-navigation';
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
|
||||
export function SiteHeader() {
|
||||
export async function SiteHeader(props: { session: Session | null }) {
|
||||
return (
|
||||
<div className={'container mx-auto'}>
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
@@ -17,7 +19,7 @@ export function SiteHeader() {
|
||||
<div className={'flex flex-1 items-center justify-end space-x-4'}>
|
||||
<div className={'flex items-center'}></div>
|
||||
|
||||
<SiteHeaderAccountSection />
|
||||
<SiteHeaderAccountSection session={props.session} />
|
||||
|
||||
<div className={'flex lg:hidden'}>
|
||||
<SiteNavigation />
|
||||
@@ -5,11 +5,10 @@ import Script from 'next/script';
|
||||
|
||||
import { allPosts } from 'contentlayer/generated';
|
||||
|
||||
import Post from '~/(marketing)/blog/_components/post';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import Post from '../components/post';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { Post } from 'contentlayer/generated';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
|
||||
import { CoverImage } from '~/(marketing)/blog/_components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/_components/date-formatter';
|
||||
|
||||
const PostHeader: React.FC<{
|
||||
export const PostHeader: React.FC<{
|
||||
post: Post;
|
||||
}> = ({ post }) => {
|
||||
const { title, date, readingTime, description, image } = post;
|
||||
@@ -53,5 +53,3 @@ const PostHeader: React.FC<{
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PostHeader;
|
||||
@@ -4,8 +4,8 @@ import type { Post } from 'contentlayer/generated';
|
||||
|
||||
import { If } from '@kit/ui/if';
|
||||
|
||||
import { CoverImage } from '~/(marketing)/blog/components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/components/date-formatter';
|
||||
import { CoverImage } from '~/(marketing)/blog/_components/cover-image';
|
||||
import { DateFormatter } from '~/(marketing)/blog/_components/date-formatter';
|
||||
|
||||
type Props = {
|
||||
post: Post;
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
|
||||
const DEFAULT_IMAGE_HEIGHT = 250;
|
||||
|
||||
function PostPreview({
|
||||
export function PostPreview({
|
||||
post,
|
||||
preloadImage,
|
||||
imageHeight,
|
||||
@@ -67,5 +67,3 @@ function PostPreview({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PostPreview;
|
||||
@@ -4,7 +4,7 @@ import type { Post as PostType } from 'contentlayer/generated';
|
||||
|
||||
import { Mdx } from '@kit/ui/mdx';
|
||||
|
||||
import PostHeader from './post-header';
|
||||
import { PostHeader } from './post-header';
|
||||
|
||||
export const Post: React.FC<{
|
||||
post: PostType;
|
||||
@@ -2,19 +2,18 @@ import type { Metadata } from 'next';
|
||||
|
||||
import { allPosts } from 'contentlayer/generated';
|
||||
|
||||
import PostPreview from '~/(marketing)/blog/components/post-preview';
|
||||
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
|
||||
import { GridList } from '~/(marketing)/_components/grid-list';
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { PostPreview } from '~/(marketing)/blog/_components/post-preview';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { GridList } from '../components/grid-list';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Blog - ${appConfig.name}`,
|
||||
description: `Tutorials, Guides and Updates from our team`,
|
||||
};
|
||||
|
||||
async function BlogPage() {
|
||||
function BlogPage() {
|
||||
const livePosts = allPosts.filter((post) => {
|
||||
const isProduction = appConfig.production;
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { useUserSession } from '@kit/supabase/hooks/use-user-session';
|
||||
import { Button } from '@kit/ui/button';
|
||||
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
export function SiteHeaderAccountSection() {
|
||||
const signOut = useSignOut();
|
||||
const userSession = useUserSession();
|
||||
|
||||
if (userSession.data) {
|
||||
return (
|
||||
<PersonalAccountDropdown
|
||||
session={userSession.data}
|
||||
paths={{
|
||||
home: pathsConfig.app.home,
|
||||
}}
|
||||
signOutRequested={() => signOut.mutateAsync()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AuthButtons />;
|
||||
}
|
||||
|
||||
function AuthButtons() {
|
||||
return (
|
||||
<div className={'hidden space-x-2 lg:flex'}>
|
||||
<Button variant={'link'}>
|
||||
<Link href={pathsConfig.auth.signIn}>Sign In</Link>
|
||||
</Button>
|
||||
|
||||
<Link href={pathsConfig.auth.signUp}>
|
||||
<Button className={'rounded-full'}>
|
||||
Sign Up
|
||||
<ChevronRightIcon className={'h-4'} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Mdx } from '@kit/ui/mdx';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/components/site-page-header';
|
||||
import { DocsCards } from '~/(marketing)/docs/components/docs-cards';
|
||||
import { DocumentationPageLink } from '~/(marketing)/docs/components/documentation-page-link';
|
||||
import { getDocumentationPageTree } from '~/(marketing)/docs/utils/get-documentation-page-tree';
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { DocsCards } from '~/(marketing)/docs/_components/docs-cards';
|
||||
import { DocumentationPageLink } from '~/(marketing)/docs/_components/documentation-page-link';
|
||||
import { getDocumentationPageTree } from '~/(marketing)/docs/_lib/get-documentation-page-tree';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
const getPageBySlug = cache((slug: string) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import type { ProcessedDocumentationPage } from '../utils/build-documentation-tree';
|
||||
import type { ProcessedDocumentationPage } from '~/(marketing)/docs/_lib/build-documentation-tree';
|
||||
|
||||
const DocsNavLink: React.FC<{
|
||||
label: string;
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { DocumentationPage } from 'contentlayer/generated';
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
|
||||
import DocsNavigation from './components/docs-navigation';
|
||||
import { buildDocumentationTree } from './utils/build-documentation-tree';
|
||||
import DocsNavigation from '~/(marketing)/docs/_components/docs-navigation';
|
||||
import { buildDocumentationTree } from '~/(marketing)/docs/_lib/build-documentation-tree';
|
||||
|
||||
function DocsLayout({ children }: React.PropsWithChildren) {
|
||||
const tree = buildDocumentationTree(allDocumentationPages);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { allDocumentationPages } from 'contentlayer/generated';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { DocsCards } from '~/(marketing)/docs/_components/docs-cards';
|
||||
import { buildDocumentationTree } from '~/(marketing)/docs/_lib/build-documentation-tree';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
import { DocsCards } from './components/docs-cards';
|
||||
import { buildDocumentationTree } from './utils/build-documentation-tree';
|
||||
|
||||
export const metadata = {
|
||||
title: `Documentation - ${appConfig.name}`,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
|
||||
export const metadata = {
|
||||
title: 'FAQ',
|
||||
};
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
|
||||
import { SiteFooter } from '~/(marketing)/_components/site-footer';
|
||||
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SiteFooter } from './components/site-footer';
|
||||
import { SiteHeader } from './components/site-header';
|
||||
async function SiteLayout(props: React.PropsWithChildren) {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
} = await client.auth.getSession();
|
||||
|
||||
function SiteLayout(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<>
|
||||
<SiteHeader />
|
||||
<SiteHeader session={session} />
|
||||
|
||||
{props.children}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { PricingTable } from '@kit/billing/components/pricing-table';
|
||||
|
||||
import { SitePageHeader } from '~/(marketing)/_components/site-page-header';
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { SitePageHeader } from '../components/site-page-header';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Pricing',
|
||||
};
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { Page } from '@/components/app/Page';
|
||||
|
||||
import AdminSidebar from '../../packages/admin/components/AdminSidebar';
|
||||
import isUserSuperAdmin from './utils/is-user-super-admin';
|
||||
|
||||
async function AdminLayout({ children }: React.PropsWithChildren) {
|
||||
const isAdmin = await isUserSuperAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const csrfToken = headers().get('X-CSRF-Token');
|
||||
|
||||
return <Page sidebar={<AdminSidebar />}>{children}</Page>;
|
||||
}
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import isUserSuperAdmin from '~/admin/utils/is-user-super-admin';
|
||||
|
||||
export function withAdminSession<Args extends unknown[], Response>(
|
||||
fn: (...params: Args) => Response,
|
||||
) {
|
||||
return async (...params: Args) => {
|
||||
const isAdmin = await isUserSuperAdmin({
|
||||
client: getSupabaseServerActionClient(),
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return fn(...params);
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
@@ -1,29 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
const getClient = () => getSupabaseServerActionClient({ admin: true });
|
||||
|
||||
export const deleteOrganizationAction = withAdminSession(
|
||||
async ({ id }: { id: number; csrfToken: string }) => {
|
||||
const client = getClient();
|
||||
|
||||
Logger.info({ id }, `Admin requested to delete Organization`);
|
||||
|
||||
await deleteOrganization(client, {
|
||||
organizationId: id,
|
||||
});
|
||||
|
||||
revalidatePath('/admin/organizations', 'page');
|
||||
|
||||
Logger.info({ id }, `Organization account deleted`);
|
||||
|
||||
redirect('/admin/organizations');
|
||||
},
|
||||
);
|
||||
@@ -1,95 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type Organization from '@/lib/organizations/types/organization';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { deleteOrganizationAction } from '../actions.server';
|
||||
|
||||
function DeleteOrganizationModal({
|
||||
organization,
|
||||
}: React.PropsWithChildren<{
|
||||
organization: Organization;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const csrfToken = useCsrfToken();
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
startTransition(async () => {
|
||||
await deleteOrganizationAction({
|
||||
id: organization.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
onDismiss();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deleting Organization</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form action={onConfirm}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to delete the organization{' '}
|
||||
<b>{organization.name}</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Delete this organization will potentially delete the data
|
||||
associated with it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>This action is not reversible</b>.
|
||||
</p>
|
||||
|
||||
<p>Are you sure you want to do this?</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Confirm by typing <b>DELETE</b>
|
||||
<Input required type={'text'} pattern={'DELETE'} />
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button disabled={pending} variant={'destructive'}>
|
||||
Yes, delete organization
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteOrganizationModal;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { getOrganizationByUid } from '@/lib/organizations/database/queries';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import DeleteOrganizationModal from '../components/DeleteOrganizationModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function DeleteOrganizationModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = await getOrganizationByUid(client, params.uid);
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`Organization not found`);
|
||||
}
|
||||
|
||||
return <DeleteOrganizationModal organization={data} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(DeleteOrganizationModalPage);
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import { DataTable } from '@/components/app/DataTable';
|
||||
import type Membership from '@/lib/organizations/types/membership';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
import { EllipsisVerticalIcon } from 'lucide-react';
|
||||
|
||||
import type UserData from '@kit/session/types/user-data';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
|
||||
import RoleBadge from '../../../../../(app)/[account]/account/organization/components/RoleBadge';
|
||||
|
||||
type Data = {
|
||||
id: Membership['id'];
|
||||
role: Membership['role'];
|
||||
user: {
|
||||
id: UserData['id'];
|
||||
displayName: UserData['displayName'];
|
||||
};
|
||||
};
|
||||
|
||||
const columns: ColumnDef<Data>[] = [
|
||||
{
|
||||
header: 'Membership ID',
|
||||
id: 'id',
|
||||
accessorKey: 'id',
|
||||
},
|
||||
{
|
||||
header: 'User ID',
|
||||
id: 'user-id',
|
||||
cell: ({ row }) => {
|
||||
const userId = row.original.user.id;
|
||||
|
||||
return (
|
||||
<Link className={'hover:underline'} href={`/admin/users/${userId}`}>
|
||||
{userId}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Name',
|
||||
id: 'name',
|
||||
accessorKey: 'user.displayName',
|
||||
},
|
||||
{
|
||||
header: 'Role',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className={'inline-flex'}>
|
||||
<RoleBadge role={row.original.role} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => {
|
||||
const membership = row.original;
|
||||
const userId = membership.user.id;
|
||||
|
||||
return (
|
||||
<div className={'flex'}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size={'icon'}>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<EllipsisVerticalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${userId}`}>View User</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${userId}/impersonate`}>
|
||||
Impersonate User
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function OrganizationsMembersTable({
|
||||
memberships,
|
||||
page,
|
||||
perPage,
|
||||
pageCount,
|
||||
}: React.PropsWithChildren<{
|
||||
memberships: Data[];
|
||||
page: number;
|
||||
perPage: number;
|
||||
pageCount: number;
|
||||
}>) {
|
||||
const data = memberships.filter((membership) => {
|
||||
return membership.user;
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
tableProps={{
|
||||
'data-test': 'admin-organization-members-table',
|
||||
}}
|
||||
onPaginationChange={({ pageIndex }) => {
|
||||
const { pathname } = new URL(path, window.location.origin);
|
||||
const page = pageIndex + 1;
|
||||
|
||||
router.push(pathname + '?page=' + page);
|
||||
}}
|
||||
pageCount={pageCount}
|
||||
pageIndex={page - 1}
|
||||
pageSize={perPage}
|
||||
columns={columns}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsMembersTable;
|
||||
@@ -1,80 +0,0 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import AdminHeader from '@packages/admin/components/AdminHeader';
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import getPageFromQueryParams from '../../../utils/get-page-from-query-param';
|
||||
import { getMembershipsByOrganizationUid } from '../../queries';
|
||||
import OrganizationsMembersTable from './components/OrganizationsMembersTable';
|
||||
|
||||
interface AdminMembersPageParams {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
|
||||
searchParams: {
|
||||
page?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: `Members | ${appConfig.name}`,
|
||||
};
|
||||
|
||||
function AdminMembersPage(params: AdminMembersPageParams) {
|
||||
const adminClient = getSupabaseServerComponentClient({ admin: true });
|
||||
const uid = params.params.uid;
|
||||
const perPage = 20;
|
||||
const page = getPageFromQueryParams(params.searchParams.page);
|
||||
|
||||
const { data: memberships, count } = use(
|
||||
getMembershipsByOrganizationUid(adminClient, { uid, page, perPage }),
|
||||
);
|
||||
|
||||
const pageCount = count ? Math.ceil(count / perPage) : 0;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Manage Members</AdminHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Breadcrumbs />
|
||||
|
||||
<OrganizationsMembersTable
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
pageCount={pageCount}
|
||||
memberships={memberships}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminMembersPage;
|
||||
|
||||
function Breadcrumbs() {
|
||||
return (
|
||||
<div className={'flex items-center space-x-2 p-2 text-xs'}>
|
||||
<div className={'flex items-center space-x-1.5'}>
|
||||
<Link href={'/admin'}>Admin</Link>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon className={'w-3'} />
|
||||
|
||||
<Link href={'/admin/organizations'}>Organizations</Link>
|
||||
|
||||
<ChevronRightIcon className={'w-3'} />
|
||||
|
||||
<span>Members</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
function OrganizationsAdminPageError() {
|
||||
return (
|
||||
<PageBody>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Could not load organizations</AlertTitle>
|
||||
<AlertDescription>
|
||||
There was an error loading the organizations. Please check your
|
||||
console errors.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsAdminPageError;
|
||||
@@ -1,14 +0,0 @@
|
||||
function OrganizationsLayout(
|
||||
props: React.PropsWithChildren<{
|
||||
modal: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
{props.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrganizationsLayout;
|
||||
@@ -1,67 +0,0 @@
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
import AdminGuard from '@/packages/admin/components/AdminGuard';
|
||||
import AdminHeader from '@/packages/admin/components/AdminHeader';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import { Input } from '@kit/ui/input';
|
||||
|
||||
import OrganizationsTable from './components/OrganizationsTable';
|
||||
import { getOrganizations } from './queries';
|
||||
|
||||
interface OrganizationsAdminPageProps {
|
||||
searchParams: {
|
||||
page?: string;
|
||||
search?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: `Organizations | ${appConfig.name}`,
|
||||
};
|
||||
|
||||
async function OrganizationsAdminPage({
|
||||
searchParams,
|
||||
}: OrganizationsAdminPageProps) {
|
||||
const page = searchParams.page ? parseInt(searchParams.page, 10) : 1;
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const perPage = 10;
|
||||
const search = searchParams.search || '';
|
||||
|
||||
const { organizations, count } = await getOrganizations(
|
||||
client,
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
);
|
||||
|
||||
const pageCount = count ? Math.ceil(count / perPage) : 0;
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Manage Organizations</AdminHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<form method={'GET'}>
|
||||
<Input
|
||||
name={'search'}
|
||||
defaultValue={search}
|
||||
placeholder={'Search Organization...'}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<OrganizationsTable
|
||||
perPage={perPage}
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
organizations={organizations}
|
||||
/>
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(OrganizationsAdminPage);
|
||||
@@ -1,131 +0,0 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@/database.types';
|
||||
import { MEMBERSHIPS_TABLE, ORGANIZATIONS_TABLE } from '@/lib/db-tables';
|
||||
import type { UserOrganizationData } from '@/lib/organizations/database/queries';
|
||||
import type MembershipRole from '@/lib/organizations/types/membership-role';
|
||||
|
||||
type Client = SupabaseClient<Database>;
|
||||
|
||||
export async function getOrganizations(
|
||||
client: Client,
|
||||
search: string,
|
||||
page = 1,
|
||||
perPage = 20,
|
||||
) {
|
||||
const startOffset = (page - 1) * perPage;
|
||||
const endOffset = startOffset - 1 + perPage;
|
||||
|
||||
let query = client.from(ORGANIZATIONS_TABLE).select<
|
||||
string,
|
||||
UserOrganizationData['organization'] & {
|
||||
memberships: {
|
||||
userId: string;
|
||||
role: MembershipRole;
|
||||
code: string;
|
||||
}[];
|
||||
}
|
||||
>(
|
||||
`
|
||||
id,
|
||||
uuid,
|
||||
name,
|
||||
logoURL: logo_url,
|
||||
memberships (
|
||||
userId: user_id,
|
||||
role,
|
||||
code
|
||||
),
|
||||
subscription: organizations_subscriptions (
|
||||
customerId: customer_id,
|
||||
data: subscription_id (
|
||||
id,
|
||||
status,
|
||||
currency,
|
||||
interval,
|
||||
cancelAtPeriodEnd: cancel_at_period_end,
|
||||
intervalCount: interval_count,
|
||||
priceId: price_id,
|
||||
createdAt: created_at,
|
||||
periodStartsAt: period_starts_at,
|
||||
periodEndsAt: period_ends_at,
|
||||
trialStartsAt: trial_starts_at,
|
||||
trialEndsAt: trial_ends_at
|
||||
)
|
||||
)`,
|
||||
{
|
||||
count: 'exact',
|
||||
},
|
||||
);
|
||||
|
||||
if (search) {
|
||||
query = query.ilike('name', `%${search}%`);
|
||||
}
|
||||
|
||||
const {
|
||||
data: organizations,
|
||||
count,
|
||||
error,
|
||||
} = await query.range(startOffset, endOffset);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
organizations,
|
||||
count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMembershipsByOrganizationUid(
|
||||
client: Client,
|
||||
params: {
|
||||
uid: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
},
|
||||
) {
|
||||
const startOffset = (params.page - 1) * params.perPage;
|
||||
const endOffset = startOffset + params.perPage;
|
||||
|
||||
const { data, error, count } = await client
|
||||
.from(MEMBERSHIPS_TABLE)
|
||||
.select<
|
||||
string,
|
||||
{
|
||||
id: number;
|
||||
role: MembershipRole;
|
||||
user: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
photoURL: string;
|
||||
};
|
||||
}
|
||||
>(
|
||||
`
|
||||
id,
|
||||
role,
|
||||
user: user_id (
|
||||
id,
|
||||
displayName: display_name,
|
||||
photoURL: photo_url
|
||||
),
|
||||
organization: organization_id !inner (
|
||||
id,
|
||||
uuid
|
||||
)`,
|
||||
{
|
||||
count: 'exact',
|
||||
},
|
||||
)
|
||||
.eq('organization.uuid', params.uid)
|
||||
.is('code', null)
|
||||
.range(startOffset, endOffset);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { data, count };
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminDashboard from '../../packages/admin/components/AdminDashboard';
|
||||
import AdminGuard from '../../packages/admin/components/AdminGuard';
|
||||
import AdminHeader from '../../packages/admin/components/AdminHeader';
|
||||
|
||||
export const metadata = {
|
||||
title: `Admin | ${appConfig.name}`,
|
||||
};
|
||||
|
||||
async function AdminPage() {
|
||||
const data = await loadData();
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Admin</AdminHeader>
|
||||
|
||||
<PageBody>
|
||||
<AdminDashboard data={data} />
|
||||
</PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(AdminPage);
|
||||
|
||||
async function loadData() {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
|
||||
const { count: usersCount } = await client.from('users').select('*', {
|
||||
count: 'exact',
|
||||
head: true,
|
||||
});
|
||||
|
||||
const { count: organizationsCount } = await client
|
||||
.from('organizations')
|
||||
.select('*', {
|
||||
count: 'exact',
|
||||
head: true,
|
||||
});
|
||||
|
||||
const { count: activeSubscriptions } = await client
|
||||
.from('subscriptions')
|
||||
.select(`*`, {
|
||||
count: 'exact',
|
||||
head: true,
|
||||
})
|
||||
.eq('status', 'active');
|
||||
|
||||
const { count: trialSubscriptions } = await client
|
||||
.from('subscriptions')
|
||||
.select(`*`, {
|
||||
count: 'exact',
|
||||
head: true,
|
||||
})
|
||||
.eq('status', 'trialing');
|
||||
|
||||
return {
|
||||
usersCount: usersCount || 0,
|
||||
organizationsCount: organizationsCount || 0,
|
||||
activeSubscriptions: activeSubscriptions || 0,
|
||||
trialSubscriptions: trialSubscriptions || 0,
|
||||
};
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { withAdminSession } from '~/admin/lib/actions-utils';
|
||||
|
||||
const getClient = () => getSupabaseServerActionClient({ admin: true });
|
||||
|
||||
export const banUser = withAdminSession(async ({ userId }) => {
|
||||
await setBanDuration(userId, `876600h`);
|
||||
});
|
||||
|
||||
export const reactivateUser = withAdminSession(async ({ userId }) => {
|
||||
await setBanDuration(userId, `none`);
|
||||
});
|
||||
|
||||
export const impersonateUser = withAdminSession(async ({ userId }) => {
|
||||
await assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
const client = getClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error,
|
||||
} = await client.auth.admin.getUserById(userId);
|
||||
|
||||
if (error || !user) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
const email = user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new Error(`User has no email. Cannot impersonate`);
|
||||
}
|
||||
|
||||
const { error: linkError, data } = await getClient().auth.admin.generateLink({
|
||||
type: 'magiclink',
|
||||
email,
|
||||
options: {
|
||||
redirectTo: `/`,
|
||||
},
|
||||
});
|
||||
|
||||
if (linkError || !data) {
|
||||
throw new Error(`Error generating magic link`);
|
||||
}
|
||||
|
||||
const response = await fetch(data.properties?.action_link, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
const location = response.headers.get('Location');
|
||||
|
||||
if (!location) {
|
||||
throw new Error(`Error generating magic link. Location header not found`);
|
||||
}
|
||||
|
||||
const hash = new URL(location).hash.substring(1);
|
||||
const query = new URLSearchParams(hash);
|
||||
const accessToken = query.get('access_token');
|
||||
const refreshToken = query.get('refresh_token');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error(
|
||||
`Error generating magic link. Tokens not found in URL hash.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
});
|
||||
|
||||
export const deleteUserAction = withAdminSession(
|
||||
async ({ userId }: { userId: string; csrfToken: string }) => {
|
||||
await assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
Logger.info({ userId }, `Admin requested to delete user account`);
|
||||
|
||||
// we don't want to send an email to the user
|
||||
const sendEmail = false;
|
||||
|
||||
await deleteUser({
|
||||
client: getClient(),
|
||||
userId,
|
||||
sendEmail,
|
||||
});
|
||||
|
||||
revalidatePath('/admin/users', 'page');
|
||||
|
||||
Logger.info({ userId }, `User account deleted`);
|
||||
|
||||
redirect('/admin/users');
|
||||
},
|
||||
);
|
||||
|
||||
async function setBanDuration(userId: string, banDuration: string) {
|
||||
await assertUserIsNotCurrentSuperAdmin(userId);
|
||||
|
||||
await getClient().auth.admin.updateUserById(userId, {
|
||||
ban_duration: banDuration,
|
||||
});
|
||||
|
||||
revalidatePath('/admin/users');
|
||||
}
|
||||
|
||||
async function assertUserIsNotCurrentSuperAdmin(targetUserId: string) {
|
||||
const { data: user } = await getSupabaseServerActionClient().auth.getUser();
|
||||
const currentUserId = user.user?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
throw new Error(`Error fetching user`);
|
||||
}
|
||||
|
||||
if (currentUserId === targetUserId) {
|
||||
throw new Error(
|
||||
`You cannot perform a destructive action on your own account as a Super Admin`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import BanUserModal from '../components/BanUserModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
function BanUserModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = use(client.auth.admin.getUserById(params.uid));
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
}
|
||||
|
||||
const user = data.user;
|
||||
const isBanned = 'banned_until' in user && user.banned_until !== 'none';
|
||||
|
||||
if (isBanned) {
|
||||
throw new Error(`The user is already banned`);
|
||||
}
|
||||
|
||||
return <BanUserModal user={user} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(BanUserModalPage);
|
||||
@@ -1,111 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import ErrorBoundary from '@/components/app/ErrorBoundary';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { banUser } from '../actions.server';
|
||||
|
||||
function BanUserModal({
|
||||
user,
|
||||
}: React.PropsWithChildren<{
|
||||
user: User;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const csrfToken = useCsrfToken();
|
||||
const displayText = user.email ?? user.phone ?? '';
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = async () => {
|
||||
await banUser({
|
||||
userId: user.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ban User</DialogTitle>
|
||||
|
||||
<ErrorBoundary fallback={<BanErrorAlert />}>
|
||||
<form action={onConfirm}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to ban <b>{displayText}</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You can unban them later, but they will not be able to log
|
||||
in or use their account until you do.
|
||||
</p>
|
||||
|
||||
<Label>
|
||||
Type <b>BAN</b> to confirm
|
||||
<Input type="text" required pattern={'BAN'} />
|
||||
</Label>
|
||||
|
||||
<p>Are you sure you want to do this?</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<SubmitButton />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ErrorBoundary>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button disabled={pending} variant={'destructive'}>
|
||||
Yes, ban user
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default BanUserModal;
|
||||
|
||||
function BanErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>There was an error banning this user.</AlertTitle>
|
||||
|
||||
<AlertDescription>Check the logs for more information.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
|
||||
import { deleteUserAction } from '../actions.server';
|
||||
|
||||
function DeleteUserModal({
|
||||
user,
|
||||
}: React.PropsWithChildren<{
|
||||
user: User;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const csrfToken = useCsrfToken();
|
||||
const displayText = user.email ?? user.phone ?? '';
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
startTransition(async () => {
|
||||
await deleteUserAction({
|
||||
userId: user.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
onDismiss();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deleting User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form action={onConfirm}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to delete the user <b>{displayText}</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Delete this user will also delete the organizations they are a
|
||||
Owner of, and potentially the data associated with those
|
||||
organizations.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>This action is not reversible</b>.
|
||||
</p>
|
||||
|
||||
<p>Are you sure you want to do this?</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
Confirm by typing <b>DELETE</b>
|
||||
<Input required type={'text'} pattern={'DELETE'} />
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button disabled={pending} variant={'destructive'}>
|
||||
Yes, delete user
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteUserModal;
|
||||
@@ -1,52 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import Spinner from '@/components/app/Spinner';
|
||||
|
||||
import useSupabase from '@kit/hooks/use-supabase';
|
||||
|
||||
function ImpersonateUserAuthSetter({
|
||||
tokens,
|
||||
}: React.PropsWithChildren<{
|
||||
tokens: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
}>) {
|
||||
const supabase = useSupabase();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
async function setAuth() {
|
||||
await supabase.auth.setSession({
|
||||
refresh_token: tokens.refreshToken,
|
||||
access_token: tokens.accessToken,
|
||||
});
|
||||
|
||||
router.push('/dashboard');
|
||||
}
|
||||
|
||||
void setAuth();
|
||||
}, [router, tokens, supabase.auth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex h-screen w-screen flex-1 flex-col items-center justify-center'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col items-center space-y-4'}>
|
||||
<Spinner />
|
||||
|
||||
<div>
|
||||
<p>Setting up your session...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImpersonateUserAuthSetter;
|
||||
@@ -1,128 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import LoadingOverlay from '@/components/app/LoadingOverlay';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
|
||||
import { impersonateUser } from '../actions.server';
|
||||
import ImpersonateUserAuthSetter from '../components/ImpersonateUserAuthSetter';
|
||||
|
||||
function ImpersonateUserConfirmationModal({
|
||||
user,
|
||||
}: React.PropsWithChildren<{
|
||||
user: User;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const csrfToken = useCsrfToken();
|
||||
const [error, setError] = useState<boolean>();
|
||||
|
||||
const [tokens, setTokens] = useState<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}>();
|
||||
|
||||
const displayText = user.email ?? user.phone ?? '';
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await impersonateUser({
|
||||
userId: user.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
setTokens(response);
|
||||
} catch (e) {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Impersonate User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<If condition={tokens}>
|
||||
{(tokens) => {
|
||||
return (
|
||||
<>
|
||||
<ImpersonateUserAuthSetter tokens={tokens} />
|
||||
|
||||
<LoadingOverlay>Setting up your session...</LoadingOverlay>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</If>
|
||||
|
||||
<If condition={error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Impersonation Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Sorry, something went wrong. Please check the logs.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={!error && !tokens}>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to impersonate the account belonging to{' '}
|
||||
<b>{displayText}</b> with ID <b>{user.id}</b>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You will be able to log in as them, see and do everything they
|
||||
can. To return to your own account, simply log out.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Like Uncle Ben said, with great power comes great
|
||||
responsibility. Use this power wisely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button
|
||||
type={'button'}
|
||||
disabled={pending}
|
||||
variant={'destructive'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Yes, let's do it
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImpersonateUserConfirmationModal;
|
||||
@@ -1,76 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { User } from '@supabase/gotrue-js';
|
||||
|
||||
import useCsrfToken from '@kit/hooks/use-csrf-token';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
|
||||
import { reactivateUser } from '../actions.server';
|
||||
|
||||
function ReactivateUserModal({
|
||||
user,
|
||||
}: React.PropsWithChildren<{
|
||||
user: User;
|
||||
}>) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const csrfToken = useCsrfToken();
|
||||
const displayText = user.email ?? user.phone ?? '';
|
||||
|
||||
const onDismiss = () => {
|
||||
router.back();
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
startTransition(async () => {
|
||||
await reactivateUser({
|
||||
userId: user.id,
|
||||
csrfToken,
|
||||
});
|
||||
|
||||
onDismiss();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onDismiss}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reactivate User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-2 text-sm'}>
|
||||
<p>
|
||||
You are about to reactivate the account belonging to{' '}
|
||||
<b>{displayText}</b>.
|
||||
</p>
|
||||
|
||||
<p>Are you sure you want to do this?</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex justify-end space-x-2.5'}>
|
||||
<Button disabled={pending} onClick={onConfirm}>
|
||||
Yes, reactivate user
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReactivateUserModal;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import DeleteUserModal from '../components/DeleteUserModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
function DeleteUserModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = use(client.auth.admin.getUserById(params.uid));
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
}
|
||||
|
||||
return <DeleteUserModal user={data.user} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(DeleteUserModalPage);
|
||||
@@ -1,25 +0,0 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import ImpersonateUserConfirmationModal from '../components/ImpersonateUserConfirmationModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ImpersonateUserModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = use(client.auth.admin.getUserById(params.uid));
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
}
|
||||
|
||||
return <ImpersonateUserConfirmationModal user={data.user} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(ImpersonateUserModalPage);
|
||||
@@ -1,34 +0,0 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import AdminGuard from '../../../../../../packages/admin/components/AdminGuard';
|
||||
import ReactivateUserModal from '../components/ReactivateUserModal';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ReactivateUserModalPage({ params }: Params) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const { data, error } = use(client.auth.admin.getUserById(params.uid));
|
||||
|
||||
if (!data || error) {
|
||||
throw new Error(`User not found`);
|
||||
}
|
||||
|
||||
const user = data.user;
|
||||
const isActive = !('banned_until' in user) || user.banned_until === 'none';
|
||||
|
||||
if (isActive) {
|
||||
redirect(`/admin/users`);
|
||||
}
|
||||
|
||||
return <ReactivateUserModal user={user} />;
|
||||
}
|
||||
|
||||
export default AdminGuard(ReactivateUserModalPage);
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import If from '@/components/app/If';
|
||||
import { EllipsisVerticalIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
|
||||
function UserActionsDropdown({
|
||||
uid,
|
||||
isBanned,
|
||||
}: React.PropsWithChildren<{
|
||||
uid: string;
|
||||
isBanned: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'ghost'}>
|
||||
<span className={'flex items-center space-x-2.5'}>
|
||||
<span>Manage User</span>
|
||||
|
||||
<EllipsisVerticalIcon className={'w-4'} />
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${uid}/impersonate`}>Impersonate</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<If condition={!isBanned}>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'text-orange-500'}
|
||||
href={`/admin/users/${uid}/ban`}
|
||||
>
|
||||
Ban
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={isBanned}>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/users/${uid}/reactivate`}>Reactivate</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link className={'text-red-500'} href={`/admin/users/${uid}/delete`}>
|
||||
Delete
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserActionsDropdown;
|
||||
@@ -1,238 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import configuration from '@/config/app.config';
|
||||
import type MembershipRole from '@/lib/organizations/types/membership-role';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@kit/ui/table';
|
||||
|
||||
import RoleBadge from '../../../(app)/[account]/account/organization/components/RoleBadge';
|
||||
import AdminGuard from '../../../../packages/admin/components/AdminGuard';
|
||||
import AdminHeader from '../../../../packages/admin/components/AdminHeader';
|
||||
import UserActionsDropdown from './components/UserActionsDropdown';
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: `Manage User | ${configuration.name}`,
|
||||
};
|
||||
|
||||
async function AdminUserPage({ params }: Params) {
|
||||
const uid = params.uid;
|
||||
|
||||
const data = await loadData(uid);
|
||||
const { auth, user } = data;
|
||||
const displayName = user?.displayName;
|
||||
const authUser = auth?.user;
|
||||
const email = authUser?.email;
|
||||
const phone = authUser?.phone;
|
||||
const organizations = data.organizations ?? [];
|
||||
|
||||
const isBanned = Boolean(
|
||||
authUser && 'banned_until' in authUser && authUser.banned_until !== 'none',
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Manage User</AdminHeader>
|
||||
|
||||
<PageBody>
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<div className={'flex justify-between'}>
|
||||
<Breadcrumbs displayName={displayName ?? email ?? ''} />
|
||||
|
||||
<div>
|
||||
<UserActionsDropdown uid={uid} isBanned={isBanned} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Details</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className={'flex items-center space-x-2'}>
|
||||
<div>
|
||||
<Label>Status</Label>
|
||||
</div>
|
||||
|
||||
<div className={'inline-flex'}>
|
||||
{isBanned ? (
|
||||
<Badge variant={'destructive'}>Banned</Badge>
|
||||
) : (
|
||||
<Badge variant={'success'}>Active</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Label>
|
||||
Display name
|
||||
<Input
|
||||
className={'max-w-sm'}
|
||||
defaultValue={displayName ?? ''}
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label>
|
||||
Email
|
||||
<Input
|
||||
className={'max-w-sm'}
|
||||
defaultValue={email ?? ''}
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Label>
|
||||
Phone number
|
||||
<Input
|
||||
className={'max-w-sm'}
|
||||
defaultValue={phone ?? ''}
|
||||
disabled
|
||||
/>
|
||||
</Label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organizations</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Organization ID</TableHead>
|
||||
<TableHead>UUID</TableHead>
|
||||
<TableHead>Organization</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{organizations.map((membership) => {
|
||||
const organization = membership.organization;
|
||||
const href = `/admin/organizations/${organization.uuid}/members`;
|
||||
|
||||
return (
|
||||
<TableRow key={membership.id}>
|
||||
<TableCell>{organization.id}</TableCell>
|
||||
<TableCell>{organization.uuid}</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Link className={'hover:underline'} href={href}>
|
||||
{organization.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className={'inline-flex'}>
|
||||
<RoleBadge role={membership.role} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(AdminUserPage);
|
||||
|
||||
async function loadData(uid: string) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
const authUser = client.auth.admin.getUserById(uid);
|
||||
|
||||
const userData = client
|
||||
.from('users')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
displayName: display_name,
|
||||
photoURL: photo_url,
|
||||
onboarded
|
||||
`,
|
||||
)
|
||||
.eq('id', uid)
|
||||
.single();
|
||||
|
||||
const organizationsQuery = client
|
||||
.from('memberships')
|
||||
.select<
|
||||
string,
|
||||
{
|
||||
id: number;
|
||||
role: MembershipRole;
|
||||
organization: {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
>(
|
||||
`
|
||||
id,
|
||||
role,
|
||||
organization: organization_id !inner (
|
||||
id,
|
||||
uuid,
|
||||
name
|
||||
)
|
||||
`,
|
||||
)
|
||||
.eq('user_id', uid);
|
||||
|
||||
const [auth, user, organizations] = await Promise.all([
|
||||
authUser,
|
||||
userData,
|
||||
organizationsQuery,
|
||||
]);
|
||||
|
||||
return {
|
||||
auth: auth.data,
|
||||
user: user.data,
|
||||
organizations: organizations.data,
|
||||
};
|
||||
}
|
||||
|
||||
function Breadcrumbs(
|
||||
props: React.PropsWithChildren<{
|
||||
displayName: string;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex items-center space-x-1 p-2 text-xs'}>
|
||||
<Link href={'/admin'}>Admin</Link>
|
||||
<ChevronRightIcon className={'w-3'} />
|
||||
<Link href={'/admin/users'}>Users</Link>
|
||||
<ChevronRightIcon className={'w-3'} />
|
||||
<span>{props.displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
|
||||
function UsersAdminPageError() {
|
||||
return (
|
||||
<PageBody>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Could not load users</AlertTitle>
|
||||
<AlertDescription>
|
||||
There was an error loading the users. Please check your console
|
||||
errors.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default UsersAdminPageError;
|
||||
@@ -1,14 +0,0 @@
|
||||
function UserLayout(
|
||||
props: React.PropsWithChildren<{
|
||||
modal: React.ReactNode;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{props.modal}
|
||||
{props.children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserLayout;
|
||||
@@ -1,85 +0,0 @@
|
||||
import { PageBody } from '@/components/app/Page';
|
||||
import appConfig from '@/config/app.config';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
import type UserData from '@kit/session/types/user-data';
|
||||
|
||||
import AdminGuard from '../../../packages/admin/components/AdminGuard';
|
||||
import AdminHeader from '../../../packages/admin/components/AdminHeader';
|
||||
import getPageFromQueryParams from '../utils/get-page-from-query-param';
|
||||
import { getUsers } from './queries';
|
||||
|
||||
interface UsersAdminPageProps {
|
||||
searchParams: {
|
||||
page?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: `Users | ${appConfig.name}`,
|
||||
};
|
||||
|
||||
async function UsersAdminPage({ searchParams }: UsersAdminPageProps) {
|
||||
const page = getPageFromQueryParams(searchParams.page);
|
||||
const perPage = 1;
|
||||
const { users, total } = await loadUsers(page, perPage);
|
||||
const pageCount = Math.ceil(total / perPage);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-1 flex-col'}>
|
||||
<AdminHeader>Users</AdminHeader>
|
||||
|
||||
<PageBody></PageBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminGuard(UsersAdminPage);
|
||||
|
||||
async function loadAuthUsers(page = 1, perPage = 20) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
|
||||
const response = await client.auth.admin.listUsers({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw response.error;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function loadUsers(page = 1, perPage = 20) {
|
||||
const { users: authUsers, total } = await loadAuthUsers(page, perPage);
|
||||
|
||||
const ids = authUsers.map((user) => user.id);
|
||||
const usersData = await getUsers(ids);
|
||||
|
||||
const users = authUsers
|
||||
.map((user) => {
|
||||
const data = usersData.find((u) => u.id === user.id) as UserData;
|
||||
|
||||
const banDuration =
|
||||
'banned_until' in user ? (user.banned_until as string) : 'none';
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
lastSignInAt: user.last_sign_in_at,
|
||||
banDuration,
|
||||
data,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
total,
|
||||
users,
|
||||
};
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { USERS_TABLE } from '@/lib/db-tables';
|
||||
|
||||
import getSupabaseServerComponentClient from '@packages/supabase/server-component-client';
|
||||
|
||||
export async function getUsers(ids: string[]) {
|
||||
const client = getSupabaseServerComponentClient({ admin: true });
|
||||
|
||||
const { data: users, error } = await client
|
||||
.from(USERS_TABLE)
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
photoURL: photo_url,
|
||||
displayName: display_name,
|
||||
onboarded
|
||||
`,
|
||||
)
|
||||
.in('id', ids);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Get page from query params
|
||||
* @name getPageFromQueryParams
|
||||
* @param pageParam
|
||||
*/
|
||||
function getPageFromQueryParams(pageParam: string | undefined) {
|
||||
const page = pageParam ? parseInt(pageParam) : 1;
|
||||
|
||||
if (Number.isNaN(page) || page <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
export default getPageFromQueryParams;
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* @name ENFORCE_MFA
|
||||
* @description Set this constant to true if you want the SuperAdmin user to
|
||||
* sign in using MFA when accessing the Admin page
|
||||
*/
|
||||
const ENFORCE_MFA = false;
|
||||
|
||||
/**
|
||||
* @name isUserSuperAdmin
|
||||
* @description Checks if the current user is an admin by checking the
|
||||
* user_metadata.role field in Supabase Auth is set to a SuperAdmin role.
|
||||
*/
|
||||
const isUserSuperAdmin = async (params: {
|
||||
client: SupabaseClient<Database>;
|
||||
enforceMfa?: boolean;
|
||||
}) => {
|
||||
const enforceMfa = params.enforceMfa ?? ENFORCE_MFA;
|
||||
const { data, error } = await params.client.auth.getUser();
|
||||
|
||||
if (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we enforce MFA, we need to check that the user is MFA authenticated.
|
||||
if (enforceMfa) {
|
||||
const isMfaAuthenticated = await verifyIsMultiFactorAuthenticated(
|
||||
params.client,
|
||||
);
|
||||
|
||||
if (!isMfaAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const adminMetadata = data.user?.app_metadata;
|
||||
const role = adminMetadata?.role;
|
||||
|
||||
return role === 'super-admin';
|
||||
};
|
||||
|
||||
export default isUserSuperAdmin;
|
||||
|
||||
async function verifyIsMultiFactorAuthenticated(client: SupabaseClient) {
|
||||
const { data, error } =
|
||||
await client.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
|
||||
if (error || !data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return data.currentLevel === 'aal2';
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { SiteHeader } from '~/(marketing)/components/site-header';
|
||||
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||
|
||||
const ErrorPage = () => {
|
||||
return (
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useTransition } from 'react';
|
||||
|
||||
import type { Session } from '@supabase/gotrue-js';
|
||||
|
||||
import useRefreshRoute from '@kit/shared/hooks/use-refresh-route';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function ExistingUserInviteForm(
|
||||
props: React.PropsWithChildren<{
|
||||
session: Session;
|
||||
code: string;
|
||||
}>,
|
||||
) {
|
||||
const signOut = useSignOut();
|
||||
const refresh = useRefreshRoute();
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
const onSignOut = useCallback(async () => {
|
||||
await signOut.mutateAsync();
|
||||
refresh();
|
||||
}, [refresh, signOut]);
|
||||
|
||||
const onInviteAccepted = useCallback(() => {
|
||||
return startTransition(async () => {
|
||||
await acceptInviteAction({
|
||||
code: props.code,
|
||||
});
|
||||
});
|
||||
}, [props.code, startTransition]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<p className={'text-center text-sm'}>
|
||||
<Trans
|
||||
i18nKey={'auth:clickToAcceptAs'}
|
||||
values={{ email: props.session?.user.email }}
|
||||
components={{ b: <b /> }}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className={'w-full'}
|
||||
disabled={isSubmitting}
|
||||
onClick={onInviteAccepted}
|
||||
data-test={'accept-invite-submit-button'}
|
||||
type={'submit'}
|
||||
>
|
||||
<Trans i18nKey={'auth:acceptInvite'} />
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<p className={'text-center'}>
|
||||
<span
|
||||
className={
|
||||
'text-center text-sm text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={'auth:acceptInviteWithDifferentAccount'} />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className={'flex justify-center'}>
|
||||
<Button
|
||||
data-test={'invite-sign-out-button'}
|
||||
disabled={isSubmitting}
|
||||
variant={'ghost'}
|
||||
size={'sm'}
|
||||
onClick={onSignOut}
|
||||
type={'button'}
|
||||
>
|
||||
<Trans i18nKey={'auth:signOut'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExistingUserInviteForm;
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState, useTransition } from 'react';
|
||||
|
||||
import { EmailOtpContainer } from '@kit/auth/src/components/email-otp-container';
|
||||
import { OauthProviders } from '@kit/auth/src/components/oauth-providers';
|
||||
import { PasswordSignInContainer } from '@kit/auth/src/components/password-sign-in-container';
|
||||
import { EmailPasswordSignUpContainer } from '@kit/auth/src/components/password-sign-up-container';
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import authConfig from '~/config/auth.config';
|
||||
|
||||
enum Mode {
|
||||
SignUp,
|
||||
SignIn,
|
||||
}
|
||||
|
||||
function NewUserInviteForm(
|
||||
props: React.PropsWithChildren<{
|
||||
code: string;
|
||||
}>,
|
||||
) {
|
||||
const [mode, setMode] = useState<Mode>(Mode.SignUp);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const oAuthReturnUrl = isBrowser() ? window.location.pathname : '';
|
||||
|
||||
const onInviteAccepted = useCallback(
|
||||
async (userId?: string) => {
|
||||
startTransition(async () => {
|
||||
await acceptInviteAction({
|
||||
code: props.code,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
},
|
||||
[props.code],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<If condition={isSubmitting}>
|
||||
<LoadingOverlay fullPage>
|
||||
Accepting invite. Please wait...
|
||||
</LoadingOverlay>
|
||||
</If>
|
||||
|
||||
<OauthProviders inviteCode={props.code} returnUrl={oAuthReturnUrl} />
|
||||
|
||||
<If condition={authConfig.providers.password}>
|
||||
<If condition={mode === Mode.SignUp}>
|
||||
<div className={'flex w-full flex-col items-center space-y-4'}>
|
||||
<EmailPasswordSignUpContainer
|
||||
emailRedirectTo={emailRedirectTo}
|
||||
onSignUp={onInviteAccepted}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={'w-full'}
|
||||
variant={'ghost'}
|
||||
size={'sm'}
|
||||
onClick={() => setMode(Mode.SignIn)}
|
||||
>
|
||||
<Trans i18nKey={'auth:alreadyHaveAccountStatement'} />
|
||||
</Button>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={mode === Mode.SignIn}>
|
||||
<div className={'flex w-full flex-col items-center space-y-4'}>
|
||||
<PasswordSignInContainer onSignIn={onInviteAccepted} />
|
||||
|
||||
<Button
|
||||
className={'w-full'}
|
||||
variant={'ghost'}
|
||||
size={'sm'}
|
||||
onClick={() => setMode(Mode.SignUp)}
|
||||
>
|
||||
<Trans i18nKey={'auth:doNotHaveAccountStatement'} />
|
||||
</Button>
|
||||
</div>
|
||||
</If>
|
||||
</If>
|
||||
|
||||
<If condition={authConfig.providers.magicLink}>
|
||||
<MagicLinkAuth inviteCode={props.code} />
|
||||
</If>
|
||||
|
||||
<If condition={authConfig.providers.otp}>
|
||||
<EmailOtpContainer
|
||||
inviteCode={props.code}
|
||||
shouldCreateUser={true}
|
||||
onSuccess={onInviteAccepted}
|
||||
/>
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewUserInviteForm;
|
||||
@@ -1,17 +1,10 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import ExistingUserInviteForm from '~/join/_components/ExistingUserInviteForm';
|
||||
import NewUserInviteForm from '~/join/_components/NewUserInviteForm';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
interface Context {
|
||||
@@ -28,12 +21,10 @@ async function JoinTeamAccountPage({ searchParams }: Context) {
|
||||
const token = searchParams.invite_token;
|
||||
const data = await getInviteDataFromInviteToken(token);
|
||||
|
||||
if (!data.membership) {
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const organization = data.membership.organization;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading level={4}>
|
||||
@@ -62,70 +53,26 @@ async function JoinTeamAccountPage({ searchParams }: Context) {
|
||||
</If>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<If
|
||||
condition={data.session}
|
||||
fallback={<NewUserInviteForm code={token} />}
|
||||
>
|
||||
{(session) => <ExistingUserInviteForm code={token} session={session} />}
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(JoinTeamAccountPage);
|
||||
|
||||
async function getInviteDataFromInviteToken(code: string) {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
async function getInviteDataFromInviteToken(token: string) {
|
||||
// we use an admin client to be able to read the pending membership
|
||||
// without having to be logged in
|
||||
const adminClient = getSupabaseServerComponentClient({ admin: true });
|
||||
|
||||
const { data: membership, error } = await getInvite(adminClient, code);
|
||||
const { data: invitation, error } = await adminClient
|
||||
.from('invitations')
|
||||
.select('*')
|
||||
.eq('invite_token', token)
|
||||
.single();
|
||||
|
||||
// if the invite wasn't found, it's 404
|
||||
if (error) {
|
||||
Logger.warn(
|
||||
{
|
||||
code,
|
||||
error,
|
||||
},
|
||||
`User navigated to invite page, but it wasn't found. Redirecting to home page...`,
|
||||
);
|
||||
|
||||
notFound();
|
||||
if (!invitation ?? error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data: userSession } = await client.auth.getSession();
|
||||
const session = userSession?.session;
|
||||
const csrfToken = headers().get('x-csrf-token');
|
||||
|
||||
return {
|
||||
csrfToken,
|
||||
session,
|
||||
membership,
|
||||
code,
|
||||
};
|
||||
}
|
||||
|
||||
function getInvite(adminClient: SupabaseClient<Database>, code: string) {
|
||||
return getMembershipByInviteCode<{
|
||||
id: number;
|
||||
code: string;
|
||||
organization: {
|
||||
name: string;
|
||||
id: number;
|
||||
};
|
||||
}>(adminClient, {
|
||||
code,
|
||||
query: `
|
||||
id,
|
||||
code,
|
||||
organization: organization_id (
|
||||
name,
|
||||
id
|
||||
)
|
||||
`,
|
||||
});
|
||||
return invitation;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowLeftIcon } from '@radix-ui/react-icons';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { SiteHeader } from '~/(marketing)/components/site-header';
|
||||
import { SiteHeader } from '~/(marketing)/_components/site-header';
|
||||
import appConfig from '~/config/app.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
@@ -17,7 +17,7 @@ export const metadata = {
|
||||
const NotFoundPage = () => {
|
||||
return (
|
||||
<div className={'flex h-screen flex-1 flex-col'}>
|
||||
<SiteHeader />
|
||||
<SiteHeader session={null} />
|
||||
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import appConfig from '@/config/app.config';
|
||||
import { invariant } from '@epic-web/invariant';
|
||||
import { allDocumentationPages, allPosts } from 'contentlayer/generated';
|
||||
import { getServerSideSitemap } from 'next-sitemap';
|
||||
import { join } from 'path';
|
||||
|
||||
import appConfig from '~/config/app.config';
|
||||
|
||||
const siteUrl = appConfig.url;
|
||||
|
||||
@@ -21,7 +21,7 @@ function getSiteUrls() {
|
||||
|
||||
return urls.map((url) => {
|
||||
return {
|
||||
loc: join(siteUrl, url),
|
||||
loc: new URL(siteUrl, url).href,
|
||||
lastmod: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
@@ -32,7 +32,7 @@ function getPostsSitemap() {
|
||||
|
||||
return allPosts.map((post) => {
|
||||
return {
|
||||
loc: join(siteUrl, post.url),
|
||||
loc: new URL(siteUrl, post.url).href,
|
||||
lastmod: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
@@ -43,7 +43,7 @@ function getDocsSitemap() {
|
||||
|
||||
return allDocumentationPages.map((page) => {
|
||||
return {
|
||||
loc: join(siteUrl, page.url),
|
||||
loc: new URL(siteUrl, page.url).href,
|
||||
lastmod: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { PasswordResetForm } from '@kit/auth/password-reset';
|
||||
import { AuthLayoutShell } from '@kit/auth/shared';
|
||||
import PasswordResetForm from '@kit/auth/src/components/password-reset-form';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
@@ -16,9 +17,11 @@ async function PasswordResetPage() {
|
||||
redirect(pathsConfig.auth.passwordReset);
|
||||
}
|
||||
|
||||
const redirectTo = `/${pathsConfig.auth.callback}?next=${pathsConfig.app.home}`;
|
||||
|
||||
return (
|
||||
<AuthLayoutShell>
|
||||
<PasswordResetForm />
|
||||
<AuthLayoutShell Logo={AppLogo}>
|
||||
<PasswordResetForm redirectTo={redirectTo} />
|
||||
</AuthLayoutShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user