Revert "Unify workspace dropdowns; Update layouts (#458)"
This reverts commit 4bc8448a1d.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export enum LineItemType {
|
||||
Flat = 'flat',
|
||||
@@ -19,13 +19,42 @@ export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
|
||||
|
||||
export const LineItemSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
cost: z.number().min(0),
|
||||
id: z
|
||||
.string({
|
||||
description:
|
||||
'Unique identifier for the line item. Defined by the Provider.',
|
||||
})
|
||||
.min(1),
|
||||
name: z
|
||||
.string({
|
||||
description: 'Name of the line item. Displayed to the user.',
|
||||
})
|
||||
.min(1),
|
||||
description: z
|
||||
.string({
|
||||
description:
|
||||
'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' +
|
||||
' from the line item. This is useful if you want to provide a more detailed description to the user.',
|
||||
})
|
||||
.optional(),
|
||||
cost: z
|
||||
.number({
|
||||
description: 'Cost of the line item. Displayed to the user.',
|
||||
})
|
||||
.min(0),
|
||||
type: LineItemTypeSchema,
|
||||
unit: z.string().optional(),
|
||||
setupFee: z.number().positive().optional(),
|
||||
unit: z
|
||||
.string({
|
||||
description:
|
||||
'Unit of the line item. Displayed to the user. Example "seat" or "GB"',
|
||||
})
|
||||
.optional(),
|
||||
setupFee: z
|
||||
.number({
|
||||
description: `Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`,
|
||||
})
|
||||
.positive()
|
||||
.optional(),
|
||||
tiers: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -61,8 +90,16 @@ export const LineItemSchema = z
|
||||
|
||||
export const PlanSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
id: z
|
||||
.string({
|
||||
description: 'Unique identifier for the plan. Defined by yourself.',
|
||||
})
|
||||
.min(1),
|
||||
name: z
|
||||
.string({
|
||||
description: 'Name of the plan. Displayed to the user.',
|
||||
})
|
||||
.min(1),
|
||||
interval: BillingIntervalSchema.optional(),
|
||||
custom: z.boolean().default(false).optional(),
|
||||
label: z.string().min(1).optional(),
|
||||
@@ -85,7 +122,13 @@ export const PlanSchema = z
|
||||
path: ['lineItems'],
|
||||
},
|
||||
),
|
||||
trialDays: z.number().positive().optional(),
|
||||
trialDays: z
|
||||
.number({
|
||||
description:
|
||||
'Number of days for the trial period. Leave empty for no trial.',
|
||||
})
|
||||
.positive()
|
||||
.optional(),
|
||||
paymentType: PaymentTypeSchema,
|
||||
})
|
||||
.refine(
|
||||
@@ -164,15 +207,56 @@ export const PlanSchema = z
|
||||
|
||||
const ProductSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
currency: z.string().min(3).max(3),
|
||||
badge: z.string().optional(),
|
||||
features: z.array(z.string()).nonempty(),
|
||||
enableDiscountField: z.boolean().optional(),
|
||||
highlighted: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
id: z
|
||||
.string({
|
||||
description:
|
||||
'Unique identifier for the product. Defined by th Provider.',
|
||||
})
|
||||
.min(1),
|
||||
name: z
|
||||
.string({
|
||||
description: 'Name of the product. Displayed to the user.',
|
||||
})
|
||||
.min(1),
|
||||
description: z
|
||||
.string({
|
||||
description: 'Description of the product. Displayed to the user.',
|
||||
})
|
||||
.min(1),
|
||||
currency: z
|
||||
.string({
|
||||
description: 'Currency code for the product. Displayed to the user.',
|
||||
})
|
||||
.min(3)
|
||||
.max(3),
|
||||
badge: z
|
||||
.string({
|
||||
description:
|
||||
'Badge for the product. Displayed to the user. Example: "Popular"',
|
||||
})
|
||||
.optional(),
|
||||
features: z
|
||||
.array(
|
||||
z.string({
|
||||
description: 'Features of the product. Displayed to the user.',
|
||||
}),
|
||||
)
|
||||
.nonempty(),
|
||||
enableDiscountField: z
|
||||
.boolean({
|
||||
description: 'Enable discount field for the product in the checkout.',
|
||||
})
|
||||
.optional(),
|
||||
highlighted: z
|
||||
.boolean({
|
||||
description: 'Highlight this product. Displayed to the user.',
|
||||
})
|
||||
.optional(),
|
||||
hidden: z
|
||||
.boolean({
|
||||
description: 'Hide this product from being displayed to users.',
|
||||
})
|
||||
.optional(),
|
||||
plans: z.array(PlanSchema),
|
||||
})
|
||||
.refine((data) => data.plans.length > 0, {
|
||||
@@ -253,14 +337,14 @@ const BillingSchema = z
|
||||
},
|
||||
);
|
||||
|
||||
export function createBillingSchema(config: z.output<typeof BillingSchema>) {
|
||||
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||
return BillingSchema.parse(config);
|
||||
}
|
||||
|
||||
export type BillingConfig = z.output<typeof BillingSchema>;
|
||||
export type ProductSchema = z.output<typeof ProductSchema>;
|
||||
export type BillingConfig = z.infer<typeof BillingSchema>;
|
||||
export type ProductSchema = z.infer<typeof ProductSchema>;
|
||||
|
||||
export function getPlanIntervals(config: z.output<typeof BillingSchema>) {
|
||||
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
||||
const intervals = config.products
|
||||
.flatMap((product) => product.plans.map((plan) => plan.interval))
|
||||
.filter(Boolean);
|
||||
@@ -279,7 +363,7 @@ export function getPlanIntervals(config: z.output<typeof BillingSchema>) {
|
||||
* @param planId
|
||||
*/
|
||||
export function getPrimaryLineItem(
|
||||
config: z.output<typeof BillingSchema>,
|
||||
config: z.infer<typeof BillingSchema>,
|
||||
planId: string,
|
||||
) {
|
||||
for (const product of config.products) {
|
||||
@@ -307,7 +391,7 @@ export function getPrimaryLineItem(
|
||||
}
|
||||
|
||||
export function getProductPlanPair(
|
||||
config: z.output<typeof BillingSchema>,
|
||||
config: z.infer<typeof BillingSchema>,
|
||||
planId: string,
|
||||
) {
|
||||
for (const product of config.products) {
|
||||
@@ -322,7 +406,7 @@ export function getProductPlanPair(
|
||||
}
|
||||
|
||||
export function getProductPlanPairByVariantId(
|
||||
config: z.output<typeof BillingSchema>,
|
||||
config: z.infer<typeof BillingSchema>,
|
||||
planId: string,
|
||||
) {
|
||||
for (const product of config.products) {
|
||||
@@ -338,7 +422,7 @@ export function getProductPlanPairByVariantId(
|
||||
throw new Error('Plan not found');
|
||||
}
|
||||
|
||||
export type PlanTypeMap = Map<string, z.output<typeof LineItemTypeSchema>>;
|
||||
export type PlanTypeMap = Map<string, z.infer<typeof LineItemTypeSchema>>;
|
||||
|
||||
/**
|
||||
* @name getPlanTypesMap
|
||||
@@ -346,7 +430,7 @@ export type PlanTypeMap = Map<string, z.output<typeof LineItemTypeSchema>>;
|
||||
* @param config
|
||||
*/
|
||||
export function getPlanTypesMap(
|
||||
config: z.output<typeof BillingSchema>,
|
||||
config: z.infer<typeof BillingSchema>,
|
||||
): PlanTypeMap {
|
||||
const planTypes: PlanTypeMap = new Map();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CancelSubscriptionParamsSchema = z.object({
|
||||
subscriptionId: z.string(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateBillingPortalSessionSchema = z.object({
|
||||
returnUrl: z.string().url(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PlanSchema } from '../create-billing-schema';
|
||||
|
||||
@@ -15,5 +15,5 @@ export const CreateBillingCheckoutSchema = z.object({
|
||||
quantity: z.number(),
|
||||
}),
|
||||
),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
const TimeFilter = z.object({
|
||||
startTime: z.number(),
|
||||
endTime: z.number(),
|
||||
});
|
||||
const TimeFilter = z.object(
|
||||
{
|
||||
startTime: z.number(),
|
||||
endTime: z.number(),
|
||||
},
|
||||
{
|
||||
description: `The time range to filter the usage records. Used for Stripe`,
|
||||
},
|
||||
);
|
||||
|
||||
const PageFilter = z.object({
|
||||
page: z.number(),
|
||||
size: z.number(),
|
||||
});
|
||||
const PageFilter = z.object(
|
||||
{
|
||||
page: z.number(),
|
||||
size: z.number(),
|
||||
},
|
||||
{
|
||||
description: `The page and size to filter the usage records. Used for LS`,
|
||||
},
|
||||
);
|
||||
|
||||
export const QueryBillingUsageSchema = z.object({
|
||||
id: z.string(),
|
||||
customerId: z.string(),
|
||||
id: z.string({
|
||||
description:
|
||||
'The id of the usage record. For Stripe a meter ID, for LS a subscription item ID.',
|
||||
}),
|
||||
customerId: z.string({
|
||||
description: 'The id of the customer in the billing system',
|
||||
}),
|
||||
filter: z.union([TimeFilter, PageFilter]),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ReportBillingUsageSchema = z.object({
|
||||
id: z.string(),
|
||||
eventName: z.string().optional(),
|
||||
id: z.string({
|
||||
description:
|
||||
'The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.',
|
||||
}),
|
||||
eventName: z
|
||||
.string({
|
||||
description: 'The name of the event that triggered the usage',
|
||||
})
|
||||
.optional(),
|
||||
usage: z.object({
|
||||
quantity: z.number(),
|
||||
action: z.enum(['increment', 'set']).optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RetrieveCheckoutSessionSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const UpdateSubscriptionParamsSchema = z.object({
|
||||
subscriptionId: z.string().min(1),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CancelSubscriptionParamsSchema,
|
||||
@@ -13,13 +13,13 @@ import { UpsertSubscriptionParams } from '../types';
|
||||
|
||||
export abstract class BillingStrategyProviderService {
|
||||
abstract createBillingPortalSession(
|
||||
params: z.output<typeof CreateBillingPortalSessionSchema>,
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
): Promise<{
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
abstract retrieveCheckoutSession(
|
||||
params: z.output<typeof RetrieveCheckoutSessionSchema>,
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
): Promise<{
|
||||
checkoutToken: string | null;
|
||||
status: 'complete' | 'expired' | 'open';
|
||||
@@ -31,31 +31,31 @@ export abstract class BillingStrategyProviderService {
|
||||
}>;
|
||||
|
||||
abstract createCheckoutSession(
|
||||
params: z.output<typeof CreateBillingCheckoutSchema>,
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
): Promise<{
|
||||
checkoutToken: string;
|
||||
}>;
|
||||
|
||||
abstract cancelSubscription(
|
||||
params: z.output<typeof CancelSubscriptionParamsSchema>,
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
|
||||
abstract reportUsage(
|
||||
params: z.output<typeof ReportBillingUsageSchema>,
|
||||
params: z.infer<typeof ReportBillingUsageSchema>,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
|
||||
abstract queryUsage(
|
||||
params: z.output<typeof QueryBillingUsageSchema>,
|
||||
params: z.infer<typeof QueryBillingUsageSchema>,
|
||||
): Promise<{
|
||||
value: number;
|
||||
}>;
|
||||
|
||||
abstract updateSubscriptionItem(
|
||||
params: z.output<typeof UpdateSubscriptionParamsSchema>,
|
||||
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-intl": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"react-i18next": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"typesVersions": {
|
||||
|
||||
@@ -17,19 +17,19 @@ export function BillingPortalCard() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="billing.billingPortalCardTitle" />
|
||||
<Trans i18nKey="billing:billingPortalCardTitle" />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey="billing.billingPortalCardDescription" />
|
||||
<Trans i18nKey="billing:billingPortalCardDescription" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-2'}>
|
||||
<div>
|
||||
<Button type="submit" data-test={'manage-billing-redirect-button'}>
|
||||
<Button data-test={'manage-billing-redirect-button'}>
|
||||
<span>
|
||||
<Trans i18nKey="billing.billingPortalCardButton" />
|
||||
<Trans i18nKey="billing:billingPortalCardButton" />
|
||||
</span>
|
||||
|
||||
<ArrowUpRight className={'h-4'} />
|
||||
|
||||
@@ -41,7 +41,7 @@ export function BillingSessionStatus({
|
||||
|
||||
<Heading level={3}>
|
||||
<span className={'mr-4 font-semibold'}>
|
||||
<Trans i18nKey={'billing.checkoutSuccessTitle'} />
|
||||
<Trans i18nKey={'billing:checkoutSuccessTitle'} />
|
||||
</span>
|
||||
🎉
|
||||
</Heading>
|
||||
@@ -49,26 +49,22 @@ export function BillingSessionStatus({
|
||||
<div className={'text-muted-foreground flex flex-col space-y-4'}>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={'billing.checkoutSuccessDescription'}
|
||||
i18nKey={'billing:checkoutSuccessDescription'}
|
||||
values={{ customerEmail }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
data-test={'checkout-success-back-link'}
|
||||
render={
|
||||
<Link href={redirectPath}>
|
||||
<span>
|
||||
<Trans i18nKey={'billing.checkoutSuccessBackButton'} />
|
||||
</span>
|
||||
<Button data-test={'checkout-success-back-link'} asChild>
|
||||
<Link href={redirectPath}>
|
||||
<span>
|
||||
<Trans i18nKey={'billing:checkoutSuccessBackButton'} />
|
||||
</span>
|
||||
|
||||
<ChevronRight className={'h-4'} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<ChevronRight className={'h-4'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -44,11 +44,11 @@ export function CurrentLifetimeOrderCard({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="billing.planCardTitle" />
|
||||
<Trans i18nKey="billing:planCardTitle" />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey="billing.planCardDescription" />
|
||||
<Trans i18nKey="billing:planCardDescription" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function CurrentLifetimeOrderCard({
|
||||
<div>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing.detailsLabel" />
|
||||
<Trans i18nKey="billing:detailsLabel" />
|
||||
</span>
|
||||
|
||||
<LineItemDetails
|
||||
|
||||
@@ -21,7 +21,7 @@ export function CurrentPlanAlert(
|
||||
status: Enums<'subscription_status'>;
|
||||
}>,
|
||||
) {
|
||||
const prefix = 'billing.status';
|
||||
const prefix = 'billing:status';
|
||||
|
||||
const text = `${prefix}.${props.status}.description`;
|
||||
const title = `${prefix}.${props.status}.heading`;
|
||||
|
||||
@@ -23,7 +23,7 @@ export function CurrentPlanBadge(
|
||||
status: Status;
|
||||
}>,
|
||||
) {
|
||||
const text = `billing.status.${props.status}.badge`;
|
||||
const text = `billing:status.${props.status}.badge`;
|
||||
const variant = statusBadgeMap[props.status];
|
||||
|
||||
return (
|
||||
|
||||
@@ -48,11 +48,11 @@ export function CurrentSubscriptionCard({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="billing.planCardTitle" />
|
||||
<Trans i18nKey="billing:planCardTitle" />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey="billing.planCardDescription" />
|
||||
<Trans i18nKey="billing:planCardDescription" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -94,7 +94,7 @@ export function CurrentSubscriptionCard({
|
||||
|
||||
<div className="flex flex-col gap-y-1 border-y border-dashed py-4">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing.detailsLabel" />
|
||||
<Trans i18nKey="billing:detailsLabel" />
|
||||
</span>
|
||||
|
||||
<LineItemDetails
|
||||
@@ -110,12 +110,12 @@ export function CurrentSubscriptionCard({
|
||||
<InfoIcon className={'h-4 w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey="billing.trialAlertTitle" />
|
||||
<Trans i18nKey="billing:trialAlertTitle" />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="billing.trialAlertDescription"
|
||||
i18nKey="billing:trialAlertDescription"
|
||||
values={{
|
||||
date: formatDate(
|
||||
subscription.trial_ends_at ?? '',
|
||||
@@ -134,12 +134,12 @@ export function CurrentSubscriptionCard({
|
||||
<MessageCircleWarning className={'h-4 w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey="billing.subscriptionCancelled" />
|
||||
<Trans i18nKey="billing:subscriptionCancelled" />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="billing.cancelSubscriptionDate"
|
||||
i18nKey="billing:cancelSubscriptionDate"
|
||||
values={{
|
||||
date: formatDate(
|
||||
subscription.period_ends_at ?? '',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { PlusSquare } from 'lucide-react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import * as z from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { LineItemSchema } from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
@@ -14,14 +14,14 @@ const className = 'flex text-secondary-foreground items-center text-sm';
|
||||
|
||||
export function LineItemDetails(
|
||||
props: React.PropsWithChildren<{
|
||||
lineItems: z.output<typeof LineItemSchema>[];
|
||||
lineItems: z.infer<typeof LineItemSchema>[];
|
||||
currency: string;
|
||||
selectedInterval?: string | undefined;
|
||||
alwaysDisplayMonthlyPrice?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const t = useTranslations('billing');
|
||||
const locale = useLocale();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language;
|
||||
const currencyCode = props?.currency.toLowerCase();
|
||||
|
||||
const shouldDisplayMonthlyPrice =
|
||||
@@ -32,16 +32,16 @@ export function LineItemDetails(
|
||||
return '';
|
||||
}
|
||||
|
||||
const i18nKey = `units.${unit}` as never;
|
||||
const i18nKey = `billing:units.${unit}`;
|
||||
|
||||
if (!t.has(i18nKey)) {
|
||||
if (!i18n.exists(i18nKey)) {
|
||||
return unit;
|
||||
}
|
||||
|
||||
return t(i18nKey, {
|
||||
count,
|
||||
defaultValue: unit,
|
||||
} as never);
|
||||
});
|
||||
};
|
||||
|
||||
const getDisplayCost = (cost: number, hasTiers: boolean) => {
|
||||
@@ -82,7 +82,7 @@ export function LineItemDetails(
|
||||
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing.setupFee'}
|
||||
i18nKey={'billing:setupFee'}
|
||||
values={{
|
||||
setupFee: formatCurrency({
|
||||
currencyCode,
|
||||
@@ -111,18 +111,18 @@ export function LineItemDetails(
|
||||
<PlusSquare className={'w-3'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'billing.basePlan'} />
|
||||
<Trans i18nKey={'billing:basePlan'} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<If
|
||||
condition={props.selectedInterval}
|
||||
fallback={<Trans i18nKey={'billing.lifetime'} />}
|
||||
fallback={<Trans i18nKey={'billing:lifetime'} />}
|
||||
>
|
||||
(
|
||||
<Trans
|
||||
i18nKey={`billing.billingInterval.${props.selectedInterval}`}
|
||||
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
|
||||
/>
|
||||
)
|
||||
</If>
|
||||
@@ -149,7 +149,7 @@ export function LineItemDetails(
|
||||
<span className={'flex gap-x-2 text-sm'}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing.perUnit'}
|
||||
i18nKey={'billing:perUnit'}
|
||||
values={{
|
||||
unit: getUnitLabel(unit, 1),
|
||||
}}
|
||||
@@ -172,10 +172,10 @@ export function LineItemDetails(
|
||||
<span>
|
||||
<If
|
||||
condition={Boolean(unit) && !isDefaultSeatUnit}
|
||||
fallback={<Trans i18nKey={'billing.perTeamMember'} />}
|
||||
fallback={<Trans i18nKey={'billing:perTeamMember'} />}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'billing.perUnitShort'}
|
||||
i18nKey={'billing:perUnitShort'}
|
||||
values={{
|
||||
unit: getUnitLabel(unit, 1),
|
||||
}}
|
||||
@@ -215,7 +215,7 @@ export function LineItemDetails(
|
||||
<span className={'flex space-x-1'}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing.perUnit'}
|
||||
i18nKey={'billing:perUnit'}
|
||||
values={{
|
||||
unit: getUnitLabel(unit, 1),
|
||||
}}
|
||||
@@ -268,11 +268,11 @@ function Tiers({
|
||||
unit,
|
||||
}: {
|
||||
currency: string;
|
||||
item: z.infer<typeof LineItemSchema>;
|
||||
unit?: string;
|
||||
item: z.output<typeof LineItemSchema>;
|
||||
}) {
|
||||
const t = useTranslations('billing');
|
||||
const locale = useLocale();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language;
|
||||
|
||||
// Helper to safely convert tier values to numbers for pluralization
|
||||
// Falls back to plural form (2) for 'unlimited' values
|
||||
@@ -285,13 +285,10 @@ function Tiers({
|
||||
const getUnitLabel = (count: number) => {
|
||||
if (!unit) return '';
|
||||
|
||||
return t(
|
||||
`units.${unit}` as never,
|
||||
{
|
||||
count,
|
||||
defaultValue: unit,
|
||||
} as never,
|
||||
);
|
||||
return t(`billing:units.${unit}`, {
|
||||
count,
|
||||
defaultValue: unit,
|
||||
});
|
||||
};
|
||||
|
||||
const tiers = item.tiers?.map((tier, index) => {
|
||||
@@ -330,7 +327,7 @@ function Tiers({
|
||||
<If condition={tiersLength > 1}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing.andAbove'}
|
||||
i18nKey={'billing:andAbove'}
|
||||
values={{
|
||||
unit: getUnitLabel(getSafeCount(previousTierFrom) - 1),
|
||||
previousTier: getSafeCount(previousTierFrom) - 1,
|
||||
@@ -341,7 +338,7 @@ function Tiers({
|
||||
<If condition={tiersLength === 1}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing.forEveryUnit'}
|
||||
i18nKey={'billing:forEveryUnit'}
|
||||
values={{
|
||||
unit: getUnitLabel(1),
|
||||
}}
|
||||
@@ -353,7 +350,7 @@ function Tiers({
|
||||
<If condition={isIncluded}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing.includedUpTo'}
|
||||
i18nKey={'billing:includedUpTo'}
|
||||
values={{
|
||||
unit: getUnitLabel(getSafeCount(upTo)),
|
||||
upTo,
|
||||
@@ -371,7 +368,7 @@ function Tiers({
|
||||
</span>{' '}
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing.fromPreviousTierUpTo'}
|
||||
i18nKey={'billing:fromPreviousTierUpTo'}
|
||||
values={{
|
||||
previousTierFrom,
|
||||
unit: getUnitLabel(1),
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useLocale } from 'next-intl';
|
||||
import * as z from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { LineItemSchema } from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
type PlanCostDisplayProps = {
|
||||
primaryLineItem: z.output<typeof LineItemSchema>;
|
||||
primaryLineItem: z.infer<typeof LineItemSchema>;
|
||||
currencyCode: string;
|
||||
interval?: string;
|
||||
alwaysDisplayMonthlyPrice?: boolean;
|
||||
@@ -30,7 +30,7 @@ export function PlanCostDisplay({
|
||||
alwaysDisplayMonthlyPrice = true,
|
||||
className,
|
||||
}: PlanCostDisplayProps) {
|
||||
const locale = useLocale();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const { shouldDisplayTier, lowestTier, tierTranslationKey, displayCost } =
|
||||
useMemo(() => {
|
||||
@@ -62,8 +62,8 @@ export function PlanCostDisplay({
|
||||
isMultiTier,
|
||||
lowestTier,
|
||||
tierTranslationKey: isMultiTier
|
||||
? 'billing.startingAtPriceUnit'
|
||||
: 'billing.priceUnit',
|
||||
? 'billing:startingAtPriceUnit'
|
||||
: 'billing:priceUnit',
|
||||
displayCost: cost,
|
||||
};
|
||||
}, [primaryLineItem, interval, alwaysDisplayMonthlyPrice]);
|
||||
@@ -72,7 +72,7 @@ export function PlanCostDisplay({
|
||||
const formattedCost = formatCurrency({
|
||||
currencyCode: currencyCode.toLowerCase(),
|
||||
value: lowestTier?.cost ?? 0,
|
||||
locale: locale,
|
||||
locale: i18n.language,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -91,7 +91,7 @@ export function PlanCostDisplay({
|
||||
const formattedCost = formatCurrency({
|
||||
currencyCode: currencyCode.toLowerCase(),
|
||||
value: displayCost,
|
||||
locale: locale,
|
||||
locale: i18n.language,
|
||||
});
|
||||
|
||||
return <span className={className}>{formattedCost}</span>;
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingConfig,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
@@ -49,7 +50,7 @@ export function PlanPicker(
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const t = useTranslations('billing');
|
||||
const { t } = useTranslation(`billing`);
|
||||
|
||||
const intervals = useMemo(
|
||||
() => getPlanIntervals(props.config),
|
||||
@@ -136,7 +137,7 @@ export function PlanPicker(
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className={'flex flex-col gap-4'}>
|
||||
<FormControl>
|
||||
<FormControl id={'plan-picker-id'}>
|
||||
<RadioGroup name={field.name} value={field.value}>
|
||||
<div className={'flex space-x-1'}>
|
||||
{intervals.map((interval) => {
|
||||
@@ -146,23 +147,6 @@ export function PlanPicker(
|
||||
<label
|
||||
htmlFor={interval}
|
||||
key={interval}
|
||||
onClick={() => {
|
||||
form.setValue('interval', interval, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
if (selectedProduct) {
|
||||
const plan = selectedProduct.plans.find(
|
||||
(item) => item.interval === interval,
|
||||
);
|
||||
|
||||
form.setValue('planId', plan?.id ?? '', {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'focus-within:border-primary flex items-center gap-x-2.5 rounded-md px-2.5 py-2 transition-colors',
|
||||
{
|
||||
@@ -174,6 +158,27 @@ export function PlanPicker(
|
||||
<RadioGroupItem
|
||||
id={interval}
|
||||
value={interval}
|
||||
onClick={() => {
|
||||
form.setValue('interval', interval, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
if (selectedProduct) {
|
||||
const plan = selectedProduct.plans.find(
|
||||
(item) => item.interval === interval,
|
||||
);
|
||||
|
||||
form.setValue(
|
||||
'planId',
|
||||
plan?.id ?? '',
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<span
|
||||
@@ -182,7 +187,7 @@ export function PlanPicker(
|
||||
})}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`billing.billingInterval.${interval}`}
|
||||
i18nKey={`billing:billingInterval.${interval}`}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
@@ -239,28 +244,15 @@ export function PlanPicker(
|
||||
<RadioGroupItemLabel
|
||||
selected={selected}
|
||||
key={primaryLineItem.id}
|
||||
htmlFor={primaryLineItem.id}
|
||||
className="rounded-md !border-transparent"
|
||||
onClick={() => {
|
||||
if (selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue('planId', planId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-col content-center gap-y-3 lg:flex-row lg:items-center lg:justify-between lg:space-y-0'
|
||||
}
|
||||
>
|
||||
<div
|
||||
<Label
|
||||
htmlFor={plan.id}
|
||||
className={
|
||||
'flex flex-col justify-center space-y-2.5'
|
||||
}
|
||||
@@ -271,11 +263,24 @@ export function PlanPicker(
|
||||
key={plan.id + selected}
|
||||
id={plan.id}
|
||||
value={plan.id}
|
||||
onClick={() => {
|
||||
if (selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue('planId', planId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="font-semibold">
|
||||
<Trans
|
||||
i18nKey={`billing.plans.${product.id}.name`}
|
||||
i18nKey={`billing:plans.${product.id}.name`}
|
||||
defaults={product.name}
|
||||
/>
|
||||
</span>
|
||||
@@ -291,7 +296,7 @@ export function PlanPicker(
|
||||
variant={'success'}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`billing.trialPeriod`}
|
||||
i18nKey={`billing:trialPeriod`}
|
||||
values={{
|
||||
period: plan.trialDays,
|
||||
}}
|
||||
@@ -303,11 +308,11 @@ export function PlanPicker(
|
||||
|
||||
<span className={'text-muted-foreground'}>
|
||||
<Trans
|
||||
i18nKey={`billing.plans.${product.id}.description`}
|
||||
i18nKey={`billing:plans.${product.id}.description`}
|
||||
defaults={product.description}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<div
|
||||
className={
|
||||
@@ -331,10 +336,10 @@ export function PlanPicker(
|
||||
plan.paymentType === 'recurring'
|
||||
}
|
||||
fallback={
|
||||
<Trans i18nKey={`billing.lifetime`} />
|
||||
<Trans i18nKey={`billing:lifetime`} />
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={`billing.perMonth`} />
|
||||
<Trans i18nKey={`billing:perMonth`} />
|
||||
</If>
|
||||
</span>
|
||||
</div>
|
||||
@@ -362,7 +367,6 @@ export function PlanPicker(
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
data-test="checkout-submit-button"
|
||||
disabled={props.pending ?? !form.formState.isValid}
|
||||
>
|
||||
@@ -404,7 +408,7 @@ function PlanDetails({
|
||||
selectedInterval: string;
|
||||
|
||||
selectedPlan: {
|
||||
lineItems: z.output<typeof LineItemSchema>[];
|
||||
lineItems: z.infer<typeof LineItemSchema>[];
|
||||
paymentType: string;
|
||||
};
|
||||
}) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as z from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingConfig,
|
||||
@@ -122,14 +122,14 @@ function PricingItem(
|
||||
|
||||
selectable: boolean;
|
||||
|
||||
primaryLineItem: z.output<typeof LineItemSchema> | undefined;
|
||||
primaryLineItem: z.infer<typeof LineItemSchema> | undefined;
|
||||
|
||||
redirectToCheckout?: boolean;
|
||||
alwaysDisplayMonthlyPrice?: boolean;
|
||||
|
||||
plan: {
|
||||
id: string;
|
||||
lineItems: z.output<typeof LineItemSchema>[];
|
||||
lineItems: z.infer<typeof LineItemSchema>[];
|
||||
interval?: Interval;
|
||||
name?: string;
|
||||
href?: string;
|
||||
@@ -154,19 +154,19 @@ function PricingItem(
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const t = useTranslations();
|
||||
const { t, i18n } = useTranslation();
|
||||
const highlighted = props.product.highlighted ?? false;
|
||||
const lineItem = props.primaryLineItem!;
|
||||
const isCustom = props.plan.custom ?? false;
|
||||
|
||||
const i18nKey = `billing.units.${lineItem.unit}` as never;
|
||||
const i18nKey = `billing:units.${lineItem.unit}`;
|
||||
|
||||
const unitLabel = lineItem?.unit
|
||||
? t.has(i18nKey)
|
||||
? i18n.exists(i18nKey)
|
||||
? t(i18nKey, {
|
||||
count: 1,
|
||||
defaultValue: lineItem.unit,
|
||||
} as never)
|
||||
})
|
||||
: lineItem.unit
|
||||
: '';
|
||||
|
||||
@@ -260,10 +260,10 @@ function PricingItem(
|
||||
<span>
|
||||
<If
|
||||
condition={props.plan.interval}
|
||||
fallback={<Trans i18nKey={'billing.lifetime'} />}
|
||||
fallback={<Trans i18nKey={'billing:lifetime'} />}
|
||||
>
|
||||
{(interval) => (
|
||||
<Trans i18nKey={`billing.billingInterval.${interval}`} />
|
||||
<Trans i18nKey={`billing:billingInterval.${interval}`} />
|
||||
)}
|
||||
</If>
|
||||
</span>
|
||||
@@ -279,10 +279,10 @@ function PricingItem(
|
||||
<If condition={lineItem?.type === 'per_seat'}>
|
||||
<If
|
||||
condition={Boolean(lineItem?.unit) && !isDefaultSeatUnit}
|
||||
fallback={<Trans i18nKey={'billing.perTeamMember'} />}
|
||||
fallback={<Trans i18nKey={'billing:perTeamMember'} />}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'billing.perUnitShort'}
|
||||
i18nKey={'billing:perUnitShort'}
|
||||
values={{
|
||||
unit: unitLabel,
|
||||
}}
|
||||
@@ -294,7 +294,7 @@ function PricingItem(
|
||||
condition={lineItem?.type !== 'per_seat' && lineItem?.unit}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'billing.perUnit'}
|
||||
i18nKey={'billing:perUnit'}
|
||||
values={{
|
||||
unit: lineItem?.unit,
|
||||
}}
|
||||
@@ -343,7 +343,7 @@ function PricingItem(
|
||||
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<h6 className={'text-sm font-semibold'}>
|
||||
<Trans i18nKey={'billing.detailsLabel'} />
|
||||
<Trans i18nKey={'billing:detailsLabel'} />
|
||||
</h6>
|
||||
|
||||
<LineItemDetails
|
||||
@@ -402,7 +402,7 @@ function Price({
|
||||
<span className={'text-muted-foreground text-sm leading-loose'}>
|
||||
<span>/</span>
|
||||
|
||||
<Trans i18nKey={'billing.perMonth'} />
|
||||
<Trans i18nKey={'billing:perMonth'} />
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
@@ -446,41 +446,41 @@ function PlanIntervalSwitcher(
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'hover:border-border border-border/50 flex gap-x-0 rounded-full border'
|
||||
'hover:border-border flex gap-x-1 rounded-full border border-transparent transition-colors'
|
||||
}
|
||||
>
|
||||
{props.intervals.map((plan, index) => {
|
||||
const selected = plan === props.interval;
|
||||
|
||||
const className = cn(
|
||||
'animate-in fade-in rounded-full transition-all focus:!ring-0',
|
||||
'animate-in fade-in rounded-full !outline-hidden transition-all focus:!ring-0',
|
||||
{
|
||||
'border-r-transparent': index === 0,
|
||||
['hover:text-primary text-muted-foreground']: !selected,
|
||||
['cursor-default']: selected,
|
||||
['cursor-default font-semibold']: selected,
|
||||
['hover:bg-initial']: !selected,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
key={plan}
|
||||
variant={selected ? 'secondary' : 'custom'}
|
||||
size={'sm'}
|
||||
variant={selected ? 'secondary' : 'ghost'}
|
||||
className={className}
|
||||
onClick={() => props.setInterval(plan)}
|
||||
>
|
||||
<span className={'flex items-center'}>
|
||||
<CheckCircle
|
||||
className={cn(
|
||||
'animate-in fade-in zoom-in-50 mr-1 size-3 duration-200',
|
||||
{
|
||||
hidden: !selected,
|
||||
},
|
||||
)}
|
||||
className={cn('animate-in fade-in zoom-in-95 h-3', {
|
||||
hidden: !selected,
|
||||
'slide-in-from-left-4': index === 0,
|
||||
'slide-in-from-right-4': index === props.intervals.length - 1,
|
||||
})}
|
||||
/>
|
||||
|
||||
<span className={'text-xs capitalize'}>
|
||||
<Trans i18nKey={`billing.billingInterval.${plan}`} />
|
||||
<span className={'capitalize'}>
|
||||
<Trans i18nKey={`common:billingInterval.${plan}`} />
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
@@ -509,7 +509,7 @@ function DefaultCheckoutButton(
|
||||
highlighted?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const t = useTranslations('billing');
|
||||
const { t } = useTranslation('billing');
|
||||
|
||||
const signUpPath = props.paths.signUp;
|
||||
|
||||
@@ -522,7 +522,7 @@ function DefaultCheckoutButton(
|
||||
const linkHref =
|
||||
props.plan.href ?? `${signUpPath}?${searchParams.toString()}`;
|
||||
|
||||
const label = props.plan.buttonLabel ?? 'common.getStartedWithPlan';
|
||||
const label = props.plan.buttonLabel ?? 'common:getStartedWithPlan';
|
||||
|
||||
return (
|
||||
<Link className={'w-full'} href={linkHref}>
|
||||
@@ -536,9 +536,9 @@ function DefaultCheckoutButton(
|
||||
i18nKey={label}
|
||||
defaults={label}
|
||||
values={{
|
||||
plan: t.has(props.product.name as never)
|
||||
? t(props.product.name as never)
|
||||
: props.product.name,
|
||||
plan: t(props.product.name, {
|
||||
defaultValue: props.product.name,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'server-only';
|
||||
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type BillingProviderSchema,
|
||||
@@ -20,7 +20,7 @@ export function createBillingEventHandlerFactoryService(
|
||||
// Create a registry for billing webhook handlers
|
||||
const billingWebhookHandlerRegistry = createRegistry<
|
||||
BillingWebhookHandlerService,
|
||||
z.output<typeof BillingProviderSchema>
|
||||
z.infer<typeof BillingProviderSchema>
|
||||
>();
|
||||
|
||||
// Register the Stripe webhook handler
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'server-only';
|
||||
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type BillingProviderSchema,
|
||||
@@ -11,7 +11,7 @@ import { createRegistry } from '@kit/shared/registry';
|
||||
// Create a registry for billing strategy providers
|
||||
export const billingStrategyRegistry = createRegistry<
|
||||
BillingStrategyProviderService,
|
||||
z.output<typeof BillingProviderSchema>
|
||||
z.infer<typeof BillingProviderSchema>
|
||||
>();
|
||||
|
||||
// Register the Stripe billing strategy
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BillingProviderSchema } from '@kit/billing';
|
||||
import {
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { billingStrategyRegistry } from './billing-gateway-registry';
|
||||
|
||||
export function createBillingGatewayService(
|
||||
provider: z.output<typeof BillingProviderSchema>,
|
||||
provider: z.infer<typeof BillingProviderSchema>,
|
||||
) {
|
||||
return new BillingGatewayService(provider);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function createBillingGatewayService(
|
||||
*/
|
||||
class BillingGatewayService {
|
||||
constructor(
|
||||
private readonly provider: z.output<typeof BillingProviderSchema>,
|
||||
private readonly provider: z.infer<typeof BillingProviderSchema>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -40,7 +40,7 @@ class BillingGatewayService {
|
||||
*
|
||||
*/
|
||||
async createCheckoutSession(
|
||||
params: z.output<typeof CreateBillingCheckoutSchema>,
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = CreateBillingCheckoutSchema.parse(params);
|
||||
@@ -54,7 +54,7 @@ class BillingGatewayService {
|
||||
* @param {RetrieveCheckoutSessionSchema} params - The parameters to retrieve the checkout session.
|
||||
*/
|
||||
async retrieveCheckoutSession(
|
||||
params: z.output<typeof RetrieveCheckoutSessionSchema>,
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = RetrieveCheckoutSessionSchema.parse(params);
|
||||
@@ -68,7 +68,7 @@ class BillingGatewayService {
|
||||
* @param {CreateBillingPortalSessionSchema} params - The parameters to create the billing portal session.
|
||||
*/
|
||||
async createBillingPortalSession(
|
||||
params: z.output<typeof CreateBillingPortalSessionSchema>,
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = CreateBillingPortalSessionSchema.parse(params);
|
||||
@@ -82,7 +82,7 @@ class BillingGatewayService {
|
||||
* @param {CancelSubscriptionParamsSchema} params - The parameters for cancelling the subscription.
|
||||
*/
|
||||
async cancelSubscription(
|
||||
params: z.output<typeof CancelSubscriptionParamsSchema>,
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = CancelSubscriptionParamsSchema.parse(params);
|
||||
@@ -95,7 +95,7 @@ class BillingGatewayService {
|
||||
* @description This is used to report the usage of the billing to the provider.
|
||||
* @param params
|
||||
*/
|
||||
async reportUsage(params: z.output<typeof ReportBillingUsageSchema>) {
|
||||
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = ReportBillingUsageSchema.parse(params);
|
||||
|
||||
@@ -107,7 +107,7 @@ class BillingGatewayService {
|
||||
* @description Queries the usage of the metered billing.
|
||||
* @param params
|
||||
*/
|
||||
async queryUsage(params: z.output<typeof QueryBillingUsageSchema>) {
|
||||
async queryUsage(params: z.infer<typeof QueryBillingUsageSchema>) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = QueryBillingUsageSchema.parse(params);
|
||||
|
||||
@@ -129,7 +129,7 @@ class BillingGatewayService {
|
||||
* @param params
|
||||
*/
|
||||
async updateSubscriptionItem(
|
||||
params: z.output<typeof UpdateSubscriptionParamsSchema>,
|
||||
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
|
||||
) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = UpdateSubscriptionParamsSchema.parse(params);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'server-only';
|
||||
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingConfig,
|
||||
@@ -24,7 +24,7 @@ export async function resolveProductPlan(
|
||||
currency: string,
|
||||
): Promise<{
|
||||
product: ProductSchema;
|
||||
plan: z.output<typeof PlanSchema>;
|
||||
plan: z.infer<typeof PlanSchema>;
|
||||
}> {
|
||||
// we can't always guarantee that the plan will be present in the local config
|
||||
// so we need to fallback to fetching the plan details from the billing provider
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* @name getLemonSqueezyEnv
|
||||
@@ -10,18 +10,18 @@ export const getLemonSqueezyEnv = () =>
|
||||
.object({
|
||||
secretKey: z
|
||||
.string({
|
||||
error: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`,
|
||||
description: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`,
|
||||
})
|
||||
.min(1),
|
||||
webhooksSecret: z
|
||||
.string({
|
||||
error: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`,
|
||||
description: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`,
|
||||
})
|
||||
.min(1)
|
||||
.max(40),
|
||||
storeId: z
|
||||
.string({
|
||||
error: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`,
|
||||
description: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`,
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
||||
* @param {object} params - The parameters required to create the billing portal session.
|
||||
*/
|
||||
export async function createLemonSqueezyBillingPortalSession(
|
||||
params: z.output<typeof CreateBillingPortalSessionSchema>,
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
await initializeLemonSqueezyClient();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
createCheckout,
|
||||
getCustomer,
|
||||
} from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CreateBillingCheckoutSchema } from '@kit/billing/schema';
|
||||
|
||||
@@ -14,7 +14,7 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
||||
* Creates a checkout for a Lemon Squeezy product.
|
||||
*/
|
||||
export async function createLemonSqueezyCheckout(
|
||||
params: z.output<typeof CreateBillingCheckoutSchema>,
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
await initializeLemonSqueezyClient();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
listUsageRecords,
|
||||
updateSubscriptionItem,
|
||||
} from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingStrategyProviderService } from '@kit/billing';
|
||||
import type {
|
||||
@@ -40,7 +40,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
|
||||
* @param params
|
||||
*/
|
||||
async createCheckoutSession(
|
||||
params: z.output<typeof CreateBillingCheckoutSchema>,
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
@@ -78,7 +78,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
|
||||
* @param params
|
||||
*/
|
||||
async createBillingPortalSession(
|
||||
params: z.output<typeof CreateBillingPortalSessionSchema>,
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
@@ -117,7 +117,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
|
||||
* @param params
|
||||
*/
|
||||
async cancelSubscription(
|
||||
params: z.output<typeof CancelSubscriptionParamsSchema>,
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
@@ -165,7 +165,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
|
||||
* @param params
|
||||
*/
|
||||
async retrieveCheckoutSession(
|
||||
params: z.output<typeof RetrieveCheckoutSessionSchema>,
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
@@ -209,7 +209,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
|
||||
* @description Reports the usage of the billing
|
||||
* @param params
|
||||
*/
|
||||
async reportUsage(params: z.output<typeof ReportBillingUsageSchema>) {
|
||||
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
@@ -248,7 +248,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
|
||||
* @param params
|
||||
*/
|
||||
async queryUsage(
|
||||
params: z.output<typeof QueryBillingUsageSchema>,
|
||||
params: z.infer<typeof QueryBillingUsageSchema>,
|
||||
): Promise<{ value: number }> {
|
||||
const logger = await getLogger();
|
||||
|
||||
@@ -312,7 +312,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
|
||||
* @param params
|
||||
*/
|
||||
async updateSubscriptionItem(
|
||||
params: z.output<typeof UpdateSubscriptionParamsSchema>,
|
||||
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ function EmbeddedCheckoutPopup({
|
||||
<Dialog
|
||||
defaultOpen
|
||||
open={open}
|
||||
disablePointerDismissal
|
||||
onOpenChange={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
@@ -64,6 +63,9 @@ function EmbeddedCheckoutPopup({
|
||||
maxHeight: '98vh',
|
||||
}}
|
||||
className={className}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className={'hidden'}>Checkout</DialogTitle>
|
||||
<div>{children}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const StripeClientEnvSchema = z
|
||||
.object({
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const StripeServerEnvSchema = z
|
||||
.object({
|
||||
secretKey: z
|
||||
.string({
|
||||
error: `Please provide the variable STRIPE_SECRET_KEY`,
|
||||
required_error: `Please provide the variable STRIPE_SECRET_KEY`,
|
||||
})
|
||||
.min(1),
|
||||
webhooksSecret: z
|
||||
.string({
|
||||
error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
|
||||
required_error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Stripe } from 'stripe';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
|
||||
*/
|
||||
export async function createStripeBillingPortalSession(
|
||||
stripe: Stripe,
|
||||
params: z.output<typeof CreateBillingPortalSessionSchema>,
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
return stripe.billingPortal.sessions.create({
|
||||
customer: params.customerId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Stripe } from 'stripe';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CreateBillingCheckoutSchema } from '@kit/billing/schema';
|
||||
|
||||
@@ -17,7 +17,7 @@ const enableTrialWithoutCreditCard =
|
||||
*/
|
||||
export async function createStripeCheckout(
|
||||
stripe: Stripe,
|
||||
params: z.output<typeof CreateBillingCheckoutSchema>,
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
// in MakerKit, a subscription belongs to an organization,
|
||||
// rather than to a user
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'server-only';
|
||||
|
||||
import type { Stripe } from 'stripe';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingStrategyProviderService } from '@kit/billing';
|
||||
import type {
|
||||
@@ -35,7 +35,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
|
||||
* @param params
|
||||
*/
|
||||
async createCheckoutSession(
|
||||
params: z.output<typeof CreateBillingCheckoutSchema>,
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
const logger = await getLogger();
|
||||
@@ -67,7 +67,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
|
||||
* @param params
|
||||
*/
|
||||
async createBillingPortalSession(
|
||||
params: z.output<typeof CreateBillingPortalSessionSchema>,
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
const logger = await getLogger();
|
||||
@@ -96,7 +96,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
|
||||
* @param params
|
||||
*/
|
||||
async cancelSubscription(
|
||||
params: z.output<typeof CancelSubscriptionParamsSchema>,
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
const logger = await getLogger();
|
||||
@@ -139,7 +139,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
|
||||
* @param params
|
||||
*/
|
||||
async retrieveCheckoutSession(
|
||||
params: z.output<typeof RetrieveCheckoutSessionSchema>,
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
const logger = await getLogger();
|
||||
@@ -183,7 +183,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
|
||||
* @description Reports usage for a subscription with the Metrics API
|
||||
* @param params
|
||||
*/
|
||||
async reportUsage(params: z.output<typeof ReportBillingUsageSchema>) {
|
||||
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
|
||||
const stripe = await this.stripeProvider();
|
||||
const logger = await getLogger();
|
||||
|
||||
@@ -230,7 +230,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
|
||||
* @name queryUsage
|
||||
* @description Reports the total usage for a subscription with the Metrics API
|
||||
*/
|
||||
async queryUsage(params: z.output<typeof QueryBillingUsageSchema>) {
|
||||
async queryUsage(params: z.infer<typeof QueryBillingUsageSchema>) {
|
||||
const stripe = await this.stripeProvider();
|
||||
const logger = await getLogger();
|
||||
|
||||
@@ -287,7 +287,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
|
||||
* @param params
|
||||
*/
|
||||
async updateSubscriptionItem(
|
||||
params: z.output<typeof UpdateSubscriptionParamsSchema>,
|
||||
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
const logger = await getLogger();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { KeystaticStorage } from './keystatic-storage';
|
||||
import { keyStaticConfig } from './keystatic.config';
|
||||
@@ -51,7 +51,7 @@ function getKeystaticGithubConfiguration() {
|
||||
return z
|
||||
.object({
|
||||
token: z.string({
|
||||
error:
|
||||
description:
|
||||
'The GitHub token to use for authentication. Please provide the value through the "KEYSTATIC_GITHUB_TOKEN" environment variable.',
|
||||
}),
|
||||
repo: z.custom<`${string}/${string}`>(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CloudConfig, GitHubConfig, LocalConfig } from '@keystatic/core';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
type ZodOutputFor<T> = z.ZodType<T, z.ZodTypeDef, unknown>;
|
||||
|
||||
/**
|
||||
* @name STORAGE_KIND
|
||||
@@ -35,7 +37,7 @@ const PROJECT = process.env.KEYSTATIC_STORAGE_PROJECT;
|
||||
*/
|
||||
const local = z.object({
|
||||
kind: z.literal('local'),
|
||||
}) satisfies z.ZodType<LocalConfig['storage']>;
|
||||
}) satisfies ZodOutputFor<LocalConfig['storage']>;
|
||||
|
||||
/**
|
||||
* @name cloud
|
||||
@@ -45,12 +47,12 @@ const cloud = z.object({
|
||||
kind: z.literal('cloud'),
|
||||
project: z
|
||||
.string({
|
||||
error: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`,
|
||||
description: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`,
|
||||
})
|
||||
.min(1),
|
||||
branchPrefix: z.string().optional(),
|
||||
pathPrefix: z.string().optional(),
|
||||
}) satisfies z.ZodType<CloudConfig['storage']>;
|
||||
}) satisfies ZodOutputFor<CloudConfig['storage']>;
|
||||
|
||||
/**
|
||||
* @name github
|
||||
@@ -61,7 +63,7 @@ const github = z.object({
|
||||
repo: z.custom<`${string}/${string}`>(),
|
||||
branchPrefix: z.string().optional(),
|
||||
pathPrefix: z.string().optional(),
|
||||
}) satisfies z.ZodType<GitHubConfig['storage']>;
|
||||
}) satisfies ZodOutputFor<GitHubConfig['storage']>;
|
||||
|
||||
/**
|
||||
* @name KeystaticStorage
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DatabaseWebhookVerifierService } from './database-webhook-verifier.service';
|
||||
|
||||
const webhooksSecret = z
|
||||
.string({
|
||||
error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
|
||||
description: `The secret used to verify the webhook signature`,
|
||||
required_error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
|
||||
})
|
||||
.min(1)
|
||||
.parse(process.env.SUPABASE_DB_WEBHOOK_SECRET);
|
||||
|
||||
@@ -4,8 +4,7 @@ This package owns transactional email templates and renderers using React Email.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss
|
||||
it.
|
||||
1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss it.
|
||||
2. New email renderer must be exported from `src/index.ts`.
|
||||
3. Renderer contract: async function returning `{ html, subject }`.
|
||||
4. i18n namespace must match locale filename in `src/locales/<lang>/<namespace>.json`.
|
||||
@@ -20,5 +19,4 @@ This package owns transactional email templates and renderers using React Email.
|
||||
3. Export template renderer from `src/index.ts`.
|
||||
4. Add renderer to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`).
|
||||
|
||||
`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering
|
||||
will miss it.
|
||||
`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering will miss it.
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/i18n": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next-intl": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
|
||||
@@ -29,11 +29,11 @@ export async function renderAccountDeleteEmail(props: Props) {
|
||||
namespace,
|
||||
});
|
||||
|
||||
const previewText = t(`previewText`, {
|
||||
const previewText = t(`${namespace}:previewText`, {
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
const subject = t(`subject`, {
|
||||
const subject = t(`${namespace}:subject`, {
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
@@ -54,27 +54,27 @@ export async function renderAccountDeleteEmail(props: Props) {
|
||||
|
||||
<EmailContent>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`hello`)}
|
||||
{t(`${namespace}:hello`)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`paragraph1`, {
|
||||
{t(`${namespace}:paragraph1`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`paragraph2`)}
|
||||
{t(`${namespace}:paragraph2`)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`paragraph3`, {
|
||||
{t(`${namespace}:paragraph3`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`paragraph4`, {
|
||||
{t(`${namespace}:paragraph4`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -42,24 +42,24 @@ export async function renderInviteEmail(props: Props) {
|
||||
});
|
||||
|
||||
const previewText = `Join ${props.invitedUserEmail} on ${props.productName}`;
|
||||
const subject = t(`subject`);
|
||||
const subject = t(`${namespace}:subject`);
|
||||
|
||||
const heading = t(`heading`, {
|
||||
const heading = t(`${namespace}:heading`, {
|
||||
teamName: props.teamName,
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
const hello = t(`hello`, {
|
||||
const hello = t(`${namespace}:hello`, {
|
||||
invitedUserEmail: props.invitedUserEmail,
|
||||
});
|
||||
|
||||
const mainText = t(`mainText`, {
|
||||
const mainText = t(`${namespace}:mainText`, {
|
||||
inviter: props.inviter,
|
||||
teamName: props.teamName,
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
const joinTeam = t(`joinTeam`, {
|
||||
const joinTeam = t(`${namespace}:joinTeam`, {
|
||||
teamName: props.teamName,
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ export async function renderInviteEmail(props: Props) {
|
||||
</Section>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`copyPasteLink`)}{' '}
|
||||
{t(`${namespace}:copyPasteLink`)}{' '}
|
||||
<Link href={props.link} className="text-blue-600 no-underline">
|
||||
{props.link}
|
||||
</Link>
|
||||
@@ -117,7 +117,7 @@ export async function renderInviteEmail(props: Props) {
|
||||
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
|
||||
|
||||
<Text className="text-[12px] leading-[24px] text-[#666666]">
|
||||
{t(`invitationIntendedFor`, {
|
||||
{t(`${namespace}:invitationIntendedFor`, {
|
||||
invitedUserEmail: props.invitedUserEmail,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -32,22 +32,22 @@ export async function renderOtpEmail(props: Props) {
|
||||
namespace,
|
||||
});
|
||||
|
||||
const subject = t(`subject`, {
|
||||
const subject = t(`${namespace}:subject`, {
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
const previewText = subject;
|
||||
|
||||
const heading = t(`heading`, {
|
||||
const heading = t(`${namespace}:heading`, {
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
const otpText = t(`otpText`, {
|
||||
const otpText = t(`${namespace}:otpText`, {
|
||||
otp: props.otp,
|
||||
});
|
||||
|
||||
const mainText = t(`mainText`);
|
||||
const footerText = t(`footerText`);
|
||||
const mainText = t(`${namespace}:mainText`);
|
||||
const footerText = t(`${namespace}:footerText`);
|
||||
|
||||
const html = await render(
|
||||
<Html>
|
||||
|
||||
@@ -1,47 +1,32 @@
|
||||
import type { AbstractIntlMessages } from 'next-intl';
|
||||
import { createTranslator } from 'next-intl';
|
||||
import { createI18nSettings } from '@kit/i18n';
|
||||
import { initializeServerI18n } from '@kit/i18n/server';
|
||||
|
||||
export async function initializeEmailI18n(params: {
|
||||
export function initializeEmailI18n(params: {
|
||||
language: string | undefined;
|
||||
namespace: string;
|
||||
}) {
|
||||
const language = params.language ?? 'en';
|
||||
const language =
|
||||
params.language ?? process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en';
|
||||
|
||||
try {
|
||||
// Load the translation messages for the specified namespace
|
||||
const messages = (await import(
|
||||
`../locales/${language}/${params.namespace}.json`
|
||||
)) as AbstractIntlMessages;
|
||||
|
||||
// Create a translator function with the messages
|
||||
const translator = createTranslator({
|
||||
locale: language,
|
||||
messages,
|
||||
});
|
||||
|
||||
// Type-cast to make it compatible with the i18next API
|
||||
const t = translator as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, unknown>,
|
||||
) => string;
|
||||
|
||||
// Return an object compatible with the i18next API
|
||||
return {
|
||||
t,
|
||||
return initializeServerI18n(
|
||||
createI18nSettings({
|
||||
language,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Error loading i18n file: locales/${language}/${params.namespace}.json`,
|
||||
error,
|
||||
);
|
||||
languages: [language],
|
||||
namespaces: params.namespace,
|
||||
}),
|
||||
async (language, namespace) => {
|
||||
try {
|
||||
const data = await import(`../locales/${language}/${namespace}.json`);
|
||||
|
||||
// Return a fallback translator that returns the key as-is
|
||||
const t = (key: string) => key;
|
||||
return data as Record<string, string>;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Error loading i18n file: locales/${language}/${namespace}.json`,
|
||||
error,
|
||||
);
|
||||
|
||||
return {
|
||||
t,
|
||||
language,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"subject": "We have deleted your {productName} account",
|
||||
"previewText": "We have deleted your {productName} account",
|
||||
"hello": "Hello {displayName},",
|
||||
"paragraph1": "This is to confirm that we have processed your request to delete your account with {productName}.",
|
||||
"subject": "We have deleted your {{productName}} account",
|
||||
"previewText": "We have deleted your {{productName}} account",
|
||||
"hello": "Hello {{displayName}},",
|
||||
"paragraph1": "This is to confirm that we have processed your request to delete your account with {{productName}}.",
|
||||
"paragraph2": "We're sorry to see you go. Please note that this action is irreversible, and we'll make sure to delete all of your data from our systems.",
|
||||
"paragraph3": "We thank you again for using {productName}.",
|
||||
"paragraph4": "The {productName} Team"
|
||||
"paragraph3": "We thank you again for using {{productName}}.",
|
||||
"paragraph4": "The {{productName}} Team"
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"subject": "You have been invited to join a team",
|
||||
"heading": "Join {teamName} on {productName}",
|
||||
"hello": "Hello {invitedUserEmail},",
|
||||
"mainText": "<strong>{inviter}</strong> has invited you to the <strong>{teamName}</strong> team on <strong>{productName}</strong>.",
|
||||
"joinTeam": "Join {teamName}",
|
||||
"heading": "Join {{teamName}} on {{productName}}",
|
||||
"hello": "Hello {{invitedUserEmail}},",
|
||||
"mainText": "<strong>{{inviter}}</strong> has invited you to the <strong>{{teamName}}</strong> team on <strong>{{productName}}</strong>.",
|
||||
"joinTeam": "Join {{teamName}}",
|
||||
"copyPasteLink": "or copy and paste this URL into your browser:",
|
||||
"invitationIntendedFor": "This invitation is intended for {invitedUserEmail}."
|
||||
"invitationIntendedFor": "This invitation is intended for {{invitedUserEmail}}."
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"subject": "One-time password for {productName}",
|
||||
"heading": "One-time password for {productName}",
|
||||
"otpText": "Your one-time password is: {otp}",
|
||||
"subject": "One-time password for {{productName}}",
|
||||
"heading": "One-time password for {{productName}}",
|
||||
"otpText": "Your one-time password is: {{otp}}",
|
||||
"footerText": "Please enter the one-time password in the app to continue.",
|
||||
"mainText": "You're receiving this email because you need to verify your identity using a one-time password."
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"@kit/billing-gateway": "workspace:*",
|
||||
"@kit/email-templates": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/i18n": "workspace:*",
|
||||
"@kit/mailers": "workspace:*",
|
||||
"@kit/monitoring": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
@@ -34,18 +33,18 @@
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-intl": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"next-themes": "0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"react-i18next": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ChevronsUpDown, Plus, User } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons';
|
||||
import { CheckCircle, Plus } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -39,7 +40,7 @@ interface AccountSelectorProps {
|
||||
selectedAccount?: string;
|
||||
collapsed?: boolean;
|
||||
className?: string;
|
||||
showPersonalAccount?: boolean;
|
||||
collisionPadding?: number;
|
||||
|
||||
onAccountChange: (value: string | undefined) => void;
|
||||
}
|
||||
@@ -56,14 +57,16 @@ export function AccountSelector({
|
||||
enableTeamCreation: true,
|
||||
},
|
||||
collapsed = false,
|
||||
showPersonalAccount = true,
|
||||
collisionPadding = 20,
|
||||
}: React.PropsWithChildren<AccountSelectorProps>) {
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [isCreatingAccount, setIsCreatingAccount] = useState<boolean>(false);
|
||||
const t = useTranslations('teams');
|
||||
const { t } = useTranslation('teams');
|
||||
const personalData = usePersonalAccountData(userId);
|
||||
|
||||
const value = selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
|
||||
const value = useMemo(() => {
|
||||
return selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
|
||||
}, [selectedAccount]);
|
||||
|
||||
const selected = accounts.find((account) => account.value === value);
|
||||
const pictureUrl = personalData.data?.picture_url;
|
||||
@@ -71,136 +74,128 @@ export function AccountSelector({
|
||||
return (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
data-test={'account-selector-trigger'}
|
||||
size={collapsed ? 'icon' : 'default'}
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'dark:shadow-primary/10 group mr-1 w-full min-w-0 px-2 lg:w-auto lg:max-w-[185px]',
|
||||
{
|
||||
'justify-start': !collapsed,
|
||||
'm-auto justify-center px-2 lg:w-full': collapsed,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<If
|
||||
condition={selected}
|
||||
fallback={
|
||||
<span
|
||||
className={cn('flex max-w-full items-center', {
|
||||
'justify-center gap-x-0': collapsed,
|
||||
'gap-x-2': !collapsed,
|
||||
})}
|
||||
>
|
||||
<PersonalAccountAvatar pictureUrl={pictureUrl} />
|
||||
|
||||
<span
|
||||
className={cn('truncate', {
|
||||
hidden: collapsed,
|
||||
})}
|
||||
>
|
||||
<Trans i18nKey={'teams.personalAccount'} />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(account) => (
|
||||
<span
|
||||
className={cn('flex max-w-full items-center', {
|
||||
'justify-center gap-x-0': collapsed,
|
||||
'gap-x-2': !collapsed,
|
||||
})}
|
||||
>
|
||||
<Avatar className={'h-6 w-6 rounded-xs'}>
|
||||
<AvatarImage src={account.image ?? undefined} />
|
||||
|
||||
<AvatarFallback
|
||||
className={'group-hover:bg-background rounded-xs'}
|
||||
>
|
||||
{account.label ? account.label[0] : ''}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span
|
||||
className={cn('truncate', {
|
||||
hidden: collapsed,
|
||||
})}
|
||||
>
|
||||
{account.label}
|
||||
</span>
|
||||
</span>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
data-test={'account-selector-trigger'}
|
||||
size={collapsed ? 'icon' : 'default'}
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'dark:shadow-primary/10 group mr-1 w-full min-w-0 px-2 lg:w-auto lg:max-w-fit',
|
||||
{
|
||||
'justify-start': !collapsed,
|
||||
'm-auto justify-center px-2 lg:w-full': collapsed,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
</If>
|
||||
>
|
||||
<If
|
||||
condition={selected}
|
||||
fallback={
|
||||
<span
|
||||
className={cn('flex max-w-full items-center', {
|
||||
'justify-center gap-x-0': collapsed,
|
||||
'gap-x-2': !collapsed,
|
||||
})}
|
||||
>
|
||||
<PersonalAccountAvatar pictureUrl={pictureUrl} />
|
||||
|
||||
<ChevronsUpDown
|
||||
className={cn('ml-1 h-4 w-4 shrink-0 opacity-50', {
|
||||
hidden: collapsed,
|
||||
})}
|
||||
/>
|
||||
<span
|
||||
className={cn('truncate', {
|
||||
hidden: collapsed,
|
||||
})}
|
||||
>
|
||||
<Trans i18nKey={'teams:personalAccount'} />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(account) => (
|
||||
<span
|
||||
className={cn('flex max-w-full items-center', {
|
||||
'justify-center gap-x-0': collapsed,
|
||||
'gap-x-2': !collapsed,
|
||||
})}
|
||||
>
|
||||
<Avatar className={'h-6 w-6 rounded-xs'}>
|
||||
<AvatarImage src={account.image ?? undefined} />
|
||||
|
||||
<AvatarFallback
|
||||
className={'group-hover:bg-background rounded-xs'}
|
||||
>
|
||||
{account.label ? account.label[0] : ''}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span
|
||||
className={cn('truncate', {
|
||||
hidden: collapsed,
|
||||
})}
|
||||
>
|
||||
{account.label}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</If>
|
||||
|
||||
<CaretSortIcon
|
||||
className={cn('ml-1 h-4 w-4 shrink-0 opacity-50', {
|
||||
hidden: collapsed,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
data-test={'account-selector-content'}
|
||||
className="w-full gap-0 p-0"
|
||||
className="w-full p-0"
|
||||
collisionPadding={collisionPadding}
|
||||
>
|
||||
<Command value={value}>
|
||||
<Command>
|
||||
<CommandInput placeholder={t('searchAccount')} className="h-9" />
|
||||
|
||||
<CommandList>
|
||||
{showPersonalAccount && (
|
||||
<>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
tabIndex={0}
|
||||
value={PERSONAL_ACCOUNT_SLUG}
|
||||
onSelect={() => onAccountChange(undefined)}
|
||||
className={cn('', {
|
||||
'bg-muted': value === PERSONAL_ACCOUNT_SLUG,
|
||||
'hover:bg-muted/50 data-selected:bg-transparent':
|
||||
value !== PERSONAL_ACCOUNT_SLUG,
|
||||
})}
|
||||
>
|
||||
<PersonalAccountAvatar />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
className="shadow-none"
|
||||
onSelect={() => onAccountChange(undefined)}
|
||||
value={PERSONAL_ACCOUNT_SLUG}
|
||||
>
|
||||
<PersonalAccountAvatar />
|
||||
|
||||
<span className={'ml-2'}>
|
||||
<Trans i18nKey={'teams.personalAccount'} />
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<span className={'ml-2'}>
|
||||
<Trans i18nKey={'teams:personalAccount'} />
|
||||
</span>
|
||||
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
<Icon selected={value === PERSONAL_ACCOUNT_SLUG} />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<If condition={accounts.length > 0}>
|
||||
<CommandGroup
|
||||
heading={
|
||||
<Trans
|
||||
i18nKey={'teams.yourTeams'}
|
||||
i18nKey={'teams:yourTeams'}
|
||||
values={{ teamsCount: accounts.length }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(accounts ?? []).map((account) => (
|
||||
<CommandItem
|
||||
className={cn('', {
|
||||
'bg-muted': value === account.value,
|
||||
'hover:bg-muted/50 data-selected:bg-transparent':
|
||||
value !== account.value,
|
||||
})}
|
||||
tabIndex={0}
|
||||
data-test={'account-selector-team'}
|
||||
data-name={account.label}
|
||||
data-slug={account.value}
|
||||
className={cn(
|
||||
'group my-1 flex justify-between shadow-none transition-colors',
|
||||
{
|
||||
['bg-muted']: value === account.value,
|
||||
},
|
||||
)}
|
||||
key={account.value}
|
||||
value={account.value ?? undefined}
|
||||
value={account.value ?? ''}
|
||||
onSelect={(currentValue) => {
|
||||
setOpen(false);
|
||||
|
||||
@@ -209,12 +204,13 @@ export function AccountSelector({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={'flex w-full items-center'}>
|
||||
<div className={'flex items-center'}>
|
||||
<Avatar className={'mr-2 h-6 w-6 rounded-xs'}>
|
||||
<AvatarImage src={account.image ?? undefined} />
|
||||
|
||||
<AvatarFallback
|
||||
className={cn('rounded-xs', {
|
||||
['bg-background']: value === account.value,
|
||||
['group-hover:bg-background']:
|
||||
value !== account.value,
|
||||
})}
|
||||
@@ -223,10 +219,12 @@ export function AccountSelector({
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span className={'max-w-[165px] truncate'}>
|
||||
<span className={'mr-2 max-w-[165px] truncate'}>
|
||||
{account.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Icon selected={(account.value ?? '') === value} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
@@ -234,27 +232,26 @@ export function AccountSelector({
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
<Separator />
|
||||
|
||||
<If condition={features.enableTeamCreation}>
|
||||
<div className="px-1">
|
||||
<Separator />
|
||||
<div className={'p-1'}>
|
||||
<Button
|
||||
data-test={'create-team-account-trigger'}
|
||||
variant="ghost"
|
||||
size={'sm'}
|
||||
className="w-full justify-start text-sm font-normal"
|
||||
onClick={() => {
|
||||
setIsCreatingAccount(true);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-3 h-4 w-4" />
|
||||
|
||||
<div className="py-1">
|
||||
<Button
|
||||
data-test={'create-team-account-trigger'}
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm font-normal"
|
||||
onClick={() => {
|
||||
setIsCreatingAccount(true);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-3 h-4 w-4" />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'teams.createTeam'} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<span>
|
||||
<Trans i18nKey={'teams:createTeam'} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</If>
|
||||
</PopoverContent>
|
||||
@@ -278,10 +275,18 @@ function UserAvatar(props: { pictureUrl?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ selected }: { selected: boolean }) {
|
||||
return (
|
||||
<CheckCircle
|
||||
className={cn('ml-auto h-4 w-4', selected ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalAccountAvatar({ pictureUrl }: { pictureUrl?: string | null }) {
|
||||
return pictureUrl ? (
|
||||
<UserAvatar pictureUrl={pictureUrl} />
|
||||
) : (
|
||||
<User className="h-5 w-5" />
|
||||
<PersonIcon className="h-5 w-5" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
LogOut,
|
||||
MessageCircleQuestion,
|
||||
Shield,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { JWTUserData } from '@kit/supabase/types';
|
||||
@@ -50,7 +49,6 @@ export function PersonalAccountDropdown({
|
||||
|
||||
paths: {
|
||||
home: string;
|
||||
profileSettings: string;
|
||||
};
|
||||
|
||||
features: {
|
||||
@@ -89,10 +87,11 @@ export function PersonalAccountDropdown({
|
||||
aria-label="Open your profile menu"
|
||||
data-test={'account-dropdown-trigger'}
|
||||
className={cn(
|
||||
'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[collapsible=icon]:px-0',
|
||||
'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[minimized=true]/sidebar:px-0',
|
||||
className ?? '',
|
||||
{
|
||||
['active:bg-secondary/50 group-data-[collapsible=none]:hover:bg-secondary items-center gap-4 rounded-md border-dashed p-2 transition-colors group-data-[collapsible=none]:border']:
|
||||
['active:bg-secondary/50 items-center gap-4 rounded-md' +
|
||||
' hover:bg-secondary border border-dashed p-2 transition-colors']:
|
||||
showProfileName,
|
||||
},
|
||||
)}
|
||||
@@ -109,7 +108,7 @@ export function PersonalAccountDropdown({
|
||||
<If condition={showProfileName}>
|
||||
<div
|
||||
className={
|
||||
'fade-in flex w-full flex-col truncate text-left group-data-[collapsible=icon]:hidden'
|
||||
'fade-in flex w-full flex-col truncate text-left group-data-[minimized=true]/sidebar:hidden'
|
||||
}
|
||||
>
|
||||
<span
|
||||
@@ -129,7 +128,7 @@ export function PersonalAccountDropdown({
|
||||
|
||||
<ChevronsUpDown
|
||||
className={
|
||||
'text-muted-foreground mr-1 h-8 group-data-[collapsible=icon]:hidden'
|
||||
'text-muted-foreground mr-1 h-8 group-data-[minimized=true]/sidebar:hidden'
|
||||
}
|
||||
/>
|
||||
</If>
|
||||
@@ -141,7 +140,7 @@ export function PersonalAccountDropdown({
|
||||
className={'flex flex-col justify-start truncate text-left text-xs'}
|
||||
>
|
||||
<div className={'text-muted-foreground'}>
|
||||
<Trans i18nKey={'common.signedInAs'} />
|
||||
<Trans i18nKey={'common:signedInAs'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -152,69 +151,48 @@ export function PersonalAccountDropdown({
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
className={'s-full flex cursor-pointer items-center space-x-2'}
|
||||
href={paths.home}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Home className={'h-5'} />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'s-full flex cursor-pointer items-center space-x-2'}
|
||||
href={paths.home}
|
||||
>
|
||||
<Home className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common.routes.home'} />
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
className={'s-full flex cursor-pointer items-center space-x-2'}
|
||||
href={paths.profileSettings}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<User className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common.routes.profile'} />
|
||||
</span>
|
||||
<span>
|
||||
<Trans i18nKey={'common:routes.home'} />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
className={'s-full flex cursor-pointer items-center space-x-2'}
|
||||
href={'/docs'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MessageCircleQuestion className={'h-5'} />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'s-full flex cursor-pointer items-center space-x-2'}
|
||||
href={'/docs'}
|
||||
>
|
||||
<MessageCircleQuestion className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'common.documentation'} />
|
||||
</span>
|
||||
<span>
|
||||
<Trans i18nKey={'common:documentation'} />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<If condition={isSuperAdmin}>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
className={
|
||||
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
|
||||
}
|
||||
href={'/admin'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Shield className={'h-5'} />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={
|
||||
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
|
||||
}
|
||||
href={'/admin'}
|
||||
>
|
||||
<Shield className={'h-5'} />
|
||||
|
||||
<span>Super Admin</span>
|
||||
<span>Super Admin</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
@@ -236,7 +214,7 @@ export function PersonalAccountDropdown({
|
||||
<LogOut className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'auth.signOut'} />
|
||||
<Trans i18nKey={'auth:signOut'} />
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { ErrorBoundary } from '@kit/monitoring/components';
|
||||
@@ -30,11 +31,11 @@ export function AccountDangerZone() {
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<span className={'text-sm font-medium'}>
|
||||
<Trans i18nKey={'account.deleteAccount'} />
|
||||
<Trans i18nKey={'account:deleteAccount'} />
|
||||
</span>
|
||||
|
||||
<p className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'account.deleteAccountDescription'} />
|
||||
<Trans i18nKey={'account:deleteAccountDescription'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -54,18 +55,16 @@ function DeleteAccountModal() {
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button data-test={'delete-account-button'} variant={'destructive'}>
|
||||
<Trans i18nKey={'account.deleteAccount'} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button data-test={'delete-account-button'} variant={'destructive'}>
|
||||
<Trans i18nKey={'account:deleteAccount'} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account.deleteAccount'} />
|
||||
<Trans i18nKey={'account:deleteAccount'} />
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
|
||||
@@ -78,8 +77,6 @@ function DeleteAccountModal() {
|
||||
}
|
||||
|
||||
function DeleteAccountForm(props: { email: string }) {
|
||||
const { execute, isPending } = useAction(deletePersonalAccountAction);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(DeletePersonalAccountSchema),
|
||||
defaultValues: {
|
||||
@@ -97,7 +94,7 @@ function DeleteAccountForm(props: { email: string }) {
|
||||
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
|
||||
CancelButton={
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common.cancel'} />
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
}
|
||||
/>
|
||||
@@ -108,12 +105,11 @@ function DeleteAccountForm(props: { email: string }) {
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'delete-account-form'}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
execute({ otp });
|
||||
}}
|
||||
action={deletePersonalAccountAction}
|
||||
className={'flex flex-col space-y-4'}
|
||||
>
|
||||
<input type="hidden" name="otp" value={otp} />
|
||||
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<div
|
||||
className={
|
||||
@@ -122,11 +118,11 @@ function DeleteAccountForm(props: { email: string }) {
|
||||
>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div>
|
||||
<Trans i18nKey={'account.deleteAccountDescription'} />
|
||||
<Trans i18nKey={'account:deleteAccountDescription'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'common.modalConfirmationQuestion'} />
|
||||
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,28 +130,36 @@ function DeleteAccountForm(props: { email: string }) {
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common.cancel'} />
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<Button
|
||||
data-test={'confirm-delete-account-button'}
|
||||
type={'submit'}
|
||||
disabled={isPending || !form.formState.isValid}
|
||||
name={'action'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
{isPending ? (
|
||||
<Trans i18nKey={'account.deletingAccount'} />
|
||||
) : (
|
||||
<Trans i18nKey={'account.deleteAccount'} />
|
||||
)}
|
||||
</Button>
|
||||
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountSubmitButton(props: { disabled: boolean }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-test={'confirm-delete-account-button'}
|
||||
type={'submit'}
|
||||
disabled={pending || props.disabled}
|
||||
name={'action'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
{pending ? (
|
||||
<Trans i18nKey={'account:deletingAccount'} />
|
||||
) : (
|
||||
<Trans i18nKey={'account:deleteAccount'} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountErrorContainer() {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
@@ -163,7 +167,7 @@ function DeleteAccountErrorContainer() {
|
||||
|
||||
<div>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common.cancel'} />
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,14 +177,14 @@ function DeleteAccountErrorContainer() {
|
||||
function DeleteAccountErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.deleteAccountErrorHeading'} />
|
||||
<Trans i18nKey={'account:deleteAccountErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common.genericError'} />
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { routing } from '@kit/i18n';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -54,11 +55,11 @@ export function PersonalAccountSettingsContainer(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account.accountImage'} />
|
||||
<Trans i18nKey={'account:accountImage'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account.accountImageDescription'} />
|
||||
<Trans i18nKey={'account:accountImageDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -75,11 +76,11 @@ export function PersonalAccountSettingsContainer(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account.name'} />
|
||||
<Trans i18nKey={'account:name'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account.nameDescription'} />
|
||||
<Trans i18nKey={'account:nameDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -92,16 +93,16 @@ export function PersonalAccountSettingsContainer(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account.language'} />
|
||||
<Trans i18nKey={'account:language'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account.languageDescription'} />
|
||||
<Trans i18nKey={'account:languageDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<LanguageSelector locales={routing.locales} />
|
||||
<LanguageSelector />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</If>
|
||||
@@ -109,11 +110,11 @@ export function PersonalAccountSettingsContainer(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account.updateEmailCardTitle'} />
|
||||
<Trans i18nKey={'account:updateEmailCardTitle'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account.updateEmailCardDescription'} />
|
||||
<Trans i18nKey={'account:updateEmailCardDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -126,11 +127,11 @@ export function PersonalAccountSettingsContainer(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account.updatePasswordCardTitle'} />
|
||||
<Trans i18nKey={'account:updatePasswordCardTitle'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account.updatePasswordCardDescription'} />
|
||||
<Trans i18nKey={'account:updatePasswordCardDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -143,11 +144,11 @@ export function PersonalAccountSettingsContainer(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account.multiFactorAuth'} />
|
||||
<Trans i18nKey={'account:multiFactorAuth'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account.multiFactorAuthDescription'} />
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -159,11 +160,11 @@ export function PersonalAccountSettingsContainer(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account.linkedAccounts'} />
|
||||
<Trans i18nKey={'account:linkedAccounts'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account.linkedAccountsDescription'} />
|
||||
<Trans i18nKey={'account:linkedAccountsDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -182,11 +183,11 @@ export function PersonalAccountSettingsContainer(
|
||||
<Card className={'border-destructive'}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey={'account.dangerZone'} />
|
||||
<Trans i18nKey={'account:dangerZone'} />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey={'account.dangerZoneDescription'} />
|
||||
<Trans i18nKey={'account:dangerZoneDescription'} />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -200,7 +201,10 @@ export function PersonalAccountSettingsContainer(
|
||||
}
|
||||
|
||||
function useSupportMultiLanguage() {
|
||||
const { locales } = routing;
|
||||
const { i18n } = useTranslation();
|
||||
const langs = (i18n?.options?.supportedLngs as string[]) ?? [];
|
||||
|
||||
return locales.length > 1;
|
||||
const supportedLangs = langs.filter((lang) => lang !== 'cimode');
|
||||
|
||||
return supportedLangs.length > 1;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Check, Mail } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
@@ -61,7 +62,7 @@ export function UpdateEmailForm({
|
||||
callbackPath: string;
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const t = useTranslations('account');
|
||||
const { t } = useTranslation('account');
|
||||
const updateUserMutation = useUpdateUser();
|
||||
const isSettingEmail = !email;
|
||||
|
||||
@@ -107,14 +108,14 @@ export function UpdateEmailForm({
|
||||
>
|
||||
<If condition={updateUserMutation.data}>
|
||||
<Alert variant={'success'}>
|
||||
<Check className={'h-4'} />
|
||||
<CheckIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans
|
||||
i18nKey={
|
||||
isSettingEmail
|
||||
? 'account.setEmailSuccess'
|
||||
: 'account.updateEmailSuccess'
|
||||
? 'account:setEmailSuccess'
|
||||
: 'account:updateEmailSuccess'
|
||||
}
|
||||
/>
|
||||
</AlertTitle>
|
||||
@@ -123,8 +124,8 @@ export function UpdateEmailForm({
|
||||
<Trans
|
||||
i18nKey={
|
||||
isSettingEmail
|
||||
? 'account.setEmailSuccessMessage'
|
||||
: 'account.updateEmailSuccessMessage'
|
||||
? 'account:setEmailSuccessMessage'
|
||||
: 'account:updateEmailSuccessMessage'
|
||||
}
|
||||
/>
|
||||
</AlertDescription>
|
||||
@@ -147,7 +148,9 @@ export function UpdateEmailForm({
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={t(
|
||||
isSettingEmail ? 'emailAddress' : 'newEmail',
|
||||
isSettingEmail
|
||||
? 'account:emailAddress'
|
||||
: 'account:newEmail',
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
@@ -159,7 +162,7 @@ export function UpdateEmailForm({
|
||||
)}
|
||||
name={'email'}
|
||||
/>
|
||||
Perform
|
||||
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
@@ -174,7 +177,7 @@ export function UpdateEmailForm({
|
||||
data-test={'account-email-form-repeat-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={t('repeatEmail')}
|
||||
placeholder={t('account:repeatEmail')}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
@@ -187,12 +190,12 @@ export function UpdateEmailForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="submit" disabled={updateUserMutation.isPending}>
|
||||
<Button disabled={updateUserMutation.isPending}>
|
||||
<Trans
|
||||
i18nKey={
|
||||
isSettingEmail
|
||||
? 'account.setEmailAddress'
|
||||
: 'account.updateEmailSubmitLabel'
|
||||
? 'account:setEmailAddress'
|
||||
: 'account:updateEmailSubmitLabel'
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
@@ -112,9 +112,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
|
||||
const promise = unlinkMutation.mutateAsync(identity);
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: <Trans i18nKey={'account.unlinkingAccount'} />,
|
||||
success: <Trans i18nKey={'account.accountUnlinked'} />,
|
||||
error: <Trans i18nKey={'account.unlinkAccountError'} />,
|
||||
loading: <Trans i18nKey={'account:unlinkingAccount'} />,
|
||||
success: <Trans i18nKey={'account:accountUnlinked'} />,
|
||||
error: <Trans i18nKey={'account:unlinkAccountError'} />,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -129,9 +129,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
|
||||
});
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: <Trans i18nKey={'account.linkingAccount'} />,
|
||||
success: <Trans i18nKey={'account.accountLinked'} />,
|
||||
error: <Trans i18nKey={'account.linkAccountError'} />,
|
||||
loading: <Trans i18nKey={'account:linkingAccount'} />,
|
||||
success: <Trans i18nKey={'account:accountLinked'} />,
|
||||
error: <Trans i18nKey={'account:linkAccountError'} />,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -149,11 +149,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<h3 className="text-foreground text-sm font-medium">
|
||||
<Trans i18nKey={'account.linkedMethods'} />
|
||||
<Trans i18nKey={'account:linkedMethods'} />
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans i18nKey={'account.alreadyLinkedMethodsDescription'} />
|
||||
<Trans i18nKey={'account:alreadyLinkedMethodsDescription'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -185,30 +185,28 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
|
||||
<ItemActions>
|
||||
<If condition={hasMultipleIdentities}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
>
|
||||
<If condition={unlinkMutation.isPending}>
|
||||
<Spinner className="mr-2 h-3 w-3" />
|
||||
</If>
|
||||
<Trans i18nKey={'account.unlinkAccount'} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
>
|
||||
<If condition={unlinkMutation.isPending}>
|
||||
<Spinner className="mr-2 h-3 w-3" />
|
||||
</If>
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account.confirmUnlinkAccount'} />
|
||||
<Trans i18nKey={'account:confirmUnlinkAccount'} />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans
|
||||
i18nKey={'account.unlinkAccountConfirmation'}
|
||||
i18nKey={'account:unlinkAccountConfirmation'}
|
||||
values={{ provider: identity.provider }}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
@@ -216,14 +214,14 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common.cancel'} />
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
onClick={() => handleUnlinkAccount(identity)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<Trans i18nKey={'account.unlinkAccount'} />
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -245,11 +243,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<h3 className="text-foreground text-sm font-medium">
|
||||
<Trans i18nKey={'account.availableMethods'} />
|
||||
<Trans i18nKey={'account:availableMethods'} />
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans i18nKey={'account.availableMethodsDescription'} />
|
||||
<Trans i18nKey={'account:availableMethodsDescription'} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -283,7 +281,7 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
|
||||
|
||||
<ItemDescription>
|
||||
<Trans
|
||||
i18nKey={'account.linkAccountDescription'}
|
||||
i18nKey={'account:linkAccountDescription'}
|
||||
values={{ provider }}
|
||||
/>
|
||||
</ItemDescription>
|
||||
@@ -301,7 +299,7 @@ function NoAccountsAvailable() {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Trans i18nKey={'account.noAccountsAvailable'} />
|
||||
<Trans i18nKey={'account:noAccountsAvailable'} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -312,41 +310,38 @@ function UpdateEmailDialog(props: { redirectTo: string }) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Item variant="outline" role="button" className="hover:bg-muted/50">
|
||||
<ItemMedia>
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
|
||||
<OauthProviderLogoImage providerId={'email'} />
|
||||
<DialogTrigger asChild>
|
||||
<Item variant="outline" role="button" className="hover:bg-muted/50">
|
||||
<ItemMedia>
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
|
||||
<OauthProviderLogoImage providerId={'email'} />
|
||||
</div>
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<div className="flex flex-col">
|
||||
<ItemTitle className="text-sm font-medium">
|
||||
<Trans i18nKey={'account:setEmailAddress'} />
|
||||
</ItemTitle>
|
||||
|
||||
<ItemDescription>
|
||||
<Trans i18nKey={'account:setEmailDescription'} />
|
||||
</ItemDescription>
|
||||
</div>
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<div className="flex flex-col">
|
||||
<ItemTitle className="text-sm font-medium">
|
||||
<Trans i18nKey={'account.setEmailAddress'} />
|
||||
</ItemTitle>
|
||||
|
||||
<ItemDescription>
|
||||
<Trans i18nKey={'account.setEmailDescription'} />
|
||||
</ItemDescription>
|
||||
</div>
|
||||
</ItemHeader>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
}
|
||||
/>
|
||||
</ItemHeader>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'account.setEmailAddress'} />
|
||||
<Trans i18nKey={'account:setEmailAddress'} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={'account.setEmailDescription'} />
|
||||
<Trans i18nKey={'account:setEmailDescription'} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -378,38 +373,34 @@ function UpdatePasswordDialog(props: {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger
|
||||
nativeButton={false}
|
||||
data-test="open-password-dialog-trigger"
|
||||
render={
|
||||
<Item variant="outline" role="button" className="hover:bg-muted/50">
|
||||
<ItemMedia>
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
|
||||
<OauthProviderLogoImage providerId={'password'} />
|
||||
<DialogTrigger asChild data-test="open-password-dialog-trigger">
|
||||
<Item variant="outline" role="button" className="hover:bg-muted/50">
|
||||
<ItemMedia>
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
|
||||
<OauthProviderLogoImage providerId={'password'} />
|
||||
</div>
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<div className="flex flex-col">
|
||||
<ItemTitle className="text-sm font-medium">
|
||||
<Trans i18nKey={'account:linkEmailPassword'} />
|
||||
</ItemTitle>
|
||||
|
||||
<ItemDescription>
|
||||
<Trans i18nKey={'account:updatePasswordDescription'} />
|
||||
</ItemDescription>
|
||||
</div>
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent>
|
||||
<ItemHeader>
|
||||
<div className="flex flex-col">
|
||||
<ItemTitle className="text-sm font-medium">
|
||||
<Trans i18nKey={'account.linkEmailPassword'} />
|
||||
</ItemTitle>
|
||||
|
||||
<ItemDescription>
|
||||
<Trans i18nKey={'account.updatePasswordDescription'} />
|
||||
</ItemDescription>
|
||||
</div>
|
||||
</ItemHeader>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
}
|
||||
/>
|
||||
</ItemHeader>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'account.linkEmailPassword'} />
|
||||
<Trans i18nKey={'account:linkEmailPassword'} />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useCallback, useState } from 'react';
|
||||
|
||||
import type { Factor } from '@supabase/supabase-js';
|
||||
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ShieldCheck, TriangleAlert, X } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ShieldCheck, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
@@ -77,7 +78,7 @@ function FactorsTableContainer(props: { userId: string }) {
|
||||
<Spinner />
|
||||
|
||||
<div>
|
||||
<Trans i18nKey={'account.loadingFactors'} />
|
||||
<Trans i18nKey={'account:loadingFactors'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -87,14 +88,14 @@ function FactorsTableContainer(props: { userId: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.factorsListError'} />
|
||||
<Trans i18nKey={'account:factorsListError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account.factorsListErrorDescription'} />
|
||||
<Trans i18nKey={'account:factorsListErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -113,11 +114,11 @@ function FactorsTableContainer(props: { userId: string }) {
|
||||
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<Trans i18nKey={'account.multiFactorAuthHeading'} />
|
||||
<Trans i18nKey={'account:multiFactorAuthHeading'} />
|
||||
</ItemTitle>
|
||||
|
||||
<ItemDescription>
|
||||
<Trans i18nKey={'account.multiFactorAuthDescription'} />
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
@@ -135,7 +136,7 @@ function ConfirmUnenrollFactorModal(
|
||||
setIsModalOpen: (isOpen: boolean) => void;
|
||||
}>,
|
||||
) {
|
||||
const t = useTranslations();
|
||||
const { t } = useTranslation();
|
||||
const unEnroll = useUnenrollFactor(props.userId);
|
||||
|
||||
const onUnenrollRequested = useCallback(
|
||||
@@ -148,18 +149,15 @@ function ConfirmUnenrollFactorModal(
|
||||
if (!response.success) {
|
||||
const errorCode = response.data;
|
||||
|
||||
throw t(
|
||||
`auth.errors.${errorCode}` as never,
|
||||
{
|
||||
defaultValue: t(`account.unenrollFactorError` as never),
|
||||
} as never,
|
||||
);
|
||||
throw t(`auth:errors.${errorCode}`, {
|
||||
defaultValue: t(`account:unenrollFactorError`),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: t(`account.unenrollingFactor` as never),
|
||||
success: t(`account.unenrollFactorSuccess` as never),
|
||||
loading: t(`account:unenrollingFactor`),
|
||||
success: t(`account:unenrollFactorSuccess`),
|
||||
error: (error: string) => {
|
||||
return error;
|
||||
},
|
||||
@@ -173,17 +171,17 @@ function ConfirmUnenrollFactorModal(
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account.unenrollFactorModalHeading'} />
|
||||
<Trans i18nKey={'account:unenrollFactorModalHeading'} />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans i18nKey={'account.unenrollFactorModalDescription'} />
|
||||
<Trans i18nKey={'account:unenrollFactorModalDescription'} />
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common.cancel'} />
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
@@ -191,7 +189,7 @@ function ConfirmUnenrollFactorModal(
|
||||
disabled={unEnroll.isPending}
|
||||
onClick={() => onUnenrollRequested(props.factorId)}
|
||||
>
|
||||
<Trans i18nKey={'account.unenrollFactorModalButtonLabel'} />
|
||||
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -214,13 +212,13 @@ function FactorsTable({
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Trans i18nKey={'account.factorName'} />
|
||||
<Trans i18nKey={'account:factorName'} />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans i18nKey={'account.factorType'} />
|
||||
<Trans i18nKey={'account:factorType'} />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans i18nKey={'account.factorStatus'} />
|
||||
<Trans i18nKey={'account:factorStatus'} />
|
||||
</TableHead>
|
||||
|
||||
<TableHead />
|
||||
@@ -252,20 +250,18 @@ function FactorsTable({
|
||||
<td className={'flex justify-end'}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
size={'icon'}
|
||||
onClick={() => setUnenrolling(factor.id)}
|
||||
>
|
||||
<X className={'h-4'} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
size={'icon'}
|
||||
onClick={() => setUnenrolling(factor.id)}
|
||||
>
|
||||
<X className={'h-4'} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<Trans i18nKey={'account.unenrollTooltip'} />
|
||||
<Trans i18nKey={'account:unenrollTooltip'} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeftIcon, TriangleAlert } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
|
||||
@@ -44,33 +45,34 @@ import { Trans } from '@kit/ui/trans';
|
||||
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';
|
||||
|
||||
export function MultiFactorAuthSetupDialog(props: { userId: string }) {
|
||||
const t = useTranslations();
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const onEnrollSuccess = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
|
||||
return toast.success(t(`account.multiFactorSetupSuccess` as never));
|
||||
return toast.success(t(`account:multiFactorSetupSuccess`));
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen} disablePointerDismissal>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button>
|
||||
<Trans i18nKey={'account.setupMfaButtonLabel'} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Trans i18nKey={'account:setupMfaButtonLabel'} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogContent
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'account.setupMfaButtonLabel'} />
|
||||
<Trans i18nKey={'account:setupMfaButtonLabel'} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans i18nKey={'account.multiFactorAuthDescription'} />
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -208,7 +210,7 @@ function MultiFactorAuthSetupForm({
|
||||
|
||||
<FormDescription>
|
||||
<Trans
|
||||
i18nKey={'account.verifyActivationCodeDescription'}
|
||||
i18nKey={'account:verifyActivationCodeDescription'}
|
||||
/>
|
||||
</FormDescription>
|
||||
|
||||
@@ -221,7 +223,7 @@ function MultiFactorAuthSetupForm({
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button type={'button'} variant={'ghost'} onClick={onCancel}>
|
||||
<Trans i18nKey={'common.cancel'} />
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -231,9 +233,9 @@ function MultiFactorAuthSetupForm({
|
||||
type={'submit'}
|
||||
>
|
||||
{state.loading ? (
|
||||
<Trans i18nKey={'account.verifyingCode'} />
|
||||
<Trans i18nKey={'account:verifyingCode'} />
|
||||
) : (
|
||||
<Trans i18nKey={'account.enableMfaFactor'} />
|
||||
<Trans i18nKey={'account:enableMfaFactor'} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -255,7 +257,7 @@ function FactorQrCode({
|
||||
onSetFactorId: (factorId: string) => void;
|
||||
}>) {
|
||||
const enrollFactorMutation = useEnrollFactor(userId);
|
||||
const t = useTranslations();
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const form = useForm({
|
||||
@@ -277,16 +279,16 @@ function FactorQrCode({
|
||||
return (
|
||||
<div className={'flex w-full flex-col space-y-2'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.qrCodeErrorHeading'} />
|
||||
<Trans i18nKey={'account:qrCodeErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey={`auth.errors.${error}`}
|
||||
defaults={t('account.qrCodeErrorDescription')}
|
||||
i18nKey={`auth:errors.${error}`}
|
||||
defaults={t('account:qrCodeErrorDescription')}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -294,7 +296,7 @@ function FactorQrCode({
|
||||
<div>
|
||||
<Button variant={'outline'} onClick={onCancel}>
|
||||
<ArrowLeftIcon className={'h-4'} />
|
||||
<Trans i18nKey={`common.retry`} />
|
||||
<Trans i18nKey={`common:retry`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,7 +336,7 @@ function FactorQrCode({
|
||||
>
|
||||
<p>
|
||||
<span className={'text-muted-foreground text-sm'}>
|
||||
<Trans i18nKey={'account.multiFactorModalHeading'} />
|
||||
<Trans i18nKey={'account:multiFactorModalHeading'} />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -377,7 +379,7 @@ function FactorNameForm(
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account.factorNameLabel'} />
|
||||
<Trans i18nKey={'account:factorNameLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
@@ -385,7 +387,7 @@ function FactorNameForm(
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'account.factorNameHint'} />
|
||||
<Trans i18nKey={'account:factorNameHint'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
@@ -396,11 +398,11 @@ function FactorNameForm(
|
||||
|
||||
<div className={'flex justify-end space-x-2'}>
|
||||
<Button type={'button'} variant={'ghost'} onClick={props.onCancel}>
|
||||
<Trans i18nKey={'common.cancel'} />
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</Button>
|
||||
|
||||
<Button type={'submit'}>
|
||||
<Trans i18nKey={'account.factorNameSubmitLabel'} />
|
||||
<Trans i18nKey={'account:factorNameSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -499,14 +501,14 @@ function useVerifyCodeMutation(userId: string) {
|
||||
function ErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.multiFactorSetupErrorHeading'} />
|
||||
<Trans i18nKey={'account:multiFactorSetupErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account.multiFactorSetupErrorDescription'} />
|
||||
<Trans i18nKey={'account:multiFactorSetupErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useState } from 'react';
|
||||
import type { PostgrestError } from '@supabase/supabase-js';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Check, Lock, TriangleAlert, XIcon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { Check, Lock, XIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
@@ -40,7 +41,7 @@ export const UpdatePasswordForm = ({
|
||||
callbackPath: string;
|
||||
onSuccess?: () => void;
|
||||
}) => {
|
||||
const t = useTranslations('account');
|
||||
const { t } = useTranslation('account');
|
||||
const updateUserMutation = useUpdateUser();
|
||||
const [needsReauthentication, setNeedsReauthentication] = useState(false);
|
||||
|
||||
@@ -130,7 +131,7 @@ export const UpdatePasswordForm = ({
|
||||
autoComplete={'new-password'}
|
||||
required
|
||||
type={'password'}
|
||||
placeholder={t('newPassword')}
|
||||
placeholder={t('account:newPassword')}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
@@ -159,14 +160,14 @@ export const UpdatePasswordForm = ({
|
||||
}
|
||||
required
|
||||
type={'password'}
|
||||
placeholder={t('repeatPassword')}
|
||||
placeholder={t('account:repeatPassword')}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'account.repeatPasswordDescription'} />
|
||||
<Trans i18nKey={'account:repeatPasswordDescription'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
@@ -178,11 +179,10 @@ export const UpdatePasswordForm = ({
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateUserMutation.isPending}
|
||||
data-test="identity-form-submit"
|
||||
>
|
||||
<Trans i18nKey={'account.updatePasswordSubmitLabel'} />
|
||||
<Trans i18nKey={'account:updatePasswordSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,20 +192,20 @@ export const UpdatePasswordForm = ({
|
||||
};
|
||||
|
||||
function ErrorAlert({ error }: { error: { code: string } }) {
|
||||
const t = useTranslations();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<XIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.updatePasswordError'} />
|
||||
<Trans i18nKey={'account:updatePasswordError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey={`auth.errors.${error.code}`}
|
||||
defaults={t('auth.resetPasswordError')}
|
||||
i18nKey={`auth:errors.${error.code}`}
|
||||
defaults={t('auth:resetPasswordError')}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -218,11 +218,11 @@ function SuccessAlert() {
|
||||
<Check className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.updatePasswordSuccess'} />
|
||||
<Trans i18nKey={'account:updatePasswordSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account.updatePasswordSuccessMessage'} />
|
||||
<Trans i18nKey={'account:updatePasswordSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
@@ -231,14 +231,14 @@ function SuccessAlert() {
|
||||
function NeedsReauthenticationAlert() {
|
||||
return (
|
||||
<Alert variant={'warning'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.needsReauthentication'} />
|
||||
<Trans i18nKey={'account:needsReauthentication'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account.needsReauthenticationDescription'} />
|
||||
<Trans i18nKey={'account:needsReauthenticationDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { User } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -35,7 +35,7 @@ export function UpdateAccountDetailsForm({
|
||||
onUpdate: (user: Partial<UpdateUserDataParams>) => void;
|
||||
}) {
|
||||
const updateAccountMutation = useUpdateAccountData(userId);
|
||||
const t = useTranslations('account');
|
||||
const { t } = useTranslation('account');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(AccountDetailsSchema),
|
||||
@@ -79,7 +79,7 @@ export function UpdateAccountDetailsForm({
|
||||
<InputGroupInput
|
||||
data-test={'account-display-name'}
|
||||
minLength={2}
|
||||
placeholder={t('name')}
|
||||
placeholder={t('account:name')}
|
||||
maxLength={100}
|
||||
{...field}
|
||||
/>
|
||||
@@ -92,8 +92,8 @@ export function UpdateAccountDetailsForm({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="submit" disabled={updateAccountMutation.isPending}>
|
||||
<Trans i18nKey={'account.updateProfileSubmitLabel'} />
|
||||
<Button disabled={updateAccountMutation.isPending}>
|
||||
<Trans i18nKey={'account:updateProfileSubmitLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback } from 'react';
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
@@ -41,7 +41,7 @@ function UploadProfileAvatarForm(props: {
|
||||
onAvatarUpdated: () => void;
|
||||
}) {
|
||||
const client = useSupabase();
|
||||
const t = useTranslations('account');
|
||||
const { t } = useTranslation('account');
|
||||
|
||||
const createToaster = useCallback(
|
||||
(promise: () => Promise<unknown>) => {
|
||||
@@ -111,11 +111,11 @@ function UploadProfileAvatarForm(props: {
|
||||
<ImageUploader value={props.pictureUrl} onValueChange={onValueChange}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<span className={'text-sm'}>
|
||||
<Trans i18nKey={'account.profilePictureHeading'} />
|
||||
<Trans i18nKey={'account:profilePictureHeading'} />
|
||||
</span>
|
||||
|
||||
<span className={'text-xs'}>
|
||||
<Trans i18nKey={'account.profilePictureSubheading'} />
|
||||
<Trans i18nKey={'account:profilePictureSubheading'} />
|
||||
</span>
|
||||
</div>
|
||||
</ImageUploader>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AccountDetailsSchema = z.object({
|
||||
displayName: z.string().min(2).max(100),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DeletePersonalAccountSchema = z.object({
|
||||
otp: z.string().min(6),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LinkEmailPasswordSchema = z
|
||||
.object({
|
||||
@@ -8,5 +8,5 @@ export const LinkEmailPasswordSchema = z
|
||||
})
|
||||
.refine((values) => values.password === values.repeatPassword, {
|
||||
path: ['repeatPassword'],
|
||||
message: `account.passwordNotMatching`,
|
||||
message: `account:passwordNotMatching`,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const UpdateEmailSchema = {
|
||||
withTranslation: (errorMessage: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordUpdateSchema = {
|
||||
withTranslation: (errorMessage: string) => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createOtpApi } from '@kit/otp';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
@@ -23,17 +23,25 @@ export async function refreshAuthSession() {
|
||||
return {};
|
||||
}
|
||||
|
||||
export const deletePersonalAccountAction = authActionClient
|
||||
.schema(DeletePersonalAccountSchema)
|
||||
.action(async ({ parsedInput: data, ctx: { user } }) => {
|
||||
export const deletePersonalAccountAction = enhanceAction(
|
||||
async (formData: FormData, user) => {
|
||||
const logger = await getLogger();
|
||||
|
||||
// validate the form data
|
||||
const { success } = DeletePersonalAccountSchema.safeParse(
|
||||
Object.fromEntries(formData.entries()),
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Invalid form data');
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
name: 'account.delete',
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
const otp = data.otp;
|
||||
const otp = formData.get('otp') as string;
|
||||
|
||||
if (!otp) {
|
||||
throw new Error('OTP is required');
|
||||
@@ -93,4 +101,6 @@ export const deletePersonalAccountAction = authActionClient
|
||||
|
||||
// redirect to the home page
|
||||
redirect('/');
|
||||
});
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
@@ -133,12 +133,12 @@ class DeletePersonalAccountService {
|
||||
.object({
|
||||
productName: z
|
||||
.string({
|
||||
error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||
})
|
||||
.min(1),
|
||||
fromEmail: z
|
||||
.string({
|
||||
error: 'EMAIL_SENDER is required',
|
||||
required_error: 'EMAIL_SENDER is required',
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
"@types/react": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { EllipsisVertical } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -77,7 +77,7 @@ export function AdminAccountsTable(
|
||||
}
|
||||
|
||||
function AccountsTableFilters(props: {
|
||||
filters: z.output<typeof FiltersSchema>;
|
||||
filters: z.infer<typeof FiltersSchema>;
|
||||
}) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(FiltersSchema),
|
||||
@@ -92,7 +92,7 @@ function AccountsTableFilters(props: {
|
||||
const router = useRouter();
|
||||
const pathName = usePathname();
|
||||
|
||||
const onSubmit = ({ type, query }: z.output<typeof FiltersSchema>) => {
|
||||
const onSubmit = ({ type, query }: z.infer<typeof FiltersSchema>) => {
|
||||
const params = new URLSearchParams({
|
||||
account_type: type,
|
||||
query: query ?? '',
|
||||
@@ -105,12 +105,6 @@ function AccountsTableFilters(props: {
|
||||
|
||||
const type = useWatch({ control: form.control, name: 'type' });
|
||||
|
||||
const options = {
|
||||
all: 'All Accounts',
|
||||
team: 'Team',
|
||||
personal: 'Personal',
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -122,7 +116,7 @@ function AccountsTableFilters(props: {
|
||||
onValueChange={(value) => {
|
||||
form.setValue(
|
||||
'type',
|
||||
value as z.output<typeof FiltersSchema>['type'],
|
||||
value as z.infer<typeof FiltersSchema>['type'],
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
@@ -134,20 +128,16 @@ function AccountsTableFilters(props: {
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={'Account Type'}>
|
||||
{(value: keyof typeof options) => options[value]}
|
||||
</SelectValue>
|
||||
<SelectValue placeholder={'Account Type'} />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Account Type</SelectLabel>
|
||||
|
||||
{Object.entries(options).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={'all'}>All accounts</SelectItem>
|
||||
<SelectItem value={'team'}>Team</SelectItem>
|
||||
<SelectItem value={'personal'}>Personal</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -167,8 +157,6 @@ function AccountsTableFilters(props: {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<button type="submit" hidden />
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
@@ -223,13 +211,11 @@ function getColumns(): ColumnDef<Account>[] {
|
||||
return (
|
||||
<div className={'flex justify-end'}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant={'outline'} size={'icon'}>
|
||||
<EllipsisVertical className={'h-4'} />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={'outline'} size={'icon'}>
|
||||
<EllipsisVertical className={'h-4'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align={'end'}>
|
||||
<DropdownMenuGroup>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
@@ -42,7 +41,7 @@ export function AdminBanUserDialog(
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger render={props.children as React.ReactElement} />
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -61,9 +60,8 @@ export function AdminBanUserDialog(
|
||||
}
|
||||
|
||||
function BanUserForm(props: { userId: string; onSuccess: () => void }) {
|
||||
const { execute, isPending, hasErrored } = useAction(banUserAction, {
|
||||
onSuccess: () => props.onSuccess(),
|
||||
});
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(BanUserSchema),
|
||||
@@ -78,9 +76,18 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) {
|
||||
<form
|
||||
data-test={'admin-ban-user-form'}
|
||||
className={'flex flex-col space-y-8'}
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await banUserAction(data);
|
||||
props.onSuccess();
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<If condition={hasErrored}>
|
||||
<If condition={error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
|
||||
@@ -118,10 +125,10 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) {
|
||||
/>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
|
||||
|
||||
<Button disabled={isPending} type={'submit'} variant={'destructive'}>
|
||||
{isPending ? 'Banning...' : 'Ban User'}
|
||||
<Button disabled={pending} type={'submit'} variant={'destructive'}>
|
||||
{pending ? 'Banning...' : 'Ban User'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
@@ -39,6 +38,8 @@ import {
|
||||
} from '../lib/server/schema/create-user.schema';
|
||||
|
||||
export function AdminCreateUserDialog(props: React.PropsWithChildren) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
@@ -51,19 +52,28 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const { execute, isPending, result } = useAction(createUserAction, {
|
||||
onSuccess: () => {
|
||||
toast.success('User created successfully');
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
const onSubmit = (data: CreateUserSchemaType) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createUserAction(data);
|
||||
|
||||
const error = result.serverError;
|
||||
if (result.success) {
|
||||
toast.success('User creates successfully');
|
||||
form.reset();
|
||||
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger render={props.children as React.ReactElement} />
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -78,9 +88,7 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
|
||||
<form
|
||||
data-test={'admin-create-user-form'}
|
||||
className={'flex flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit((data: CreateUserSchemaType) =>
|
||||
execute(data),
|
||||
)}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<If condition={!!error}>
|
||||
<Alert variant={'destructive'}>
|
||||
@@ -158,8 +166,8 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<Button disabled={isPending} type={'submit'}>
|
||||
{isPending ? 'Creating...' : 'Create User'}
|
||||
<Button disabled={pending} type={'submit'}>
|
||||
{pending ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
@@ -36,7 +37,8 @@ export function AdminDeleteAccountDialog(
|
||||
accountId: string;
|
||||
}>,
|
||||
) {
|
||||
const { execute, isPending, hasErrored } = useAction(deleteAccountAction);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(DeleteAccountSchema),
|
||||
@@ -48,7 +50,7 @@ export function AdminDeleteAccountDialog(
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={props.children as React.ReactElement} />
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -63,11 +65,20 @@ export function AdminDeleteAccountDialog(
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
data-test={'admin-delete-account-form'}
|
||||
data-form={'admin-delete-account-form'}
|
||||
className={'flex flex-col space-y-8'}
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteAccountAction(data);
|
||||
setError(false);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<If condition={hasErrored}>
|
||||
<If condition={error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
|
||||
@@ -109,11 +120,11 @@ export function AdminDeleteAccountDialog(
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<Button
|
||||
disabled={isPending}
|
||||
disabled={pending}
|
||||
type={'submit'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
{isPending ? 'Deleting...' : 'Delete'}
|
||||
{pending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
@@ -36,7 +39,8 @@ export function AdminDeleteUserDialog(
|
||||
userId: string;
|
||||
}>,
|
||||
) {
|
||||
const { execute, isPending, hasErrored } = useAction(deleteUserAction);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(DeleteUserSchema),
|
||||
@@ -48,7 +52,7 @@ export function AdminDeleteUserDialog(
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={props.children as React.ReactElement} />
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -65,9 +69,23 @@ export function AdminDeleteUserDialog(
|
||||
<form
|
||||
data-test={'admin-delete-user-form'}
|
||||
className={'flex flex-col space-y-8'}
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteUserAction(data);
|
||||
|
||||
setError(false);
|
||||
} catch {
|
||||
if (isRedirectError(error)) {
|
||||
// Do nothing
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<If condition={hasErrored}>
|
||||
<If condition={error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
|
||||
@@ -109,11 +127,11 @@ export function AdminDeleteUserDialog(
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<Button
|
||||
disabled={isPending}
|
||||
disabled={pending}
|
||||
type={'submit'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
{isPending ? 'Deleting...' : 'Delete'}
|
||||
{pending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
@@ -54,13 +53,8 @@ export function AdminImpersonateUserDialog(
|
||||
refreshToken: string;
|
||||
}>();
|
||||
|
||||
const { execute, isPending, hasErrored } = useAction(impersonateUserAction, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data) {
|
||||
setTokens(data);
|
||||
}
|
||||
},
|
||||
});
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean | null>(null);
|
||||
|
||||
if (tokens) {
|
||||
return (
|
||||
@@ -74,7 +68,7 @@ export function AdminImpersonateUserDialog(
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={props.children as React.ReactElement} />
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -97,9 +91,19 @@ export function AdminImpersonateUserDialog(
|
||||
<form
|
||||
data-test={'admin-impersonate-user-form'}
|
||||
className={'flex flex-col space-y-8'}
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await impersonateUserAction(data);
|
||||
|
||||
setTokens(result);
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<If condition={hasErrored}>
|
||||
<If condition={error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
@@ -42,7 +41,7 @@ export function AdminReactivateUserDialog(
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger render={props.children as React.ReactElement} />
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -63,9 +62,8 @@ export function AdminReactivateUserDialog(
|
||||
}
|
||||
|
||||
function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
|
||||
const { execute, isPending, hasErrored } = useAction(reactivateUserAction, {
|
||||
onSuccess: () => props.onSuccess(),
|
||||
});
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ReactivateUserSchema),
|
||||
@@ -80,9 +78,18 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
|
||||
<form
|
||||
data-test={'admin-reactivate-user-form'}
|
||||
className={'flex flex-col space-y-8'}
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await reactivateUserAction(data);
|
||||
props.onSuccess();
|
||||
} catch {
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
})}
|
||||
>
|
||||
<If condition={hasErrored}>
|
||||
<If condition={error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
|
||||
@@ -120,10 +127,10 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
|
||||
/>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
|
||||
|
||||
<Button disabled={isPending} type={'submit'}>
|
||||
{isPending ? 'Reactivating...' : 'Reactivate User'}
|
||||
<Button disabled={pending} type={'submit'}>
|
||||
{pending ? 'Reactivating...' : 'Reactivate User'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
@@ -50,22 +51,33 @@ export function AdminResetPasswordDialog(
|
||||
},
|
||||
});
|
||||
|
||||
const { execute, isPending, hasErrored, hasSucceeded } = useAction(
|
||||
resetPasswordAction,
|
||||
{
|
||||
onSuccess: () => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => {
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await resetPasswordAction(data);
|
||||
|
||||
setSuccess(true);
|
||||
form.reset({ userId: props.userId, confirmation: '' });
|
||||
|
||||
toast.success('Password reset email successfully sent');
|
||||
},
|
||||
onError: () => {
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
|
||||
toast.error('We hit an error. Please read the logs.');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={props.children as React.ReactElement} />
|
||||
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -78,10 +90,7 @@ export function AdminResetPasswordDialog(
|
||||
|
||||
<div className="relative">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmation"
|
||||
@@ -106,7 +115,7 @@ export function AdminResetPasswordDialog(
|
||||
)}
|
||||
/>
|
||||
|
||||
<If condition={hasErrored}>
|
||||
<If condition={!!error}>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
We encountered an error while sending the email
|
||||
@@ -118,7 +127,7 @@ export function AdminResetPasswordDialog(
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<If condition={hasSucceeded}>
|
||||
<If condition={success}>
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
Password reset email sent successfully
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
@@ -18,168 +19,212 @@ import { CreateUserSchema } from './schema/create-user.schema';
|
||||
import { ResetPasswordSchema } from './schema/reset-password.schema';
|
||||
import { createAdminAccountsService } from './services/admin-accounts.service';
|
||||
import { createAdminAuthUserService } from './services/admin-auth-user.service';
|
||||
import { adminActionClient } from './utils/admin-action-client';
|
||||
import { adminAction } from './utils/admin-action';
|
||||
|
||||
/**
|
||||
* @name banUserAction
|
||||
* @description Ban a user from the system.
|
||||
*/
|
||||
export const banUserAction = adminActionClient
|
||||
.schema(BanUserSchema)
|
||||
.action(async ({ parsedInput: { userId } }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
export const banUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is banning user...`);
|
||||
logger.info({ userId }, `Super Admin is banning user...`);
|
||||
|
||||
const { error } = await service.banUser(userId);
|
||||
const { error } = await service.banUser(userId);
|
||||
|
||||
if (error) {
|
||||
logger.error({ error }, `Error banning user`);
|
||||
throw new Error('Error banning user');
|
||||
}
|
||||
if (error) {
|
||||
logger.error({ error }, `Error banning user`);
|
||||
|
||||
revalidateAdmin();
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully banned user`);
|
||||
});
|
||||
revalidateAdmin();
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully banned user`);
|
||||
},
|
||||
{
|
||||
schema: BanUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name reactivateUserAction
|
||||
* @description Reactivate a user in the system.
|
||||
*/
|
||||
export const reactivateUserAction = adminActionClient
|
||||
.schema(ReactivateUserSchema)
|
||||
.action(async ({ parsedInput: { userId } }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
export const reactivateUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is reactivating user...`);
|
||||
logger.info({ userId }, `Super Admin is reactivating user...`);
|
||||
|
||||
const { error } = await service.reactivateUser(userId);
|
||||
const { error } = await service.reactivateUser(userId);
|
||||
|
||||
if (error) {
|
||||
logger.error({ error }, `Error reactivating user`);
|
||||
throw new Error('Error reactivating user');
|
||||
}
|
||||
if (error) {
|
||||
logger.error({ error }, `Error reactivating user`);
|
||||
|
||||
revalidateAdmin();
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully reactivated user`);
|
||||
});
|
||||
revalidateAdmin();
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully reactivated user`);
|
||||
},
|
||||
{
|
||||
schema: ReactivateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name impersonateUserAction
|
||||
* @description Impersonate a user in the system.
|
||||
*/
|
||||
export const impersonateUserAction = adminActionClient
|
||||
.schema(ImpersonateUserSchema)
|
||||
.action(async ({ parsedInput: { userId } }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
export const impersonateUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is impersonating user...`);
|
||||
logger.info({ userId }, `Super Admin is impersonating user...`);
|
||||
|
||||
return await service.impersonateUser(userId);
|
||||
});
|
||||
return await service.impersonateUser(userId);
|
||||
},
|
||||
{
|
||||
schema: ImpersonateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteUserAction
|
||||
* @description Delete a user from the system.
|
||||
*/
|
||||
export const deleteUserAction = adminActionClient
|
||||
.schema(DeleteUserSchema)
|
||||
.action(async ({ parsedInput: { userId } }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
export const deleteUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is deleting user...`);
|
||||
logger.info({ userId }, `Super Admin is deleting user...`);
|
||||
|
||||
await service.deleteUser(userId);
|
||||
await service.deleteUser(userId);
|
||||
|
||||
logger.info({ userId }, `Super Admin has successfully deleted user`);
|
||||
logger.info({ userId }, `Super Admin has successfully deleted user`);
|
||||
|
||||
redirect('/admin/accounts');
|
||||
});
|
||||
return redirect('/admin/accounts');
|
||||
},
|
||||
{
|
||||
schema: DeleteUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteAccountAction
|
||||
* @description Delete an account from the system.
|
||||
*/
|
||||
export const deleteAccountAction = adminActionClient
|
||||
.schema(DeleteAccountSchema)
|
||||
.action(async ({ parsedInput: { accountId } }) => {
|
||||
const service = getAdminAccountsService();
|
||||
const logger = await getLogger();
|
||||
export const deleteAccountAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ accountId }) => {
|
||||
const service = getAdminAccountsService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ accountId }, `Super Admin is deleting account...`);
|
||||
logger.info({ accountId }, `Super Admin is deleting account...`);
|
||||
|
||||
await service.deleteAccount(accountId);
|
||||
await service.deleteAccount(accountId);
|
||||
|
||||
revalidateAdmin();
|
||||
revalidateAdmin();
|
||||
|
||||
logger.info({ accountId }, `Super Admin has successfully deleted account`);
|
||||
logger.info(
|
||||
{ accountId },
|
||||
`Super Admin has successfully deleted account`,
|
||||
);
|
||||
|
||||
redirect('/admin/accounts');
|
||||
});
|
||||
return redirect('/admin/accounts');
|
||||
},
|
||||
{
|
||||
schema: DeleteAccountSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name createUserAction
|
||||
* @description Create a new user in the system.
|
||||
*/
|
||||
export const createUserAction = adminActionClient
|
||||
.schema(CreateUserSchema)
|
||||
.action(async ({ parsedInput: { email, password, emailConfirm } }) => {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
const logger = await getLogger();
|
||||
export const createUserAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ email, password, emailConfirm }) => {
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ email }, `Super Admin is creating a new user...`);
|
||||
logger.info({ email }, `Super Admin is creating a new user...`);
|
||||
|
||||
const { data, error } = await adminClient.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: emailConfirm,
|
||||
});
|
||||
const { data, error } = await adminClient.auth.admin.createUser({
|
||||
email,
|
||||
password,
|
||||
email_confirm: emailConfirm,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error({ error }, `Error creating user`);
|
||||
throw new Error(`Error creating user: ${error.message}`);
|
||||
}
|
||||
if (error) {
|
||||
logger.error({ error }, `Error creating user`);
|
||||
throw new Error(`Error creating user: ${error.message}`);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ userId: data.user.id },
|
||||
`Super Admin has successfully created a new user`,
|
||||
);
|
||||
logger.info(
|
||||
{ userId: data.user.id },
|
||||
`Super Admin has successfully created a new user`,
|
||||
);
|
||||
|
||||
revalidatePath(`/admin/accounts`);
|
||||
revalidatePath(`/admin/accounts`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: data.user,
|
||||
};
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
user: data.user,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: CreateUserSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @name resetPasswordAction
|
||||
* @description Reset a user's password by sending a password reset email.
|
||||
*/
|
||||
export const resetPasswordAction = adminActionClient
|
||||
.schema(ResetPasswordSchema)
|
||||
.action(async ({ parsedInput: { userId } }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
export const resetPasswordAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ userId }) => {
|
||||
const service = getAdminAuthService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ userId }, `Super Admin is resetting user password...`);
|
||||
logger.info({ userId }, `Super Admin is resetting user password...`);
|
||||
|
||||
const result = await service.resetPassword(userId);
|
||||
const result = await service.resetPassword(userId);
|
||||
|
||||
logger.info(
|
||||
{ userId },
|
||||
`Super Admin has successfully sent password reset email`,
|
||||
);
|
||||
logger.info(
|
||||
{ userId },
|
||||
`Super Admin has successfully sent password reset email`,
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
return result;
|
||||
},
|
||||
{
|
||||
schema: ResetPasswordSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function revalidateAdmin() {
|
||||
revalidatePath(`/admin/accounts/[id]`, 'page');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
const ConfirmationSchema = z.object({
|
||||
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateUserSchema = z.object({
|
||||
email: z.string().email({ message: 'Please enter a valid email address' }),
|
||||
@@ -8,4 +8,4 @@ export const CreateUserSchema = z.object({
|
||||
emailConfirm: z.boolean().default(false).optional(),
|
||||
});
|
||||
|
||||
export type CreateUserSchemaType = z.output<typeof CreateUserSchema>;
|
||||
export type CreateUserSchemaType = z.infer<typeof CreateUserSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for resetting a user's password
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'server-only';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { isSuperAdmin } from './is-super-admin';
|
||||
|
||||
/**
|
||||
* @name adminActionClient
|
||||
* @description Safe action client for admin-only actions.
|
||||
* Extends authActionClient with super admin verification.
|
||||
*/
|
||||
export const adminActionClient = authActionClient.use(async ({ next, ctx }) => {
|
||||
const isAdmin = await isSuperAdmin(getSupabaseServerClient());
|
||||
|
||||
if (!isAdmin) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return next({ ctx });
|
||||
});
|
||||
@@ -28,14 +28,15 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@marsidev/react-turnstile": "catalog:",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-intl": "catalog:",
|
||||
"react-hook-form": "catalog:",
|
||||
"react-i18next": "catalog:",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import {
|
||||
WeakPasswordError,
|
||||
@@ -33,25 +33,23 @@ export function AuthErrorAlert({
|
||||
return <WeakPasswordErrorAlert reasons={error.reasons} />;
|
||||
}
|
||||
|
||||
const DefaultError = <Trans i18nKey="auth.errors.default" />;
|
||||
|
||||
const errorCode =
|
||||
error instanceof Error
|
||||
? 'code' in error && typeof error.code === 'string'
|
||||
? error.code
|
||||
: error.message
|
||||
: error;
|
||||
const DefaultError = <Trans i18nKey="auth:errors.default" />;
|
||||
const errorCode = error instanceof Error ? error.message : error;
|
||||
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'w-4'} />
|
||||
<ExclamationTriangleIcon className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={`auth.errorAlertHeading`} />
|
||||
<Trans i18nKey={`auth:errorAlertHeading`} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription data-test={'auth-error-message'}>
|
||||
<Trans i18nKey={`auth.errors.${errorCode}`} defaults={DefaultError} />
|
||||
<Trans
|
||||
i18nKey={`auth:errors.${errorCode}`}
|
||||
defaults={'<DefaultError />'}
|
||||
components={{ DefaultError }}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
@@ -64,21 +62,21 @@ function WeakPasswordErrorAlert({
|
||||
}) {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'w-4'} />
|
||||
<ExclamationTriangleIcon className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth.errors.weakPassword.title'} />
|
||||
<Trans i18nKey={'auth:errors.weakPassword.title'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription data-test={'auth-error-message'}>
|
||||
<Trans i18nKey={'auth.errors.weakPassword.description'} />
|
||||
<Trans i18nKey={'auth:errors.weakPassword.description'} />
|
||||
|
||||
{reasons.length > 0 && (
|
||||
<ul className="mt-2 list-inside list-disc space-y-1 text-xs">
|
||||
{reasons.map((reason) => (
|
||||
<li key={reason}>
|
||||
<Trans
|
||||
i18nKey={`auth.errors.weakPassword.reasons.${reason}`}
|
||||
i18nKey={`auth:errors.weakPassword.reasons.${reason}`}
|
||||
defaults={reason}
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Mail } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@kit/ui/input-group';
|
||||
|
||||
export function EmailInput(props: React.ComponentProps<'input'>) {
|
||||
const t = useTranslations('auth');
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
return (
|
||||
<InputGroup className="dark:bg-background">
|
||||
|
||||
@@ -7,7 +7,7 @@ import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { UserCheck } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
@@ -36,7 +36,7 @@ export function ExistingAccountHintImpl({
|
||||
useLastAuthMethod();
|
||||
|
||||
const params = useSearchParams();
|
||||
const t = useTranslations();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isInvite = params.get('invite_token');
|
||||
|
||||
@@ -53,13 +53,13 @@ export function ExistingAccountHintImpl({
|
||||
|
||||
switch (methodType) {
|
||||
case 'password':
|
||||
return 'auth.methodPassword';
|
||||
return 'auth:methodPassword';
|
||||
case 'otp':
|
||||
return 'auth.methodOtp';
|
||||
return 'auth:methodOtp';
|
||||
case 'magic_link':
|
||||
return 'auth.methodMagicLink';
|
||||
return 'auth:methodMagicLink';
|
||||
default:
|
||||
return 'auth.methodDefault';
|
||||
return 'auth:methodDefault';
|
||||
}
|
||||
}, [methodType, isOAuth, providerName]);
|
||||
|
||||
@@ -73,10 +73,10 @@ export function ExistingAccountHintImpl({
|
||||
<Alert data-test={'existing-account-hint'} className={className}>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
|
||||
<AlertDescription className={'text-xs'}>
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey="auth.existingAccountHint"
|
||||
values={{ methodName: t(methodDescription) }}
|
||||
i18nKey="auth:existingAccountHint"
|
||||
values={{ method: t(methodDescription) }}
|
||||
components={{
|
||||
method: <span className="font-medium" />,
|
||||
signInLink: (
|
||||
|
||||
@@ -32,13 +32,13 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
|
||||
const methodKey = useMemo(() => {
|
||||
switch (methodType) {
|
||||
case 'password':
|
||||
return 'auth.methodPassword';
|
||||
return 'auth:methodPassword';
|
||||
case 'otp':
|
||||
return 'auth.methodOtp';
|
||||
return 'auth:methodOtp';
|
||||
case 'magic_link':
|
||||
return 'auth.methodMagicLink';
|
||||
return 'auth:methodMagicLink';
|
||||
case 'oauth':
|
||||
return 'auth.methodOauth';
|
||||
return 'auth:methodOauth';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -61,10 +61,10 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
|
||||
<Lightbulb className="h-3 w-3" />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey="auth.lastUsedMethodPrefix" />{' '}
|
||||
<Trans i18nKey="auth:lastUsedMethodPrefix" />{' '}
|
||||
<If condition={isOAuth && Boolean(providerName)}>
|
||||
<Trans
|
||||
i18nKey="auth.methodOauthWithProvider"
|
||||
i18nKey="auth:methodOauthWithProvider"
|
||||
values={{ provider: providerName }}
|
||||
components={{
|
||||
provider: <span className="text-muted-foreground font-medium" />,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Check, TriangleAlert } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppEvents } from '@kit/shared/events';
|
||||
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
|
||||
@@ -44,7 +44,7 @@ export function MagicLinkAuthContainer({
|
||||
};
|
||||
}) {
|
||||
const captcha = useCaptcha({ siteKey: captchaSiteKey });
|
||||
const t = useTranslations();
|
||||
const { t } = useTranslation();
|
||||
const signInWithOtpMutation = useSignInWithOtp();
|
||||
const appEvents = useAppEvents();
|
||||
const { recordAuthMethod } = useLastAuthMethod();
|
||||
@@ -90,9 +90,9 @@ export function MagicLinkAuthContainer({
|
||||
};
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: t('auth.sendingEmailLink'),
|
||||
success: t(`auth.sendLinkSuccessToast`),
|
||||
error: t(`auth.errors.linkTitle`),
|
||||
loading: t('auth:sendingEmailLink'),
|
||||
success: t(`auth:sendLinkSuccessToast`),
|
||||
error: t(`auth:errors.linkTitle`),
|
||||
});
|
||||
|
||||
captcha.reset();
|
||||
@@ -116,7 +116,7 @@ export function MagicLinkAuthContainer({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common.emailAddress'} />
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
@@ -133,20 +133,17 @@ export function MagicLinkAuthContainer({
|
||||
<TermsAndConditionsFormField />
|
||||
</If>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={signInWithOtpMutation.isPending || captchaLoading}
|
||||
>
|
||||
<Button disabled={signInWithOtpMutation.isPending || captchaLoading}>
|
||||
<If condition={captchaLoading}>
|
||||
<Trans i18nKey={'auth.verifyingCaptcha'} />
|
||||
<Trans i18nKey={'auth:verifyingCaptcha'} />
|
||||
</If>
|
||||
|
||||
<If condition={signInWithOtpMutation.isPending && !captchaLoading}>
|
||||
<Trans i18nKey={'auth.sendingEmailLink'} />
|
||||
<Trans i18nKey={'auth:sendingEmailLink'} />
|
||||
</If>
|
||||
|
||||
<If condition={!signInWithOtpMutation.isPending && !captchaLoading}>
|
||||
<Trans i18nKey={'auth.sendEmailLink'} />
|
||||
<Trans i18nKey={'auth:sendEmailLink'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -158,14 +155,14 @@ export function MagicLinkAuthContainer({
|
||||
function SuccessAlert() {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<Check className={'h-4'} />
|
||||
<CheckIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth.sendLinkSuccess'} />
|
||||
<Trans i18nKey={'auth:sendLinkSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth.sendLinkSuccessDescription'} />
|
||||
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
@@ -174,14 +171,14 @@ function SuccessAlert() {
|
||||
function ErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth.errors.linkTitle'} />
|
||||
<Trans i18nKey={'auth:errors.linkTitle'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth.errors.linkDescription'} />
|
||||
<Trans i18nKey={'auth:errors.linkDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useEffect, useEffectEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
|
||||
import { useSignOut } from '@kit/supabase/hooks/use-sign-out';
|
||||
@@ -94,7 +94,7 @@ export function MultiFactorChallengeContainer({
|
||||
<div className={'flex flex-col items-center gap-y-6'}>
|
||||
<div className="flex flex-col items-center gap-y-4">
|
||||
<Heading level={5}>
|
||||
<Trans i18nKey={'auth.verifyCodeHeading'} />
|
||||
<Trans i18nKey={'auth:verifyCodeHeading'} />
|
||||
</Heading>
|
||||
</div>
|
||||
|
||||
@@ -102,15 +102,15 @@ export function MultiFactorChallengeContainer({
|
||||
<div className={'flex flex-col gap-y-4'}>
|
||||
<If condition={verifyMFAChallenge.error}>
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-5'} />
|
||||
<ExclamationTriangleIcon className={'h-5'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.invalidVerificationCodeHeading'} />
|
||||
<Trans i18nKey={'account:invalidVerificationCodeHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey={'account.invalidVerificationCodeDescription'}
|
||||
i18nKey={'account:invalidVerificationCodeDescription'}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -143,7 +143,7 @@ export function MultiFactorChallengeContainer({
|
||||
|
||||
<FormDescription className="text-center">
|
||||
<Trans
|
||||
i18nKey={'account.verifyActivationCodeDescription'}
|
||||
i18nKey={'account:verifyActivationCodeDescription'}
|
||||
/>
|
||||
</FormDescription>
|
||||
|
||||
@@ -156,7 +156,6 @@ export function MultiFactorChallengeContainer({
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
data-test={'submit-mfa-button'}
|
||||
disabled={
|
||||
@@ -167,13 +166,13 @@ export function MultiFactorChallengeContainer({
|
||||
>
|
||||
<If condition={verifyMFAChallenge.isPending}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'account.verifyingCode'} />
|
||||
<Trans i18nKey={'account:verifyingCode'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={verifyMFAChallenge.isSuccess}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth.redirecting'} />
|
||||
<Trans i18nKey={'auth:redirecting'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
@@ -182,7 +181,7 @@ export function MultiFactorChallengeContainer({
|
||||
!verifyMFAChallenge.isPending && !verifyMFAChallenge.isSuccess
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={'account.submitVerificationCode'} />
|
||||
<Trans i18nKey={'account:submitVerificationCode'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -256,7 +255,7 @@ function FactorsListContainer({
|
||||
<Spinner />
|
||||
|
||||
<div className={'text-sm'}>
|
||||
<Trans i18nKey={'account.loadingFactors'} />
|
||||
<Trans i18nKey={'account:loadingFactors'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -266,14 +265,14 @@ function FactorsListContainer({
|
||||
return (
|
||||
<div className={'w-full'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'h-4'} />
|
||||
<ExclamationTriangleIcon className={'h-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account.factorsListError'} />
|
||||
<Trans i18nKey={'account:factorsListError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account.factorsListErrorDescription'} />
|
||||
<Trans i18nKey={'account:factorsListErrorDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -286,7 +285,7 @@ function FactorsListContainer({
|
||||
<div className={'animate-in fade-in flex flex-col space-y-4 duration-500'}>
|
||||
<div>
|
||||
<span className={'font-medium'}>
|
||||
<Trans i18nKey={'account.selectFactor'} />
|
||||
<Trans i18nKey={'account:selectFactor'} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export const OauthProviders: React.FC<{
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'auth.signInWithProvider'}
|
||||
i18nKey={'auth:signInWithProvider'}
|
||||
values={{
|
||||
provider: getProviderName(provider),
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp';
|
||||
import { useVerifyOtp } from '@kit/supabase/hooks/use-verify-otp';
|
||||
@@ -132,7 +132,7 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey="common.otp.enterCodeFromEmail" />
|
||||
<Trans i18nKey="common:otp.enterCodeFromEmail" />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
@@ -149,10 +149,10 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
|
||||
{verifyMutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
<Trans i18nKey="common.otp.verifying" />
|
||||
<Trans i18nKey="common:otp.verifying" />
|
||||
</>
|
||||
) : (
|
||||
<Trans i18nKey="common.otp.verifyCode" />
|
||||
<Trans i18nKey="common:otp.verifyCode" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -166,7 +166,7 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="common.otp.requestNewCode" />
|
||||
<Trans i18nKey="common:otp.requestNewCode" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -191,7 +191,7 @@ function OtpEmailForm({
|
||||
defaultValues: { email: '' },
|
||||
});
|
||||
|
||||
const handleSendOtp = async ({ email }: z.output<typeof EmailSchema>) => {
|
||||
const handleSendOtp = async ({ email }: z.infer<typeof EmailSchema>) => {
|
||||
await signInMutation.mutateAsync({
|
||||
email,
|
||||
options: { captchaToken: captcha.token, shouldCreateUser },
|
||||
@@ -230,10 +230,10 @@ function OtpEmailForm({
|
||||
{signInMutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
<Trans i18nKey="common.otp.sendingCode" />
|
||||
<Trans i18nKey="common:otp.sendingCode" />
|
||||
</>
|
||||
) : (
|
||||
<Trans i18nKey="common.otp.sendVerificationCode" />
|
||||
<Trans i18nKey="common:otp.sendVerificationCode" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useRequestResetPassword } from '@kit/supabase/hooks/use-request-reset-password';
|
||||
import { Alert, AlertDescription } from '@kit/ui/alert';
|
||||
@@ -31,7 +31,7 @@ export function PasswordResetRequestContainer(params: {
|
||||
redirectPath: string;
|
||||
captchaSiteKey?: string;
|
||||
}) {
|
||||
const t = useTranslations('auth');
|
||||
const { t } = useTranslation('auth');
|
||||
const resetPasswordMutation = useRequestResetPassword();
|
||||
const captcha = useCaptcha({ siteKey: params.captchaSiteKey });
|
||||
const captchaLoading = !captcha.isReady;
|
||||
@@ -51,7 +51,7 @@ export function PasswordResetRequestContainer(params: {
|
||||
<If condition={success}>
|
||||
<Alert variant={'success'}>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth.passwordResetSuccessMessage'} />
|
||||
<Trans i18nKey={'auth:passwordResetSuccessMessage'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
@@ -85,7 +85,7 @@ export function PasswordResetRequestContainer(params: {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common.emailAddress'} />
|
||||
<Trans i18nKey={'common:emailAddress'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
@@ -111,15 +111,15 @@ export function PasswordResetRequestContainer(params: {
|
||||
!resetPasswordMutation.isPending && !captchaLoading
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={'auth.passwordResetLabel'} />
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</If>
|
||||
|
||||
<If condition={resetPasswordMutation.isPending}>
|
||||
<Trans i18nKey={'auth.passwordResetLabel'} />
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</If>
|
||||
|
||||
<If condition={captchaLoading}>
|
||||
<Trans i18nKey={'auth.verifyingCaptcha'} />
|
||||
<Trans i18nKey={'auth:verifyingCaptcha'} />
|
||||
</If>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function PasswordSignInContainer({
|
||||
const captchaLoading = !captcha.isReady;
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (credentials: z.output<typeof PasswordSignInSchema>) => {
|
||||
async (credentials: z.infer<typeof PasswordSignInSchema>) => {
|
||||
try {
|
||||
const data = await signInMutation.mutateAsync({
|
||||
...credentials,
|
||||
|
||||
@@ -4,8 +4,8 @@ import Link from 'next/link';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRight, Mail } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
@@ -33,12 +33,12 @@ export function PasswordSignInForm({
|
||||
loading = false,
|
||||
redirecting = false,
|
||||
}: {
|
||||
onSubmit: (params: z.output<typeof PasswordSignInSchema>) => unknown;
|
||||
onSubmit: (params: z.infer<typeof PasswordSignInSchema>) => unknown;
|
||||
captchaLoading: boolean;
|
||||
loading: boolean;
|
||||
redirecting: boolean;
|
||||
}) {
|
||||
const t = useTranslations('auth');
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(PasswordSignInSchema),
|
||||
@@ -94,14 +94,15 @@ export function PasswordSignInForm({
|
||||
|
||||
<div>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={<Link href={'/auth/password-reset'} />}
|
||||
asChild
|
||||
type={'button'}
|
||||
size={'sm'}
|
||||
variant={'link'}
|
||||
className={'text-xs'}
|
||||
>
|
||||
<Trans i18nKey={'auth.passwordForgottenQuestion'} />
|
||||
<Link href={'/auth/password-reset'}>
|
||||
<Trans i18nKey={'auth:passwordForgottenQuestion'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
@@ -117,19 +118,19 @@ export function PasswordSignInForm({
|
||||
>
|
||||
<If condition={redirecting}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth.redirecting'} />
|
||||
<Trans i18nKey={'auth:redirecting'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={loading}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth.signingIn'} />
|
||||
<Trans i18nKey={'auth:signingIn'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={captchaLoading}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth.verifyingCaptcha'} />
|
||||
<Trans i18nKey={'auth:verifyingCaptcha'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
@@ -139,7 +140,7 @@ export function PasswordSignInForm({
|
||||
'animate-in fade-in slide-in-from-bottom-24 flex items-center'
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={'auth.signInWithEmail'} />
|
||||
<Trans i18nKey={'auth:signInWithEmail'} />
|
||||
|
||||
<ArrowRight
|
||||
className={
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { CheckCircledIcon } from '@radix-ui/react-icons';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
@@ -71,14 +71,14 @@ export function EmailPasswordSignUpContainer({
|
||||
function SuccessAlert() {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<CheckCircle className={'w-4'} />
|
||||
<CheckCircledIcon className={'w-4'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth.emailConfirmationAlertHeading'} />
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription data-test={'email-confirmation-alert'}>
|
||||
<Trans i18nKey={'auth.emailConfirmationAlertBody'} />
|
||||
<Trans i18nKey={'auth:emailConfirmationAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ export function PasswordSignUpForm({
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'auth.repeatPasswordDescription'} />
|
||||
<Trans i18nKey={'auth:repeatPasswordDescription'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
@@ -123,13 +123,13 @@ export function PasswordSignUpForm({
|
||||
>
|
||||
<If condition={captchaLoading}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth.verifyingCaptcha'} />
|
||||
<Trans i18nKey={'auth:verifyingCaptcha'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={loading && !captchaLoading}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth.signingUp'} />
|
||||
<Trans i18nKey={'auth:signingUp'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
@@ -139,7 +139,7 @@ export function PasswordSignUpForm({
|
||||
'animate-in fade-in slide-in-from-bottom-24 flex items-center'
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={'auth.signUpWithEmail'} />
|
||||
<Trans i18nKey={'auth:signUpWithEmail'} />
|
||||
|
||||
<ArrowRight
|
||||
className={
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
@@ -40,12 +40,12 @@ export function ResendAuthLinkForm(props: {
|
||||
return (
|
||||
<Alert variant={'success'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth.resendLinkSuccess'} />
|
||||
<Trans i18nKey={'auth:resendLinkSuccess'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans
|
||||
i18nKey={'auth.resendLinkSuccessDescription'}
|
||||
i18nKey={'auth:resendLinkSuccessDescription'}
|
||||
defaults={'Success!'}
|
||||
/>
|
||||
</AlertDescription>
|
||||
@@ -85,17 +85,17 @@ export function ResendAuthLinkForm(props: {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={resendLink.isPending || captchaLoading}>
|
||||
<Button disabled={resendLink.isPending || captchaLoading}>
|
||||
<If condition={captchaLoading}>
|
||||
<Trans i18nKey={'auth.verifyingCaptcha'} />
|
||||
<Trans i18nKey={'auth:verifyingCaptcha'} />
|
||||
</If>
|
||||
|
||||
<If condition={resendLink.isPending && !captchaLoading}>
|
||||
<Trans i18nKey={'auth.resendingLink'} />
|
||||
<Trans i18nKey={'auth:resendingLink'} />
|
||||
</If>
|
||||
|
||||
<If condition={!resendLink.isPending && !captchaLoading}>
|
||||
<Trans i18nKey={'auth.resendLink'} defaults={'Resend Link'} />
|
||||
<Trans i18nKey={'auth:resendLink'} defaults={'Resend Link'} />
|
||||
</If>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -86,7 +86,7 @@ export function SignInMethodsContainer(props: {
|
||||
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background text-muted-foreground px-2">
|
||||
<Trans i18nKey="auth.orContinueWith" />
|
||||
<Trans i18nKey="auth:orContinueWith" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function SignUpMethodsContainer(props: {
|
||||
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background text-muted-foreground px-2">
|
||||
<Trans i18nKey="auth.orContinueWith" />
|
||||
<Trans i18nKey="auth:orContinueWith" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function TermsAndConditionsFormField(
|
||||
|
||||
<div className={'text-xs'}>
|
||||
<Trans
|
||||
i18nKey={'auth.acceptTermsAndConditions'}
|
||||
i18nKey={'auth:acceptTermsAndConditions'}
|
||||
components={{
|
||||
TermsOfServiceLink: (
|
||||
<Link
|
||||
@@ -29,7 +29,7 @@ export function TermsAndConditionsFormField(
|
||||
className={'underline'}
|
||||
href={'/terms-of-service'}
|
||||
>
|
||||
<Trans i18nKey={'auth.termsOfService'} />
|
||||
<Trans i18nKey={'auth:termsOfService'} />
|
||||
</Link>
|
||||
),
|
||||
PrivacyPolicyLink: (
|
||||
@@ -38,7 +38,7 @@ export function TermsAndConditionsFormField(
|
||||
className={'underline'}
|
||||
href={'/privacy-policy'}
|
||||
>
|
||||
<Trans i18nKey={'auth.privacyPolicy'} />
|
||||
<Trans i18nKey={'auth:privacyPolicy'} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
|
||||
@@ -31,7 +31,7 @@ export function UpdatePasswordForm(params: {
|
||||
}) {
|
||||
const updateUser = useUpdateUser();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(PasswordResetSchema),
|
||||
@@ -68,7 +68,7 @@ export function UpdatePasswordForm(params: {
|
||||
|
||||
router.replace(params.redirectTo);
|
||||
|
||||
toast.success(t('account.updatePasswordSuccessMessage'));
|
||||
toast.success(t('account:updatePasswordSuccessMessage'));
|
||||
})}
|
||||
>
|
||||
<div className={'flex-col space-y-2.5'}>
|
||||
@@ -94,7 +94,7 @@ export function UpdatePasswordForm(params: {
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'common.repeatPassword'} />
|
||||
<Trans i18nKey={'common:repeatPassword'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
@@ -107,7 +107,7 @@ export function UpdatePasswordForm(params: {
|
||||
type="submit"
|
||||
className={'w-full'}
|
||||
>
|
||||
<Trans i18nKey={'auth.passwordResetLabel'} />
|
||||
<Trans i18nKey={'auth:passwordResetLabel'} />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -122,7 +122,7 @@ function ErrorState(props: {
|
||||
code: string;
|
||||
};
|
||||
}) {
|
||||
const t = useTranslations('auth');
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
const errorMessage = t(`errors.${props.error.code}`, {
|
||||
defaultValue: t('errors.resetPasswordError'),
|
||||
@@ -131,17 +131,17 @@ function ErrorState(props: {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<TriangleAlert className={'s-6'} />
|
||||
<ExclamationTriangleIcon className={'s-6'} />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'common.genericError'} />
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={props.onRetry} variant={'outline'}>
|
||||
<Trans i18nKey={'common.retry'} />
|
||||
<Trans i18nKey={'common:retry'} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as z from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user