Next.js Supabase V3 (#463)

Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
This commit is contained in:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

View File

@@ -1,289 +1,35 @@
# Feature Packages Instructions
# Feature Packages
This file contains instructions for working with feature packages including accounts, teams, billing, auth, and notifications.
## Packages
## Feature Package Structure
- `accounts/` — Personal account management
- `admin/` — Super admin functionality
- `auth/` — Authentication features
- `notifications/` — Notification system
- `team-accounts/` — Team account management
- `accounts/` - Personal account management
- `admin/` - Super admin functionality
- `auth/` - Authentication features
- `notifications/` - Notification system
- `team-accounts/` - Team account management
## Non-Negotiables
## Account Services
1. ALWAYS use `createAccountsApi(client)` / `createTeamAccountsApi(client)` factories — NEVER query tables directly if methods exist
2. NEVER import `useUserWorkspace` outside `app/home/(user)` routes
3. NEVER import `useTeamAccountWorkspace` outside `app/home/[account]` routes
4. NEVER call admin operations without `isSuperAdmin()` check first
5. ALWAYS wrap admin pages with `AdminGuard`
6. ALWAYS use `getLogger()` from `@kit/shared/logger` for structured logging — NEVER `console.log` in production code
7. NEVER bypass permission checks when permissions exist — use `api.hasPermission({ accountId, userId, permission })`
### Personal Accounts API
## Key Imports
Located at: `packages/features/accounts/src/server/api.ts`
| API | Import |
| ----------------- | ----------------------------------------------------- |
| Personal accounts | `createAccountsApi` from `@kit/accounts/api` |
| Team accounts | `createTeamAccountsApi` from `@kit/team-accounts/api` |
| Admin check | `isSuperAdmin` from `@kit/admin` |
| Admin guard | `AdminGuard` from `@kit/admin/components/admin-guard` |
| Logger | `getLogger` from `@kit/shared/logger` |
```typescript
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
## Exemplars
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
// Get account data
const account = await api.getAccount(accountId);
// Get account workspace
const workspace = await api.getAccountWorkspace();
// Load user accounts
const accounts = await api.loadUserAccounts();
// Get subscription
const subscription = await api.getSubscription(accountId);
// Get customer ID
const customerId = await api.getCustomerId(accountId);
```
### Team Accounts API
Located at: `packages/features/team-accounts/src/server/api.ts`
```typescript
import { createTeamAccountsApi } from '@kit/team-accounts/api';
const api = createTeamAccountsApi(client);
// Get team account by slug
const account = await api.getTeamAccount(slug);
// Get account workspace
const workspace = await api.getAccountWorkspace(slug);
// Check permissions
const hasPermission = await api.hasPermission({
accountId,
userId,
permission: 'billing.manage'
});
// Get members count
const count = await api.getMembersCount(accountId);
// Get invitation
const invitation = await api.getInvitation(adminClient, token);
```
## Workspace Contexts
### Personal Account Context
Use in `apps/web/app/home/(user)` routes:
```tsx
import { useUserWorkspace } from 'kit/accounts/hooks/use-user-workspace';
function PersonalComponent() {
const { user, account } = useUserWorkspace();
// user: authenticated user data
// account: personal account data
return <div>Welcome {user.name}</div>;
}
```
Context provider: `packages/features/accounts/src/components/user-workspace-context-provider.tsx`
### Team Account Context
Use in `apps/web/app/home/[account]` routes:
```tsx
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
function TeamComponent() {
const { account, user, accounts } = useTeamAccountWorkspace();
// account: current team account data
// user: authenticated user data
// accounts: all accounts user has access to
return <div>Team: {account.name}</div>;
}
```
Context provider: `packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
## Billing Services
### Personal Billing
Located at: `apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts`
```typescript
// Personal billing operations
// - Manage individual user subscriptions
// - Handle personal account payments
// - Process individual billing changes
```
### Team Billing
Located at: `apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts`
```typescript
// Team billing operations
// - Manage team subscriptions
// - Handle team payments
// - Process team billing changes
```
### Per-Seat Billing Service
Located at: `packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts`
```typescript
import { createAccountPerSeatBillingService } from '@kit/team-accounts/billing';
const billingService = createAccountPerSeatBillingService(client);
// Increase seats when adding team members
await billingService.increaseSeats(accountId);
// Decrease seats when removing team members
await billingService.decreaseSeats(accountId);
// Get per-seat subscription item
const subscription = await billingService.getPerSeatSubscriptionItem(accountId);
```
## Authentication Features
### OTP for Sensitive Operations
Use one-time tokens from `packages/otp/src/api/index.ts`:
```tsx
import { VerifyOtpForm } from '@kit/otp/components';
<VerifyOtpForm
purpose="account-deletion"
email={user.email}
onSuccess={(otp) => {
// Proceed with verified operation
handleSensitiveOperation(otp);
}}
CancelButton={<Button variant="outline">Cancel</Button>}
/>
```
## Admin Features
### Super Admin Protection
For admin routes, use `AdminGuard`:
```tsx
import { AdminGuard } from '@kit/admin/components/admin-guard';
function AdminPage() {
return (
<div>
<h1>Admin Dashboard</h1>
{/* Admin content */}
</div>
);
}
// Wrap the page component
export default AdminGuard(AdminPage);
```
### Admin Service
Located at: `packages/features/admin/src/lib/server/services/admin.service.ts`
```typescript
// Admin service operations
// - Manage all accounts
// - Handle admin-level operations
// - Access system-wide data
```
### Checking Admin Status
```typescript
import { isSuperAdmin } from '@kit/admin';
function criticalAdminFeature() {
const isAdmin = await isSuperAdmin(client);
if (!isAdmin) {
throw new Error('Access denied: Admin privileges required');
}
// ...
}
```
## Error Handling & Logging
### Structured Logging
Use logger from `packages/shared/src/logger/logger.ts`:
```typescript
import { getLogger } from '@kit/shared/logger';
async function featureOperation() {
const logger = await getLogger();
const ctx = {
name: 'feature-operation',
userId: user.id,
accountId: account.id
};
try {
logger.info(ctx, 'Starting feature operation');
// Perform operation
const result = await performOperation();
logger.info({ ...ctx, result }, 'Feature operation completed');
return result;
} catch (error) {
logger.error({ ...ctx, error }, 'Feature operation failed');
throw error;
}
}
```
## Permission Patterns
### Team Permissions
```typescript
import { createTeamAccountsApi } from '@kit/team-accounts/api';
const api = createTeamAccountsApi(client);
// Check if user has specific permission on account
const canManageBilling = await api.hasPermission({
accountId,
userId,
permission: 'billing.manage'
});
if (!canManageBilling) {
throw new Error('Insufficient permissions');
}
```
### Account Ownership
```typescript
// Check if user is account owner (works for both personal and team accounts)
const isOwner = await client.rpc('is_account_owner', {
account_id: accountId
});
if (!isOwner) {
throw new Error('Only account owners can perform this action');
}
```
- Server actions: `packages/features/accounts/src/server/personal-accounts-server-actions.ts`
- Workspace loading: `apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts`
- Team policies: `packages/features/team-accounts/src/server/policies/policies.ts`

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,12 +1,13 @@
{
"name": "@kit/accounts",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./personal-account-dropdown": "./src/components/personal-account-dropdown.tsx",
@@ -16,43 +17,38 @@
"./hooks/*": "./src/hooks/*.ts",
"./api": "./src/server/api.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"nanoid": "^5.1.6"
"nanoid": "catalog:"
},
"devDependencies": {
"@hookform/resolvers": "^5.2.2",
"@hookform/resolvers": "catalog:",
"@kit/billing-gateway": "workspace:*",
"@kit/email-templates": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/i18n": "workspace:*",
"@kit/mailers": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*",
"@kit/otp": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@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-themes": "0.4.6",
"next-intl": "catalog:",
"next-safe-action": "catalog:",
"next-themes": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"react-i18next": "catalog:",
"zod": "catalog:"
},
"prettier": "@kit/prettier-config",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -1,10 +1,9 @@
'use client';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons';
import { CheckCircle, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ChevronsUpDown, Plus, User } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import { Button } from '@kit/ui/button';
@@ -40,7 +39,7 @@ interface AccountSelectorProps {
selectedAccount?: string;
collapsed?: boolean;
className?: string;
collisionPadding?: number;
showPersonalAccount?: boolean;
onAccountChange: (value: string | undefined) => void;
}
@@ -57,16 +56,14 @@ export function AccountSelector({
enableTeamCreation: true,
},
collapsed = false,
collisionPadding = 20,
showPersonalAccount = true,
}: React.PropsWithChildren<AccountSelectorProps>) {
const [open, setOpen] = useState<boolean>(false);
const [isCreatingAccount, setIsCreatingAccount] = useState<boolean>(false);
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const personalData = usePersonalAccountData(userId);
const value = useMemo(() => {
return selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
}, [selectedAccount]);
const value = selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
const selected = accounts.find((account) => account.value === value);
const pictureUrl = personalData.data?.picture_url;
@@ -74,128 +71,134 @@ export function AccountSelector({
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<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
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
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 w-full min-w-0 px-1 lg:w-auto',
{
'justify-start': !collapsed,
'm-auto justify-center lg:w-full': collapsed,
},
className,
)}
</If>
<CaretSortIcon
className={cn('ml-1 h-4 w-4 shrink-0 opacity-50', {
hidden: collapsed,
})}
/>
</Button>
}
>
<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'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span
className={cn('truncate lg:max-w-[130px]', {
hidden: collapsed,
})}
>
{account.label}
</span>
</span>
)}
</If>
<ChevronsUpDown
className={cn('h-4 w-4 shrink-0 opacity-50', {
hidden: collapsed,
})}
/>
</PopoverTrigger>
<PopoverContent
data-test={'account-selector-content'}
className="w-full p-0"
collisionPadding={collisionPadding}
className="w-full gap-0 p-0"
>
<Command>
<Command value={value}>
<CommandInput placeholder={t('searchAccount')} className="h-9" />
<CommandList>
<CommandGroup>
<CommandItem
className="shadow-none"
onSelect={() => onAccountChange(undefined)}
value={PERSONAL_ACCOUNT_SLUG}
>
<PersonalAccountAvatar />
{showPersonalAccount && (
<>
<CommandGroup>
<CommandItem
tabIndex={0}
value={PERSONAL_ACCOUNT_SLUG}
onSelect={() => onAccountChange(undefined)}
className={cn('', {
'bg-muted': value === PERSONAL_ACCOUNT_SLUG,
'data-selected:hover:bg-muted/50 data-selected:bg-transparent':
value !== PERSONAL_ACCOUNT_SLUG,
})}
>
<PersonalAccountAvatar />
<span className={'ml-2'}>
<Trans i18nKey={'teams:personalAccount'} />
</span>
<span className={'ml-2'}>
<Trans i18nKey={'teams.personalAccount'} />
</span>
</CommandItem>
</CommandGroup>
<Icon selected={value === PERSONAL_ACCOUNT_SLUG} />
</CommandItem>
</CommandGroup>
<CommandSeparator />
<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,
'data-selected: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 ?? ''}
value={account.value ?? undefined}
onSelect={(currentValue) => {
setOpen(false);
@@ -204,13 +207,12 @@ export function AccountSelector({
}
}}
>
<div className={'flex items-center'}>
<Avatar className={'mr-2 h-6 w-6 rounded-xs'}>
<div className={'flex w-full items-center'}>
<Avatar className={'mr-2 h-6 w-6'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
className={cn('rounded-xs', {
['bg-background']: value === account.value,
className={cn({
['group-hover:bg-background']:
value !== account.value,
})}
@@ -219,12 +221,10 @@ export function AccountSelector({
</AvatarFallback>
</Avatar>
<span className={'mr-2 max-w-[165px] truncate'}>
<span className={'max-w-[165px] truncate'}>
{account.label}
</span>
</div>
<Icon selected={(account.value ?? '') === value} />
</CommandItem>
))}
</CommandGroup>
@@ -232,26 +232,27 @@ export function AccountSelector({
</CommandList>
</Command>
<Separator />
<If condition={features.enableTeamCreation}>
<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="px-1">
<Separator />
<span>
<Trans i18nKey={'teams:createTeam'} />
</span>
</Button>
<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>
</div>
</If>
</PopoverContent>
@@ -275,18 +276,10 @@ 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} />
) : (
<PersonIcon className="h-5 w-5" />
<User className="h-5 w-5" />
);
}

View File

@@ -87,20 +87,19 @@ 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-[minimized=true]/sidebar:px-0',
'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[collapsible=icon]:px-0',
className ?? '',
{
['active:bg-secondary/50 items-center gap-4 rounded-md' +
' hover:bg-secondary border border-dashed p-2 transition-colors']:
['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']:
showProfileName,
},
)}
>
<ProfileAvatar
className={
'group-hover/trigger:border-background/50 rounded-md border border-transparent transition-colors'
'group-hover/trigger:border-background/50 border border-transparent transition-colors'
}
fallbackClassName={'rounded-md border'}
fallbackClassName={'border'}
displayName={displayName ?? user?.email ?? ''}
pictureUrl={personalAccountData?.picture_url}
/>
@@ -108,7 +107,7 @@ export function PersonalAccountDropdown({
<If condition={showProfileName}>
<div
className={
'fade-in flex w-full flex-col truncate text-left group-data-[minimized=true]/sidebar:hidden'
'fade-in flex w-full flex-col truncate text-left group-data-[collapsible=icon]:hidden'
}
>
<span
@@ -128,19 +127,25 @@ export function PersonalAccountDropdown({
<ChevronsUpDown
className={
'text-muted-foreground mr-1 h-8 group-data-[minimized=true]/sidebar:hidden'
'text-muted-foreground mr-1 h-8 group-data-[collapsible=icon]:hidden'
}
/>
</If>
</DropdownMenuTrigger>
<DropdownMenuContent className={'xl:min-w-[15rem]!'}>
<DropdownMenuItem className={'h-10! rounded-none'}>
<DropdownMenuItem
className={'group/item h-10! data-[highlighted]:bg-transparent'}
>
<div
className={'flex flex-col justify-start truncate text-left text-xs'}
>
<div className={'text-muted-foreground'}>
<Trans i18nKey={'common:signedInAs'} />
<div
className={
'text-muted-foreground group-hover/item:text-muted-foreground!'
}
>
<Trans i18nKey={'common.signedInAs'} />
</div>
<div>
@@ -151,48 +156,48 @@ export function PersonalAccountDropdown({
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={paths.home}
>
<Home className={'h-5'} />
<DropdownMenuItem
render={
<Link className={'flex items-center gap-x-2'} href={paths.home} />
}
>
<Home className={'h-4 w-4'} />
<span>
<Trans i18nKey={'common:routes.home'} />
</span>
</Link>
<span>
<Trans i18nKey={'common.routes.home'} />
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={'/docs'}
>
<MessageCircleQuestion className={'h-5'} />
<DropdownMenuItem
render={
<Link className={'flex items-center gap-x-2'} href={'/docs'} />
}
>
<MessageCircleQuestion className={'h-4 w-4'} />
<span>
<Trans i18nKey={'common:documentation'} />
</span>
</Link>
<span>
<Trans i18nKey={'common.documentation'} />
</span>
</DropdownMenuItem>
<If condition={isSuperAdmin}>
<DropdownMenuSeparator />
<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'} />
<DropdownMenuItem
render={
<Link
className={
'flex items-center gap-x-2 text-yellow-700 dark:text-yellow-500'
}
href={'/admin'}
/>
}
>
<Shield className={'h-4 w-4'} />
<span>Super Admin</span>
</Link>
<span>Super Admin</span>
</DropdownMenuItem>
</If>
@@ -210,11 +215,11 @@ export function PersonalAccountDropdown({
className={'cursor-pointer'}
onClick={signOutRequested}
>
<span className={'flex w-full items-center space-x-2'}>
<LogOut className={'h-5'} />
<span className={'flex w-full items-center gap-x-2'}>
<LogOut className={'h-4 w-4'} />
<span>
<Trans i18nKey={'auth:signOut'} />
<Trans i18nKey={'auth.signOut'} />
</span>
</span>
</DropdownMenuItem>

View File

@@ -1,9 +1,8 @@
'use client';
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { TriangleAlert } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import { ErrorBoundary } from '@kit/monitoring/components';
@@ -31,11 +30,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>
@@ -55,16 +54,18 @@ function DeleteAccountModal() {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button data-test={'delete-account-button'} variant={'destructive'}>
<Trans i18nKey={'account:deleteAccount'} />
</Button>
</AlertDialogTrigger>
<AlertDialogTrigger
render={
<Button data-test={'delete-account-button'} variant={'destructive'}>
<Trans i18nKey={'account.deleteAccount'} />
</Button>
}
/>
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:deleteAccount'} />
<Trans i18nKey={'account.deleteAccount'} />
</AlertDialogTitle>
</AlertDialogHeader>
@@ -77,6 +78,8 @@ function DeleteAccountModal() {
}
function DeleteAccountForm(props: { email: string }) {
const { execute, isPending } = useAction(deletePersonalAccountAction);
const form = useForm({
resolver: zodResolver(DeletePersonalAccountSchema),
defaultValues: {
@@ -94,7 +97,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>
}
/>
@@ -105,11 +108,12 @@ function DeleteAccountForm(props: { email: string }) {
<Form {...form}>
<form
data-test={'delete-account-form'}
action={deletePersonalAccountAction}
onSubmit={(e) => {
e.preventDefault();
execute({ otp });
}}
className={'flex flex-col space-y-4'}
>
<input type="hidden" name="otp" value={otp} />
<div className={'flex flex-col space-y-6'}>
<div
className={
@@ -118,11 +122,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>
@@ -130,36 +134,28 @@ function DeleteAccountForm(props: { email: string }) {
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
<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>
</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">
@@ -167,7 +163,7 @@ function DeleteAccountErrorContainer() {
<div>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
</div>
</div>
@@ -177,14 +173,14 @@ function DeleteAccountErrorContainer() {
function DeleteAccountErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:deleteAccountErrorHeading'} />
<Trans i18nKey={'account.deleteAccountErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
<Trans i18nKey={'common.genericError'} />
</AlertDescription>
</Alert>
);

View File

@@ -2,8 +2,7 @@
import type { Provider } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { routing } from '@kit/i18n';
import {
Card,
CardContent,
@@ -55,11 +54,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>
@@ -76,11 +75,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>
@@ -93,16 +92,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 />
<LanguageSelector locales={routing.locales} />
</CardContent>
</Card>
</If>
@@ -110,11 +109,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>
@@ -127,11 +126,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>
@@ -144,11 +143,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>
@@ -160,11 +159,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>
@@ -183,11 +182,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>
@@ -201,10 +200,7 @@ export function PersonalAccountSettingsContainer(
}
function useSupportMultiLanguage() {
const { i18n } = useTranslation();
const langs = (i18n?.options?.supportedLngs as string[]) ?? [];
const { locales } = routing;
const supportedLangs = langs.filter((lang) => lang !== 'cimode');
return supportedLangs.length > 1;
return locales.length > 1;
}

View File

@@ -1,10 +1,9 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon } from '@radix-ui/react-icons';
import { Mail } from 'lucide-react';
import { Check, Mail } from 'lucide-react';
import { useTranslations } from 'next-intl';
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';
@@ -62,7 +61,7 @@ export function UpdateEmailForm({
callbackPath: string;
onSuccess?: () => void;
}) {
const { t } = useTranslation('account');
const t = useTranslations('account');
const updateUserMutation = useUpdateUser();
const isSettingEmail = !email;
@@ -108,14 +107,14 @@ export function UpdateEmailForm({
>
<If condition={updateUserMutation.data}>
<Alert variant={'success'}>
<CheckIcon className={'h-4'} />
<Check className={'h-4'} />
<AlertTitle>
<Trans
i18nKey={
isSettingEmail
? 'account:setEmailSuccess'
: 'account:updateEmailSuccess'
? 'account.setEmailSuccess'
: 'account.updateEmailSuccess'
}
/>
</AlertTitle>
@@ -124,8 +123,8 @@ export function UpdateEmailForm({
<Trans
i18nKey={
isSettingEmail
? 'account:setEmailSuccessMessage'
: 'account:updateEmailSuccessMessage'
? 'account.setEmailSuccessMessage'
: 'account.updateEmailSuccessMessage'
}
/>
</AlertDescription>
@@ -148,9 +147,7 @@ export function UpdateEmailForm({
required
type={'email'}
placeholder={t(
isSettingEmail
? 'account:emailAddress'
: 'account:newEmail',
isSettingEmail ? 'emailAddress' : 'newEmail',
)}
{...field}
/>
@@ -177,7 +174,7 @@ export function UpdateEmailForm({
data-test={'account-email-form-repeat-email-input'}
required
type={'email'}
placeholder={t('account:repeatEmail')}
placeholder={t('repeatEmail')}
/>
</InputGroup>
</FormControl>
@@ -190,12 +187,12 @@ export function UpdateEmailForm({
</div>
<div>
<Button disabled={updateUserMutation.isPending}>
<Button type="submit" disabled={updateUserMutation.isPending}>
<Trans
i18nKey={
isSettingEmail
? 'account:setEmailAddress'
: 'account:updateEmailSubmitLabel'
? 'account.setEmailAddress'
: 'account.updateEmailSubmitLabel'
}
/>
</Button>

View File

@@ -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,28 +185,30 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<ItemActions>
<If condition={hasMultipleIdentities}>
<AlertDialog>
<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>
<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>
}
/>
<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>
@@ -214,14 +216,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>
@@ -243,11 +245,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>
@@ -281,7 +283,7 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<ItemDescription>
<Trans
i18nKey={'account:linkAccountDescription'}
i18nKey={'account.linkAccountDescription'}
values={{ provider }}
/>
</ItemDescription>
@@ -299,7 +301,7 @@ function NoAccountsAvailable() {
return (
<div>
<span className="text-muted-foreground text-xs">
<Trans i18nKey={'account:noAccountsAvailable'} />
<Trans i18nKey={'account.noAccountsAvailable'} />
</span>
</div>
);
@@ -310,38 +312,41 @@ function UpdateEmailDialog(props: { redirectTo: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<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>
<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'} />
</div>
</ItemHeader>
</ItemContent>
</Item>
</DialogTrigger>
</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>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:setEmailAddress'} />
<Trans i18nKey={'account.setEmailAddress'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'account:setEmailDescription'} />
<Trans i18nKey={'account.setEmailDescription'} />
</DialogDescription>
</DialogHeader>
@@ -373,34 +378,38 @@ function UpdatePasswordDialog(props: {
return (
<Dialog open={open} onOpenChange={setOpen}>
<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>
<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'} />
</div>
</ItemHeader>
</ItemContent>
</Item>
</DialogTrigger>
</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>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:linkEmailPassword'} />
<Trans i18nKey={'account.linkEmailPassword'} />
</DialogTitle>
</DialogHeader>

View File

@@ -4,10 +4,9 @@ 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, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ShieldCheck, TriangleAlert, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
@@ -78,7 +77,7 @@ function FactorsTableContainer(props: { userId: string }) {
<Spinner />
<div>
<Trans i18nKey={'account:loadingFactors'} />
<Trans i18nKey={'account.loadingFactors'} />
</div>
</div>
);
@@ -88,14 +87,14 @@ function FactorsTableContainer(props: { userId: string }) {
return (
<div>
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert 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>
@@ -114,11 +113,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>
@@ -136,7 +135,7 @@ function ConfirmUnenrollFactorModal(
setIsModalOpen: (isOpen: boolean) => void;
}>,
) {
const { t } = useTranslation();
const t = useTranslations();
const unEnroll = useUnenrollFactor(props.userId);
const onUnenrollRequested = useCallback(
@@ -149,15 +148,18 @@ function ConfirmUnenrollFactorModal(
if (!response.success) {
const errorCode = response.data;
throw t(`auth:errors.${errorCode}`, {
defaultValue: t(`account:unenrollFactorError`),
});
throw t(
`auth.errors.${errorCode}` as never,
{
defaultValue: t(`account.unenrollFactorError` as never),
} as never,
);
}
});
toast.promise(promise, {
loading: t(`account:unenrollingFactor`),
success: t(`account:unenrollFactorSuccess`),
loading: t(`account.unenrollingFactor` as never),
success: t(`account.unenrollFactorSuccess` as never),
error: (error: string) => {
return error;
},
@@ -171,17 +173,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
@@ -189,7 +191,7 @@ function ConfirmUnenrollFactorModal(
disabled={unEnroll.isPending}
onClick={() => onUnenrollRequested(props.factorId)}
>
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
<Trans i18nKey={'account.unenrollFactorModalButtonLabel'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -212,13 +214,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 />
@@ -250,18 +252,20 @@ function FactorsTable({
<td className={'flex justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={'ghost'}
size={'icon'}
onClick={() => setUnenrolling(factor.id)}
>
<X className={'h-4'} />
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
variant={'ghost'}
size={'icon'}
onClick={() => setUnenrolling(factor.id)}
>
<X className={'h-4'} />
</Button>
}
/>
<TooltipContent>
<Trans i18nKey={'account:unenrollTooltip'} />
<Trans i18nKey={'account.unenrollTooltip'} />
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -3,12 +3,11 @@
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 } from 'lucide-react';
import { ArrowLeftIcon, TriangleAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import * as z from 'zod';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
@@ -31,6 +30,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
@@ -45,41 +45,43 @@ import { Trans } from '@kit/ui/trans';
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';
export function MultiFactorAuthSetupDialog(props: { userId: string }) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations();
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
const onEnrollSuccess = useCallback(() => {
setIsOpen(false);
setIsPending(false);
setOpen(false);
return toast.success(t(`account:multiFactorSetupSuccess`));
}, [t]);
return toast.success(t(`account.multiFactorSetupSuccess` as never));
}, [t, setIsPending, setOpen]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
<Trans i18nKey={'account:setupMfaButtonLabel'} />
</Button>
</DialogTrigger>
<Dialog {...dialogProps}>
<DialogTrigger
render={
<Button>
<Trans i18nKey={'account.setupMfaButtonLabel'} />
</Button>
}
/>
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:setupMfaButtonLabel'} />
<Trans i18nKey={'account.setupMfaButtonLabel'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
<Trans i18nKey={'account.multiFactorAuthDescription'} />
</DialogDescription>
</DialogHeader>
<div>
<MultiFactorAuthSetupForm
userId={props.userId}
onCancel={() => setIsOpen(false)}
isPending={isPending}
setIsPending={setIsPending}
onCancel={() => setOpen(false)}
onEnrolled={onEnrollSuccess}
/>
</div>
@@ -92,10 +94,14 @@ function MultiFactorAuthSetupForm({
onEnrolled,
onCancel,
userId,
isPending,
setIsPending,
}: React.PropsWithChildren<{
userId: string;
onCancel: () => void;
onEnrolled: () => void;
isPending: boolean;
setIsPending: (pending: boolean) => void;
}>) {
const verifyCodeMutation = useVerifyCodeMutation(userId);
@@ -112,10 +118,7 @@ function MultiFactorAuthSetupForm({
},
});
const [state, setState] = useState({
loading: false,
error: '',
});
const [error, setError] = useState('');
const factorId = useWatch({
name: 'factorId',
@@ -130,10 +133,8 @@ function MultiFactorAuthSetupForm({
verificationCode: string;
factorId: string;
}) => {
setState({
loading: true,
error: '',
});
setIsPending(true);
setError('');
try {
await verifyCodeMutation.mutateAsync({
@@ -143,25 +144,18 @@ function MultiFactorAuthSetupForm({
await refreshAuthSession();
setState({
loading: false,
error: '',
});
onEnrolled();
} catch (error) {
const message = (error as Error).message || `Unknown error`;
setState({
loading: false,
error: message,
});
setIsPending(false);
setError(message);
}
},
[onEnrolled, verifyCodeMutation],
[onEnrolled, verifyCodeMutation, setIsPending],
);
if (state.error) {
if (error) {
return <ErrorAlert />;
}
@@ -170,6 +164,7 @@ function MultiFactorAuthSetupForm({
<div className={'flex justify-center'}>
<FactorQrCode
userId={userId}
isPending={isPending}
onCancel={onCancel}
onSetFactorId={(factorId) =>
verificationCodeForm.setValue('factorId', factorId)
@@ -210,7 +205,7 @@ function MultiFactorAuthSetupForm({
<FormDescription>
<Trans
i18nKey={'account:verifyActivationCodeDescription'}
i18nKey={'account.verifyActivationCodeDescription'}
/>
</FormDescription>
@@ -222,20 +217,25 @@ function MultiFactorAuthSetupForm({
/>
<div className={'flex justify-end space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={onCancel}>
<Trans i18nKey={'common:cancel'} />
<Button
type={'button'}
variant={'ghost'}
disabled={isPending}
onClick={onCancel}
>
<Trans i18nKey={'common.cancel'} />
</Button>
<Button
disabled={
!verificationCodeForm.formState.isValid || state.loading
!verificationCodeForm.formState.isValid || isPending
}
type={'submit'}
>
{state.loading ? (
<Trans i18nKey={'account:verifyingCode'} />
{isPending ? (
<Trans i18nKey={'account.verifyingCode'} />
) : (
<Trans i18nKey={'account:enableMfaFactor'} />
<Trans i18nKey={'account.enableMfaFactor'} />
)}
</Button>
</div>
@@ -251,13 +251,15 @@ function FactorQrCode({
onSetFactorId,
onCancel,
userId,
isPending,
}: React.PropsWithChildren<{
userId: string;
isPending: boolean;
onCancel: () => void;
onSetFactorId: (factorId: string) => void;
}>) {
const enrollFactorMutation = useEnrollFactor(userId);
const { t } = useTranslation();
const t = useTranslations();
const [error, setError] = useState<string>('');
const form = useForm({
@@ -279,16 +281,16 @@ function FactorQrCode({
return (
<div className={'flex w-full flex-col space-y-2'}>
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert 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>
@@ -296,7 +298,7 @@ function FactorQrCode({
<div>
<Button variant={'outline'} onClick={onCancel}>
<ArrowLeftIcon className={'h-4'} />
<Trans i18nKey={`common:retry`} />
<Trans i18nKey={`common.retry`} />
</Button>
</div>
</div>
@@ -306,6 +308,7 @@ function FactorQrCode({
if (!factorName) {
return (
<FactorNameForm
isPending={isPending}
onCancel={onCancel}
onSetFactorName={async (name) => {
const response = await enrollFactorMutation.mutateAsync(name);
@@ -336,7 +339,7 @@ function FactorQrCode({
>
<p>
<span className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'account:multiFactorModalHeading'} />
<Trans i18nKey={'account.multiFactorModalHeading'} />
</span>
</p>
@@ -349,6 +352,7 @@ function FactorQrCode({
function FactorNameForm(
props: React.PropsWithChildren<{
isPending: boolean;
onSetFactorName: (name: string) => void;
onCancel: () => void;
}>,
@@ -379,7 +383,7 @@ function FactorNameForm(
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:factorNameLabel'} />
<Trans i18nKey={'account.factorNameLabel'} />
</FormLabel>
<FormControl>
@@ -387,7 +391,7 @@ function FactorNameForm(
</FormControl>
<FormDescription>
<Trans i18nKey={'account:factorNameHint'} />
<Trans i18nKey={'account.factorNameHint'} />
</FormDescription>
<FormMessage />
@@ -397,12 +401,17 @@ function FactorNameForm(
/>
<div className={'flex justify-end space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={props.onCancel}>
<Trans i18nKey={'common:cancel'} />
<Button
type={'button'}
variant={'ghost'}
disabled={props.isPending}
onClick={props.onCancel}
>
<Trans i18nKey={'common.cancel'} />
</Button>
<Button type={'submit'}>
<Trans i18nKey={'account:factorNameSubmitLabel'} />
<Button type={'submit'} disabled={props.isPending}>
<Trans i18nKey={'account.factorNameSubmitLabel'} />
</Button>
</div>
</div>
@@ -501,14 +510,14 @@ function useVerifyCodeMutation(userId: string) {
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:multiFactorSetupErrorHeading'} />
<Trans i18nKey={'account.multiFactorSetupErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:multiFactorSetupErrorDescription'} />
<Trans i18nKey={'account.multiFactorSetupErrorDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -5,10 +5,9 @@ import { useState } from 'react';
import type { PostgrestError } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check, Lock, XIcon } from 'lucide-react';
import { Check, Lock, TriangleAlert, XIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
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';
@@ -41,7 +40,7 @@ export const UpdatePasswordForm = ({
callbackPath: string;
onSuccess?: () => void;
}) => {
const { t } = useTranslation('account');
const t = useTranslations('account');
const updateUserMutation = useUpdateUser();
const [needsReauthentication, setNeedsReauthentication] = useState(false);
@@ -131,7 +130,7 @@ export const UpdatePasswordForm = ({
autoComplete={'new-password'}
required
type={'password'}
placeholder={t('account:newPassword')}
placeholder={t('newPassword')}
{...field}
/>
</InputGroup>
@@ -160,14 +159,14 @@ export const UpdatePasswordForm = ({
}
required
type={'password'}
placeholder={t('account:repeatPassword')}
placeholder={t('repeatPassword')}
{...field}
/>
</InputGroup>
</FormControl>
<FormDescription>
<Trans i18nKey={'account:repeatPasswordDescription'} />
<Trans i18nKey={'account.repeatPasswordDescription'} />
</FormDescription>
<FormMessage />
@@ -179,10 +178,11 @@ 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 } = useTranslation();
const t = useTranslations();
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'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:needsReauthentication'} />
<Trans i18nKey={'account.needsReauthentication'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:needsReauthenticationDescription'} />
<Trans i18nKey={'account.needsReauthenticationDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -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 } = useTranslation('account');
const t = useTranslations('account');
const form = useForm({
resolver: zodResolver(AccountDetailsSchema),
@@ -79,7 +79,7 @@ export function UpdateAccountDetailsForm({
<InputGroupInput
data-test={'account-display-name'}
minLength={2}
placeholder={t('account:name')}
placeholder={t('name')}
maxLength={100}
{...field}
/>
@@ -92,8 +92,8 @@ export function UpdateAccountDetailsForm({
/>
<div>
<Button disabled={updateAccountMutation.isPending}>
<Trans i18nKey={'account:updateProfileSubmitLabel'} />
<Button type="submit" disabled={updateAccountMutation.isPending}>
<Trans i18nKey={'account.updateProfileSubmitLabel'} />
</Button>
</div>
</form>

View File

@@ -4,7 +4,7 @@ import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
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 } = useTranslation('account');
const t = useTranslations('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>

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const AccountDetailsSchema = z.object({
displayName: z.string().min(2).max(100),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const DeletePersonalAccountSchema = z.object({
otp: z.string().min(6),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as 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`,
});

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const UpdateEmailSchema = {
withTranslation: (errorMessage: string) => {

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const PasswordUpdateSchema = {
withTranslation: (errorMessage: string) => {

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { authActionClient } from '@kit/next/safe-action';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
@@ -23,25 +23,17 @@ export async function refreshAuthSession() {
return {};
}
export const deletePersonalAccountAction = enhanceAction(
async (formData: FormData, user) => {
export const deletePersonalAccountAction = authActionClient
.inputSchema(DeletePersonalAccountSchema)
.action(async ({ parsedInput: data, ctx: { 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 = formData.get('otp') as string;
const otp = data.otp;
if (!otp) {
throw new Error('OTP is required');
@@ -101,6 +93,4 @@ export const deletePersonalAccountAction = enhanceAction(
// redirect to the home page
redirect('/');
},
{},
);
});

View File

@@ -1,8 +1,7 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import * as z from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
@@ -133,12 +132,12 @@ class DeletePersonalAccountService {
.object({
productName: z
.string({
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
fromEmail: z
.string({
required_error: 'EMAIL_SENDER is required',
error: 'EMAIL_SENDER is required',
})
.min(1),
})

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,45 +1,41 @@
{
"name": "@kit/admin",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"devDependencies": {
"@hookform/resolvers": "^5.2.2",
"@kit/eslint-config": "workspace:*",
"@kit/next": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "^8.21.3",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
},
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@makerkit/data-loader-supabase-core": "catalog:",
"@makerkit/data-loader-supabase-nextjs": "catalog:",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -60,9 +60,8 @@ async function PersonalAccountPage(props: { account: Account }) {
userResult.data.user.banned_until !== 'none';
return (
<>
<PageBody className="gap-y-4">
<PageHeader
className="border-b"
description={
<AppBreadcrumbs
values={{
@@ -123,41 +122,39 @@ async function PersonalAccountPage(props: { account: Account }) {
</div>
</PageHeader>
<PageBody className={'space-y-6 py-4'}>
<div className={'flex items-center justify-between'}>
<div className={'flex items-center gap-x-4'}>
<div className={'flex items-center gap-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<div className={'flex items-center justify-between'}>
<div className={'flex items-center gap-x-4'}>
<div className={'flex items-center gap-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<Badge variant={'outline'}>Personal Account</Badge>
<Badge variant={'outline'}>Personal Account</Badge>
<If condition={isBanned}>
<Badge variant={'destructive'}>Banned</Badge>
</If>
<If condition={isBanned}>
<Badge variant={'destructive'}>Banned</Badge>
</If>
</div>
</div>
<div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div className={'divider-divider-x flex flex-col gap-y-2.5'}>
<Heading level={6}>Teams</Heading>
<div className={'rounded-lg border p-2'}>
<AdminMembershipsTable memberships={memberships} />
</div>
</div>
<div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div className={'divider-divider-x flex flex-col gap-y-2.5'}>
<Heading level={6}>Teams</Heading>
<div className={'rounded-lg border p-2'}>
<AdminMembershipsTable memberships={memberships} />
</div>
</div>
</div>
</PageBody>
</>
</div>
</PageBody>
);
}
@@ -167,9 +164,8 @@ async function TeamAccountPage(props: {
const members = await getMembers(props.account.slug ?? '');
return (
<>
<PageBody className={'gap-y-6'}>
<PageHeader
className="border-b"
description={
<AppBreadcrumbs
values={{
@@ -191,39 +187,37 @@ async function TeamAccountPage(props: {
</AdminDeleteAccountDialog>
</PageHeader>
<PageBody className={'space-y-6 py-4'}>
<div className={'flex justify-between'}>
<div className={'flex items-center gap-x-4'}>
<div className={'flex items-center gap-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<div className={'flex justify-between'}>
<div className={'flex items-center gap-x-4'}>
<div className={'flex items-center gap-x-2.5'}>
<ProfileAvatar
pictureUrl={props.account.picture_url}
displayName={props.account.name}
/>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<Badge variant={'outline'}>Team Account</Badge>
<span className={'text-sm font-semibold capitalize'}>
{props.account.name}
</span>
</div>
<Badge variant={'outline'}>Team Account</Badge>
</div>
</div>
<div>
<div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div>
<div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div className={'flex flex-col gap-y-2.5'}>
<Heading level={6}>Team Members</Heading>
<div className={'flex flex-col gap-y-2.5'}>
<Heading level={6}>Team Members</Heading>
<div className={'rounded-lg border p-2'}>
<AdminMembersTable members={members} />
</div>
<div className={'rounded-lg border p-2'}>
<AdminMembersTable members={members} />
</div>
</div>
</div>
</PageBody>
</>
</div>
</PageBody>
);
}

View File

@@ -1,5 +1,7 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
@@ -7,7 +9,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 { z } from 'zod';
import * as z from 'zod';
import { Tables } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
@@ -21,7 +23,6 @@ import {
} from '@kit/ui/dropdown-menu';
import { DataTable } from '@kit/ui/enhanced-data-table';
import { Form, FormControl, FormField, FormItem } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
Select,
@@ -77,7 +78,7 @@ export function AdminAccountsTable(
}
function AccountsTableFilters(props: {
filters: z.infer<typeof FiltersSchema>;
filters: z.output<typeof FiltersSchema>;
}) {
const form = useForm({
resolver: zodResolver(FiltersSchema),
@@ -92,7 +93,7 @@ function AccountsTableFilters(props: {
const router = useRouter();
const pathName = usePathname();
const onSubmit = ({ type, query }: z.infer<typeof FiltersSchema>) => {
const onSubmit = ({ type, query }: z.output<typeof FiltersSchema>) => {
const params = new URLSearchParams({
account_type: type,
query: query ?? '',
@@ -105,6 +106,12 @@ function AccountsTableFilters(props: {
const type = useWatch({ control: form.control, name: 'type' });
const options = {
all: 'All Accounts',
team: 'Team',
personal: 'Personal',
};
return (
<Form {...form}>
<form
@@ -116,7 +123,7 @@ function AccountsTableFilters(props: {
onValueChange={(value) => {
form.setValue(
'type',
value as z.infer<typeof FiltersSchema>['type'],
value as z.output<typeof FiltersSchema>['type'],
{
shouldValidate: true,
shouldDirty: true,
@@ -128,16 +135,20 @@ function AccountsTableFilters(props: {
}}
>
<SelectTrigger>
<SelectValue placeholder={'Account Type'} />
<SelectValue placeholder={'Account Type'}>
{(value: keyof typeof options) => options[value]}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Account Type</SelectLabel>
<SelectItem value={'all'}>All accounts</SelectItem>
<SelectItem value={'team'}>Team</SelectItem>
<SelectItem value={'personal'}>Personal</SelectItem>
{Object.entries(options).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
@@ -157,6 +168,8 @@ function AccountsTableFilters(props: {
</FormItem>
)}
/>
<button type="submit" hidden />
</form>
</Form>
);
@@ -194,75 +207,143 @@ function getColumns(): ColumnDef<Account>[] {
{
id: 'created_at',
header: 'Created At',
accessorKey: 'created_at',
cell: ({ row }) => {
return new Date(row.original.created_at!).toLocaleDateString(
undefined,
{
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
);
},
},
{
id: 'updated_at',
header: 'Updated At',
accessorKey: 'updated_at',
cell: ({ row }) => {
return row.original.updated_at
? new Date(row.original.updated_at).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '-';
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => {
const isPersonalAccount = row.original.is_personal_account;
const userId = row.original.id;
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'outline'} size={'icon'}>
<EllipsisVertical className={'h-4'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={'end'}>
<DropdownMenuGroup>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem>
<Link
className={'h-full w-full'}
href={`/admin/accounts/${userId}`}
>
View
</Link>
</DropdownMenuItem>
<If condition={isPersonalAccount}>
<AdminResetPasswordDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Send Reset Password link
</DropdownMenuItem>
</AdminResetPasswordDialog>
<AdminImpersonateUserDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Impersonate User
</DropdownMenuItem>
</AdminImpersonateUserDialog>
<AdminDeleteUserDialog userId={userId}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Delete Personal Account
</DropdownMenuItem>
</AdminDeleteUserDialog>
</If>
<If condition={!isPersonalAccount}>
<AdminDeleteAccountDialog accountId={row.original.id}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Delete Team Account
</DropdownMenuItem>
</AdminDeleteAccountDialog>
</If>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
cell: ({ row }) => <ActionsCell account={row.original} />,
},
];
}
type ActiveDialog =
| 'reset-password'
| 'impersonate'
| 'delete-user'
| 'delete-account'
| null;
function ActionsCell({ account }: { account: Account }) {
const [activeDialog, setActiveDialog] = useState<ActiveDialog>(null);
const isPersonalAccount = account.is_personal_account;
return (
<div className={'flex justify-end'}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant={'outline'} size={'icon'}>
<EllipsisVertical className={'h-4'} />
</Button>
}
/>
<DropdownMenuContent className="min-w-52">
<DropdownMenuGroup>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
render={
<Link
className={'h-full w-full'}
href={`/admin/accounts/${account.id}`}
>
View
</Link>
}
/>
{isPersonalAccount && (
<>
<DropdownMenuItem
onClick={() => setActiveDialog('reset-password')}
>
Send Reset Password link
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setActiveDialog('impersonate')}
>
Impersonate User
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setActiveDialog('delete-user')}
>
Delete Personal Account
</DropdownMenuItem>
</>
)}
{!isPersonalAccount && (
<DropdownMenuItem
variant="destructive"
onClick={() => setActiveDialog('delete-account')}
>
Delete Team Account
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{isPersonalAccount && (
<>
<AdminResetPasswordDialog
userId={account.id}
open={activeDialog === 'reset-password'}
onOpenChange={(open) => !open && setActiveDialog(null)}
/>
<AdminImpersonateUserDialog
userId={account.id}
open={activeDialog === 'impersonate'}
onOpenChange={(open) => !open && setActiveDialog(null)}
/>
<AdminDeleteUserDialog
userId={account.id}
open={activeDialog === 'delete-user'}
onOpenChange={(open) => !open && setActiveDialog(null)}
/>
</>
)}
{!isPersonalAccount && (
<AdminDeleteAccountDialog
accountId={account.id}
open={activeDialog === 'delete-account'}
onOpenChange={(open) => !open && setActiveDialog(null)}
/>
)}
</div>
);
}

View File

@@ -1,8 +1,7 @@
'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';
@@ -26,6 +25,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
@@ -37,11 +37,14 @@ export function AdminBanUserDialog(
userId: string;
}>,
) {
const [open, setOpen] = useState(false);
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogContent>
<AlertDialogHeader>
@@ -53,15 +56,31 @@ export function AdminBanUserDialog(
</AlertDialogDescription>
</AlertDialogHeader>
<BanUserForm userId={props.userId} onSuccess={() => setOpen(false)} />
<BanUserForm
userId={props.userId}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
</AlertDialogContent>
</AlertDialog>
);
}
function BanUserForm(props: { userId: string; onSuccess: () => void }) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
function BanUserForm(props: {
userId: string;
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => void;
}) {
const { execute, hasErrored } = useAction(banUserAction, {
onExecute: () => props.setIsPending(true),
onSuccess: () => props.onSuccess(),
onSettled: () => props.setIsPending(false),
});
const form = useForm({
resolver: zodResolver(BanUserSchema),
@@ -76,18 +95,9 @@ 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) => {
startTransition(async () => {
try {
await banUserAction(data);
props.onSuccess();
} catch {
setError(true);
}
});
})}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={error}>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
@@ -125,10 +135,16 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) {
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={props.isPending}>
Cancel
</AlertDialogCancel>
<Button disabled={pending} type={'submit'} variant={'destructive'}>
{pending ? 'Banning...' : 'Ban User'}
<Button
disabled={props.isPending}
type={'submit'}
variant={'destructive'}
>
{props.isPending ? 'Banning...' : 'Ban User'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,8 +1,7 @@
'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';
@@ -27,6 +26,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner';
@@ -38,9 +38,7 @@ 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 { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
const form = useForm({
resolver: zodResolver(CreateUserSchema),
@@ -52,28 +50,25 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
mode: 'onChange',
});
const onSubmit = (data: CreateUserSchemaType) => {
startTransition(async () => {
try {
const result = await createUserAction(data);
const { execute, result } = useAction(createUserAction, {
onExecute: () => setIsPending(true),
onSuccess: () => {
toast.success('User created successfully');
form.reset();
setIsPending(false);
setOpen(false);
},
onSettled: () => setIsPending(false),
});
if (result.success) {
toast.success('User creates successfully');
form.reset();
setOpen(false);
}
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error');
}
});
};
const error = result.serverError;
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogContent>
<AlertDialogHeader>
@@ -88,7 +83,9 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
<form
data-test={'admin-create-user-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit((data: CreateUserSchemaType) =>
execute(data),
)}
>
<If condition={!!error}>
<Alert variant={'destructive'}>
@@ -164,10 +161,10 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<Button disabled={pending} type={'submit'}>
{pending ? 'Creating...' : 'Create User'}
<Button disabled={isPending} type={'submit'}>
{isPending ? 'Creating...' : 'Create User'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,8 +1,7 @@
'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';
@@ -35,22 +34,15 @@ import { DeleteAccountSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminDeleteAccountDialog(
props: React.PropsWithChildren<{
accountId: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>,
) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(DeleteAccountSchema),
defaultValues: {
accountId: props.accountId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog open={props.open} onOpenChange={props.onOpenChange}>
<If condition={props.children}>
<AlertDialogTrigger render={props.children as React.ReactElement} />
</If>
<AlertDialogContent>
<AlertDialogHeader>
@@ -63,73 +55,75 @@ export function AdminDeleteAccountDialog(
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-form={'admin-delete-account-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await deleteAccountAction(data);
setError(false);
} catch {
setError(true);
}
});
})}
>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the account. Please check the
server logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
pattern={'CONFIRM'}
required
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to do this? This action cannot be
undone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
disabled={pending}
type={'submit'}
variant={'destructive'}
>
{pending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>
</Form>
<DeleteAccountForm accountId={props.accountId} />
</AlertDialogContent>
</AlertDialog>
);
}
function DeleteAccountForm(props: { accountId: string }) {
const { execute, isPending, hasErrored } = useAction(deleteAccountAction);
const form = useForm({
resolver: zodResolver(DeleteAccountSchema),
defaultValues: {
accountId: props.accountId,
confirmation: '',
},
});
return (
<Form {...form}>
<form
data-test={'admin-delete-account-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the account. Please check the server
logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
pattern={'CONFIRM'}
required
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to do this? This action cannot be undone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button disabled={isPending} type={'submit'} variant={'destructive'}>
{isPending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}

View File

@@ -1,10 +1,7 @@
'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';
@@ -37,22 +34,15 @@ import { DeleteUserSchema } from '../lib/server/schema/admin-actions.schema';
export function AdminDeleteUserDialog(
props: React.PropsWithChildren<{
userId: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>,
) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({
resolver: zodResolver(DeleteUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog open={props.open} onOpenChange={props.onOpenChange}>
<If condition={props.children}>
<AlertDialogTrigger render={props.children as React.ReactElement} />
</If>
<AlertDialogContent>
<AlertDialogHeader>
@@ -65,78 +55,75 @@ export function AdminDeleteUserDialog(
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-test={'admin-delete-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await deleteUserAction(data);
setError(false);
} catch {
if (isRedirectError(error)) {
// Do nothing
} else {
setError(true);
}
}
});
})}
>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the user. Please check the server
logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
required
pattern={'CONFIRM'}
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to do this? This action cannot be
undone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
disabled={pending}
type={'submit'}
variant={'destructive'}
>
{pending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>
</Form>
<DeleteUserForm userId={props.userId} />
</AlertDialogContent>
</AlertDialog>
);
}
function DeleteUserForm(props: { userId: string }) {
const { execute, isPending, hasErrored } = useAction(deleteUserAction);
const form = useForm({
resolver: zodResolver(DeleteUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
return (
<Form {...form}>
<form
data-test={'admin-delete-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the user. Please check the server logs
to see what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
required
pattern={'CONFIRM'}
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to do this? This action cannot be undone.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button disabled={isPending} type={'submit'} variant={'destructive'}>
{isPending ? 'Deleting...' : 'Delete'}
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}

View File

@@ -1,9 +1,10 @@
'use client';
import { useState, useTransition } from 'react';
import { useState } 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';
@@ -35,40 +36,34 @@ import { LoadingOverlay } from '@kit/ui/loading-overlay';
import { impersonateUserAction } from '../lib/server/admin-server-actions';
import { ImpersonateUserSchema } from '../lib/server/schema/admin-actions.schema';
type Tokens = {
accessToken: string;
refreshToken: string;
};
export function AdminImpersonateUserDialog(
props: React.PropsWithChildren<{
userId: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>,
) {
const form = useForm({
resolver: zodResolver(ImpersonateUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
const [tokens, setTokens] = useState<{
accessToken: string;
refreshToken: string;
}>();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<boolean | null>(null);
const [tokens, setTokens] = useState<Tokens>();
if (tokens) {
return (
<>
<ImpersonateUserAuthSetter tokens={tokens} />
<LoadingOverlay>Setting up your session...</LoadingOverlay>
</>
);
return <ImpersonateUserAuthSetter tokens={tokens} />;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog
open={props.open}
onOpenChange={(open) => {
props.onOpenChange?.(open);
}}
>
<If condition={props.children}>
<AlertDialogTrigger render={props.children as React.ReactElement} />
</If>
<AlertDialogContent>
<AlertDialogHeader>
@@ -87,73 +82,88 @@ export function AdminImpersonateUserDialog(
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
data-test={'admin-impersonate-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
const result = await impersonateUserAction(data);
setTokens(result);
} catch {
setError(true);
}
});
})}
>
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to impersonate user. Please check the logs to
understand what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
required
pattern={'CONFIRM'}
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to impersonate this user?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button disabled={isPending} type={'submit'}>
{isPending ? 'Impersonating...' : 'Impersonate User'}
</Button>
</AlertDialogFooter>
</form>
</Form>
<AdminImpersonateUserForm userId={props.userId} onSuccess={setTokens} />
</AlertDialogContent>
</AlertDialog>
);
}
function AdminImpersonateUserForm(props: {
userId: string;
onSuccess: (data: Tokens) => void;
}) {
const form = useForm({
resolver: zodResolver(ImpersonateUserSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
const { execute, isPending, hasErrored } = useAction(impersonateUserAction, {
onSuccess: ({ data }) => {
if (data) {
props.onSuccess(data);
}
},
});
return (
<Form {...form}>
<form
data-test={'admin-impersonate-user-form'}
className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to impersonate user. Please check the logs to understand
what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField
name={'confirmation'}
render={({ field }) => (
<FormItem>
<FormLabel>
Type <b>CONFIRM</b> to confirm
</FormLabel>
<FormControl>
<Input
required
pattern={'CONFIRM'}
placeholder={'Type CONFIRM to confirm'}
{...field}
/>
</FormControl>
<FormDescription>
Are you sure you want to impersonate this user?
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button disabled={isPending} type={'submit'}>
{isPending ? 'Impersonating...' : 'Impersonate User'}
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}
function ImpersonateUserAuthSetter({
tokens,
}: React.PropsWithChildren<{

View File

@@ -1,8 +1,7 @@
'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';
@@ -26,6 +25,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
@@ -37,11 +37,14 @@ export function AdminReactivateUserDialog(
userId: string;
}>,
) {
const [open, setOpen] = useState(false);
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogTrigger render={props.children as React.ReactElement} />
<AlertDialogContent>
<AlertDialogHeader>
@@ -54,16 +57,29 @@ export function AdminReactivateUserDialog(
<ReactivateUserForm
userId={props.userId}
onSuccess={() => setOpen(false)}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
</AlertDialogContent>
</AlertDialog>
);
}
function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
function ReactivateUserForm(props: {
userId: string;
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => void;
}) {
const { execute, hasErrored } = useAction(reactivateUserAction, {
onExecute: () => props.setIsPending(true),
onSuccess: () => props.onSuccess(),
onSettled: () => props.setIsPending(false),
});
const form = useForm({
resolver: zodResolver(ReactivateUserSchema),
@@ -78,18 +94,9 @@ 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) => {
startTransition(async () => {
try {
await reactivateUserAction(data);
props.onSuccess();
} catch {
setError(true);
}
});
})}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<If condition={error}>
<If condition={hasErrored}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
@@ -127,10 +134,12 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) {
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={props.isPending}>
Cancel
</AlertDialogCancel>
<Button disabled={pending} type={'submit'}>
{pending ? 'Reactivating...' : 'Reactivate User'}
<Button disabled={props.isPending} type={'submit'}>
{props.isPending ? 'Reactivating...' : 'Reactivate User'}
</Button>
</AlertDialogFooter>
</form>

View File

@@ -1,10 +1,9 @@
'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 { z } from 'zod';
import * as z from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -34,50 +33,21 @@ import { toast } from '@kit/ui/sonner';
import { resetPasswordAction } from '../lib/server/admin-server-actions';
const FormSchema = z.object({
userId: z.string().uuid(),
userId: z.uuid(),
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
});
export function AdminResetPasswordDialog(
props: React.PropsWithChildren<{
userId: string;
}>,
) {
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: {
userId: props.userId,
confirmation: '',
},
});
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');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
toast.error('We hit an error. Please read the logs.');
}
});
});
export function AdminResetPasswordDialog(props: {
userId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
children?: React.ReactNode;
}) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialog open={props.open} onOpenChange={props.onOpenChange}>
{props.children && (
<AlertDialogTrigger render={props.children as React.ReactElement} />
)}
<AlertDialogContent>
<AlertDialogHeader>
@@ -89,75 +59,102 @@ export function AdminResetPasswordDialog(
</AlertDialogHeader>
<div className="relative">
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
<FormField
control={form.control}
name="confirmation"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmation</FormLabel>
<FormDescription>
Type CONFIRM to execute this request.
</FormDescription>
<FormControl>
<Input
placeholder="CONFIRM"
{...field}
autoComplete="off"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<If condition={!!error}>
<Alert variant="destructive">
<AlertTitle>
We encountered an error while sending the email
</AlertTitle>
<AlertDescription>
Please check the server logs for more details.
</AlertDescription>
</Alert>
</If>
<If condition={success}>
<Alert>
<AlertTitle>
Password reset email sent successfully
</AlertTitle>
<AlertDescription>
The password reset email has been sent to the user.
</AlertDescription>
</Alert>
</If>
<input type="hidden" name="userId" value={props.userId} />
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>
Cancel
</AlertDialogCancel>
<Button
type="submit"
disabled={isPending}
variant="destructive"
>
{isPending ? 'Sending...' : 'Send Reset Email'}
</Button>
</AlertDialogFooter>
</form>
</Form>
<AdminResetPasswordForm
userId={props.userId}
onSuccess={() => props.onOpenChange(false)}
/>
</div>
</AlertDialogContent>
</AlertDialog>
);
}
function AdminResetPasswordForm({
userId,
onSuccess,
}: {
userId: string;
onSuccess: () => void;
}) {
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: {
userId,
confirmation: '',
},
});
const { execute, isPending, hasErrored, hasSucceeded } = useAction(
resetPasswordAction,
{
onSuccess: () => {
toast.success('Password reset email successfully sent');
onSuccess();
},
onError: () => {
toast.error('We hit an error. Please read the logs.');
},
},
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => execute(data))}
className="space-y-4"
>
<FormField
control={form.control}
name="confirmation"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmation</FormLabel>
<FormDescription>
Type CONFIRM to execute this request.
</FormDescription>
<FormControl>
<Input placeholder="CONFIRM" {...field} autoComplete="off" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<If condition={hasErrored}>
<Alert variant="destructive">
<AlertTitle>
We encountered an error while sending the email
</AlertTitle>
<AlertDescription>
Please check the server logs for more details.
</AlertDescription>
</Alert>
</If>
<If condition={hasSucceeded}>
<Alert>
<AlertTitle>Password reset email sent successfully</AlertTitle>
<AlertDescription>
The password reset email has been sent to the user.
</AlertDescription>
</Alert>
</If>
<input type="hidden" name="userId" value={userId} />
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
<Button type="submit" disabled={isPending} variant="destructive">
{isPending ? 'Sending...' : 'Send Reset Email'}
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}

View File

@@ -3,7 +3,6 @@
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';
@@ -19,212 +18,168 @@ 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 { adminAction } from './utils/admin-action';
import { adminActionClient } from './utils/admin-action-client';
/**
* @name banUserAction
* @description Ban a user from the system.
*/
export const banUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const banUserAction = adminActionClient
.inputSchema(BanUserSchema)
.action(async ({ parsedInput: { 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`);
if (error) {
logger.error({ error }, `Error banning user`);
throw new Error('Error banning user');
}
return {
success: false,
};
}
revalidateAdmin();
revalidateAdmin();
logger.info({ userId }, `Super Admin has successfully banned user`);
},
{
schema: BanUserSchema,
},
),
);
logger.info({ userId }, `Super Admin has successfully banned user`);
});
/**
* @name reactivateUserAction
* @description Reactivate a user in the system.
*/
export const reactivateUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const reactivateUserAction = adminActionClient
.inputSchema(ReactivateUserSchema)
.action(async ({ parsedInput: { 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`);
if (error) {
logger.error({ error }, `Error reactivating user`);
throw new Error('Error reactivating user');
}
return {
success: false,
};
}
revalidateAdmin();
revalidateAdmin();
logger.info({ userId }, `Super Admin has successfully reactivated user`);
},
{
schema: ReactivateUserSchema,
},
),
);
logger.info({ userId }, `Super Admin has successfully reactivated user`);
});
/**
* @name impersonateUserAction
* @description Impersonate a user in the system.
*/
export const impersonateUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const impersonateUserAction = adminActionClient
.inputSchema(ImpersonateUserSchema)
.action(async ({ parsedInput: { 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);
},
{
schema: ImpersonateUserSchema,
},
),
);
return await service.impersonateUser(userId);
});
/**
* @name deleteUserAction
* @description Delete a user from the system.
*/
export const deleteUserAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const deleteUserAction = adminActionClient
.inputSchema(DeleteUserSchema)
.action(async ({ parsedInput: { 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`);
return redirect('/admin/accounts');
},
{
schema: DeleteUserSchema,
},
),
);
redirect('/admin/accounts');
});
/**
* @name deleteAccountAction
* @description Delete an account from the system.
*/
export const deleteAccountAction = adminAction(
enhanceAction(
async ({ accountId }) => {
const service = getAdminAccountsService();
const logger = await getLogger();
export const deleteAccountAction = adminActionClient
.inputSchema(DeleteAccountSchema)
.action(async ({ parsedInput: { 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`);
return redirect('/admin/accounts');
},
{
schema: DeleteAccountSchema,
},
),
);
redirect('/admin/accounts');
});
/**
* @name createUserAction
* @description Create a new user in the system.
*/
export const createUserAction = adminAction(
enhanceAction(
async ({ email, password, emailConfirm }) => {
const adminClient = getSupabaseServerAdminClient();
const logger = await getLogger();
export const createUserAction = adminActionClient
.inputSchema(CreateUserSchema)
.action(async ({ parsedInput: { 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,
};
},
{
schema: CreateUserSchema,
},
),
);
return {
success: true,
user: data.user,
};
});
/**
* @name resetPasswordAction
* @description Reset a user's password by sending a password reset email.
*/
export const resetPasswordAction = adminAction(
enhanceAction(
async ({ userId }) => {
const service = getAdminAuthService();
const logger = await getLogger();
export const resetPasswordAction = adminActionClient
.inputSchema(ResetPasswordSchema)
.action(async ({ parsedInput: { 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;
},
{
schema: ResetPasswordSchema,
},
),
);
return result;
});
function revalidateAdmin() {
revalidatePath(`/admin/accounts/[id]`, 'page');

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { cache } from 'react';
import { getSupabaseServerClient } from '@kit/supabase/server-client';

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
const ConfirmationSchema = z.object({
confirmation: z.custom<string>((value) => value === 'CONFIRM'),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as 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.infer<typeof CreateUserSchema>;
export type CreateUserSchemaType = z.output<typeof CreateUserSchema>;

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
/**
* Schema for resetting a user's password

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';

View File

@@ -1,8 +1,7 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import * as z from 'zod';
import { Database } from '@kit/supabase/database';

View File

@@ -0,0 +1,20 @@
import 'server-only';
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) {
throw new Error('Unauthorized');
}
return next({ ctx });
});

View File

@@ -4,7 +4,5 @@
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": [
"node_modules"
]
"exclude": ["node_modules"]
}

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,12 +1,13 @@
{
"name": "@kit/auth",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./sign-in": "./src/sign-in.ts",
@@ -19,33 +20,25 @@
"./resend-email-link": "./src/components/resend-auth-link-form.tsx",
"./oauth-provider-logo-image": "./src/components/oauth-provider-logo-image.tsx"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "^5.2.2",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@hookform/resolvers": "catalog:",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@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",
"sonner": "catalog:",
"zod": "catalog:"
},
"prettier": "@kit/prettier-config",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -1,4 +1,4 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { TriangleAlert } from 'lucide-react';
import {
WeakPasswordError,
@@ -33,23 +33,25 @@ export function AuthErrorAlert({
return <WeakPasswordErrorAlert reasons={error.reasons} />;
}
const DefaultError = <Trans i18nKey="auth:errors.default" />;
const errorCode = error instanceof Error ? error.message : error;
const DefaultError = <Trans i18nKey="auth.errors.default" />;
const errorCode =
error instanceof Error
? 'code' in error && typeof error.code === 'string'
? error.code
: error.message
: error;
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'w-4'} />
<TriangleAlert 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 />'}
components={{ DefaultError }}
/>
<Trans i18nKey={`auth.errors.${errorCode}`} defaults={DefaultError} />
</AlertDescription>
</Alert>
);
@@ -62,21 +64,21 @@ function WeakPasswordErrorAlert({
}) {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'w-4'} />
<TriangleAlert 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>

View File

@@ -1,7 +1,7 @@
'use client';
import { Mail } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
import {
InputGroup,
@@ -10,7 +10,7 @@ import {
} from '@kit/ui/input-group';
export function EmailInput(props: React.ComponentProps<'input'>) {
const { t } = useTranslation('auth');
const t = useTranslations('auth');
return (
<InputGroup className="dark:bg-background">

View File

@@ -7,7 +7,7 @@ import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { UserCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
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 } = useTranslation();
const t = useTranslations();
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>
<AlertDescription className={'text-xs'}>
<Trans
i18nKey="auth:existingAccountHint"
values={{ method: t(methodDescription) }}
i18nKey="auth.existingAccountHint"
values={{ methodName: t(methodDescription) }}
components={{
method: <span className="font-medium" />,
signInLink: (

View File

@@ -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" />,

View File

@@ -1,10 +1,10 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check, TriangleAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import * as 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 } = useTranslation();
const t = useTranslations();
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,17 +133,20 @@ export function MagicLinkAuthContainer({
<TermsAndConditionsFormField />
</If>
<Button disabled={signInWithOtpMutation.isPending || captchaLoading}>
<Button
type="submit"
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>
@@ -155,14 +158,14 @@ export function MagicLinkAuthContainer({
function SuccessAlert() {
return (
<Alert variant={'success'}>
<CheckIcon className={'h-4'} />
<Check className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'auth:sendLinkSuccess'} />
<Trans i18nKey={'auth.sendLinkSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'auth:sendLinkSuccessDescription'} />
<Trans i18nKey={'auth.sendLinkSuccessDescription'} />
</AlertDescription>
</Alert>
);
@@ -171,14 +174,14 @@ function SuccessAlert() {
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert 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>
);

View File

@@ -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 { z } from 'zod';
import * as 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'}>
<ExclamationTriangleIcon className={'h-5'} />
<TriangleAlert 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,6 +156,7 @@ export function MultiFactorChallengeContainer({
</div>
<Button
type="submit"
className="w-full"
data-test={'submit-mfa-button'}
disabled={
@@ -166,13 +167,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>
@@ -181,7 +182,7 @@ export function MultiFactorChallengeContainer({
!verifyMFAChallenge.isPending && !verifyMFAChallenge.isSuccess
}
>
<Trans i18nKey={'account:submitVerificationCode'} />
<Trans i18nKey={'account.submitVerificationCode'} />
</If>
</Button>
</div>
@@ -255,7 +256,7 @@ function FactorsListContainer({
<Spinner />
<div className={'text-sm'}>
<Trans i18nKey={'account:loadingFactors'} />
<Trans i18nKey={'account.loadingFactors'} />
</div>
</div>
);
@@ -265,14 +266,14 @@ function FactorsListContainer({
return (
<div className={'w-full'}>
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert 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>
@@ -285,7 +286,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>

View File

@@ -114,7 +114,7 @@ export const OauthProviders: React.FC<{
}}
>
<Trans
i18nKey={'auth:signInWithProvider'}
i18nKey={'auth.signInWithProvider'}
values={{
provider: getProviderName(provider),
}}

View File

@@ -4,7 +4,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import * as 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.infer<typeof EmailSchema>) => {
const handleSendOtp = async ({ email }: z.output<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>

View File

@@ -1,9 +1,9 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import * as 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 } = useTranslation('auth');
const t = useTranslations('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>

View File

@@ -27,7 +27,7 @@ export function PasswordSignInContainer({
const captchaLoading = !captcha.isReady;
const onSubmit = useCallback(
async (credentials: z.infer<typeof PasswordSignInSchema>) => {
async (credentials: z.output<typeof PasswordSignInSchema>) => {
try {
const data = await signInMutation.mutateAsync({
...credentials,

View File

@@ -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.infer<typeof PasswordSignInSchema>) => unknown;
onSubmit: (params: z.output<typeof PasswordSignInSchema>) => unknown;
captchaLoading: boolean;
loading: boolean;
redirecting: boolean;
}) {
const { t } = useTranslation('auth');
const t = useTranslations('auth');
const form = useForm({
resolver: zodResolver(PasswordSignInSchema),
@@ -94,15 +94,14 @@ export function PasswordSignInForm({
<div>
<Button
asChild
nativeButton={false}
render={<Link href={'/auth/password-reset'} />}
type={'button'}
size={'sm'}
variant={'link'}
className={'text-xs'}
>
<Link href={'/auth/password-reset'}>
<Trans i18nKey={'auth:passwordForgottenQuestion'} />
</Link>
<Trans i18nKey={'auth.passwordForgottenQuestion'} />
</Button>
</div>
</FormItem>
@@ -118,19 +117,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>
@@ -140,7 +139,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={

View File

@@ -1,6 +1,6 @@
'use client';
import { CheckCircledIcon } from '@radix-ui/react-icons';
import { CheckCircle } from 'lucide-react';
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'}>
<CheckCircledIcon className={'w-4'} />
<CheckCircle 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>
);

View File

@@ -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={

View File

@@ -3,7 +3,7 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import * as 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 disabled={resendLink.isPending || captchaLoading}>
<Button type="submit" 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
),
}}

View File

@@ -3,9 +3,9 @@
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { TriangleAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
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 } = useTranslation();
const t = useTranslations();
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 } = useTranslation('auth');
const t = useTranslations('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'}>
<ExclamationTriangleIcon className={'s-6'} />
<TriangleAlert 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>
);

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
import { PasswordSchema } from './password.schema';

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
/**
* Password requirements
@@ -36,13 +36,11 @@ export function refineRepeatPassword(
) {
if (data.password !== data.repeatPassword) {
ctx.addIssue({
message: 'auth:errors.passwordsDoNotMatch',
message: 'auth.errors.passwordsDoNotMatch',
path: ['repeatPassword'],
code: 'custom',
});
}
return true;
}
function validatePassword(password: string, ctx: z.RefinementCtx) {
@@ -52,7 +50,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) {
if (specialCharsCount < 1) {
ctx.addIssue({
message: 'auth:errors.minPasswordSpecialChars',
message: 'auth.errors.minPasswordSpecialChars',
code: 'custom',
});
}
@@ -63,7 +61,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) {
if (numbersCount < 1) {
ctx.addIssue({
message: 'auth:errors.minPasswordNumbers',
message: 'auth.errors.minPasswordNumbers',
code: 'custom',
});
}
@@ -72,11 +70,9 @@ function validatePassword(password: string, ctx: z.RefinementCtx) {
if (requirements.uppercase) {
if (!/[A-Z]/.test(password)) {
ctx.addIssue({
message: 'auth:errors.uppercasePassword',
message: 'auth.errors.uppercasePassword',
code: 'custom',
});
}
}
return true;
}

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,33 +1,7 @@
{
"name": "@kit/notifications",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"exports": {
"./api": "./src/server/api.ts",
"./components": "./src/components/index.ts",
"./hooks": "./src/hooks/index.ts"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-i18next": "catalog:"
},
"prettier": "@kit/prettier-config",
"private": true,
"typesVersions": {
"*": {
"*": [
@@ -35,7 +9,28 @@
]
}
},
"exports": {
"./api": "./src/server/api.ts",
"./components": "./src/components/index.ts",
"./hooks": "./src/hooks/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@types/node": "catalog:"
},
"devDependencies": {
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next-intl": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
}
}

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Bell, CircleAlert, Info, TriangleAlert, XIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useLocale, useTranslations } from 'next-intl';
import { Button } from '@kit/ui/button';
import { If } from '@kit/ui/if';
@@ -19,7 +19,8 @@ export function NotificationsPopover(params: {
accountIds: string[];
onClick?: (notification: Notification) => void;
}) {
const { i18n, t } = useTranslation();
const t = useTranslations();
const locale = useLocale();
const [open, setOpen] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
@@ -53,7 +54,7 @@ export function NotificationsPopover(params: {
(new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
);
const formatter = new Intl.RelativeTimeFormat(i18n.language, {
const formatter = new Intl.RelativeTimeFormat(locale, {
numeric: 'auto',
});
@@ -61,7 +62,7 @@ export function NotificationsPopover(params: {
time = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60));
if (time < 5) {
return t('common:justNow');
return t('common.justNow');
}
if (time < 60) {
@@ -110,46 +111,39 @@ export function NotificationsPopover(params: {
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button className={'relative h-9 w-9'} variant={'ghost'}>
<Bell className={'min-h-4 min-w-4'} />
<PopoverTrigger
render={<Button size="icon-lg" variant="ghost" className="relative" />}
>
<Bell className={'size-4 min-h-3 min-w-3'} />
<span
className={cn(
`fade-in animate-in zoom-in absolute top-1 right-1 mt-0 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-500 text-[0.65rem] text-white`,
{
hidden: !notifications.length,
},
)}
>
{notifications.length}
</span>
</Button>
<span
className={cn(
`fade-in animate-in zoom-in absolute top-1 right-1 mt-0 flex h-3 w-3 items-center justify-center rounded-full bg-red-500 text-[0.6rem] text-white`,
{
hidden: !notifications.length,
},
)}
>
{notifications.length}
</span>
</PopoverTrigger>
<PopoverContent
className={'flex w-full max-w-96 flex-col p-0 lg:min-w-64'}
className={'flex w-full max-w-96 flex-col gap-0 lg:min-w-64'}
align={'start'}
collisionPadding={20}
sideOffset={10}
>
<div className={'flex items-center px-3 py-2 text-sm font-semibold'}>
{t('common:notifications')}
<div className={'flex items-center text-sm font-semibold'}>
{t('common.notifications')}
</div>
<Separator />
<If condition={!notifications.length}>
<div className={'px-3 py-2 text-sm'}>
{t('common:noNotifications')}
</div>
<div className={'text-sm'}>{t('common.noNotifications')}</div>
</If>
<div
className={
'flex max-h-[60vh] flex-col divide-y divide-gray-100 overflow-y-auto dark:divide-gray-800'
}
>
<div className={'flex max-h-[60vh] flex-col overflow-y-auto'}>
{notifications.map((notification) => {
const maxChars = 100;
@@ -164,11 +158,11 @@ export function NotificationsPopover(params: {
const Icon = () => {
switch (notification.type) {
case 'warning':
return <TriangleAlert className={'h-4 text-yellow-500'} />;
return <TriangleAlert className={'size-3 text-yellow-500'} />;
case 'error':
return <CircleAlert className={'text-destructive h-4'} />;
return <CircleAlert className={'text-destructive size-3'} />;
default:
return <Info className={'h-4 text-blue-500'} />;
return <Info className={'size-3 text-blue-500'} />;
}
};
@@ -176,7 +170,7 @@ export function NotificationsPopover(params: {
<div
key={notification.id.toString()}
className={cn(
'flex min-h-18 flex-col items-start justify-center gap-y-1 px-3 py-2',
'flex min-h-14 flex-col items-start justify-center gap-y-1 px-1',
)}
onClick={() => {
if (params.onClick) {
@@ -185,15 +179,11 @@ export function NotificationsPopover(params: {
}}
>
<div className={'flex w-full items-start justify-between'}>
<div
className={'flex items-start justify-start gap-x-3 py-2'}
>
<div className={'py-0.5'}>
<Icon />
</div>
<div className={'flex items-start justify-start gap-x-1.5'}>
<div className={'flex flex-col'}>
<div className={'flex items-center gap-x-2 text-sm'}>
<Icon />
<div className={'flex flex-col space-y-1'}>
<div className={'text-sm'}>
<If condition={notification.link} fallback={body}>
{(link) => (
<a href={link} className={'hover:underline'}>
@@ -209,7 +199,7 @@ export function NotificationsPopover(params: {
</div>
</div>
<div className={'py-2'}>
<div className={'ml-2'}>
<Button
className={'max-h-6 max-w-6'}
size={'icon'}

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,12 +1,13 @@
{
"name": "@kit/team-accounts",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./api": "./src/server/api.ts",
@@ -16,46 +17,41 @@
"./policies": "./src/server/policies/index.ts",
"./services/account-invitations.service": "./src/server/services/account-invitations.service.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"nanoid": "^5.1.6"
"nanoid": "catalog:"
},
"devDependencies": {
"@hookform/resolvers": "^5.2.2",
"@hookform/resolvers": "catalog:",
"@kit/accounts": "workspace:*",
"@kit/billing-gateway": "workspace:*",
"@kit/email-templates": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/mailers": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*",
"@kit/otp": "workspace:*",
"@kit/policies": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-table": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"class-variance-authority": "catalog:",
"date-fns": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"react-i18next": "catalog:",
"zod": "catalog:"
},
"prettier": "@kit/prettier-config",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -1,14 +1,5 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, useWatch } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Dialog,
DialogContent,
@@ -16,24 +7,10 @@ import {
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { Trans } from '@kit/ui/trans';
import {
CreateTeamSchema,
NON_LATIN_REGEX,
} from '../schema/create-team.schema';
import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions';
import { CreateTeamAccountForm } from './create-team-account-form';
export function CreateTeamAccountDialog(
props: React.PropsWithChildren<{
@@ -41,172 +18,30 @@ export function CreateTeamAccountDialog(
setIsOpen: (isOpen: boolean) => void;
}>,
) {
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({
open: props.isOpen,
onOpenChange: props.setIsOpen,
});
return (
<Dialog open={props.isOpen} onOpenChange={props.setIsOpen}>
<DialogContent
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<Dialog {...dialogProps}>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:createTeamModalHeading'} />
<Trans i18nKey={'teams.createTeamModalHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams:createTeamModalDescription'} />
<Trans i18nKey={'teams.createTeamModalDescription'} />
</DialogDescription>
</DialogHeader>
<CreateOrganizationAccountForm onClose={() => props.setIsOpen(false)} />
<CreateTeamAccountForm
isPending={isPending}
setIsPending={setIsPending}
onClose={() => setOpen(false)}
/>
</DialogContent>
</Dialog>
);
}
function CreateOrganizationAccountForm(props: { onClose: () => void }) {
const [error, setError] = useState<{ message?: string } | undefined>();
const [pending, startTransition] = useTransition();
const form = useForm({
defaultValues: {
name: '',
slug: '',
},
resolver: zodResolver(CreateTeamSchema),
});
const nameValue = useWatch({ control: form.control, name: 'name' });
const showSlugField = useMemo(
() => NON_LATIN_REGEX.test(nameValue ?? ''),
[nameValue],
);
return (
<Form {...form}>
<form
data-test={'create-team-form'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
const result = await createTeamAccountAction(data);
if (result.error) {
setError({ message: result.message });
}
} catch (e) {
if (!isRedirectError(e)) {
setError({});
}
}
});
})}
>
<div className={'flex flex-col space-y-4'}>
<If condition={error}>
<CreateOrganizationErrorAlert message={error?.message} />
</If>
<FormField
name={'name'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-name-input'}
required
minLength={2}
maxLength={50}
placeholder={''}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:teamNameDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<If condition={showSlugField}>
<FormField
name={'slug'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamSlugLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-slug-input'}
required
minLength={2}
maxLength={50}
placeholder={'my-team'}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:teamSlugDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
</If>
<div className={'flex justify-end space-x-2'}>
<Button
variant={'outline'}
type={'button'}
disabled={pending}
onClick={props.onClose}
>
<Trans i18nKey={'common:cancel'} />
</Button>
<Button data-test={'confirm-create-team-button'} disabled={pending}>
{pending ? (
<Trans i18nKey={'teams:creatingTeam'} />
) : (
<Trans i18nKey={'teams:createTeamSubmitLabel'} />
)}
</Button>
</div>
</div>
</form>
</Form>
);
}
function CreateOrganizationErrorAlert(props: { message?: string }) {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:createTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
{props.message ? (
<Trans i18nKey={props.message} defaults={props.message} />
) : (
<Trans i18nKey={'teams:createTeamErrorMessage'} />
)}
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import {
CreateTeamSchema,
NON_LATIN_REGEX,
} from '../schema/create-team.schema';
import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions';
export function CreateTeamAccountForm(props: {
onClose?: () => void;
isPending?: boolean;
setIsPending?: (isPending: boolean) => void;
submitLabel?: string;
}) {
const [error, setError] = useState<{ message?: string } | undefined>();
const { execute, isPending } = useAction(createTeamAccountAction, {
onExecute: () => {
setError(undefined);
if (props.setIsPending) {
props.setIsPending(true);
}
},
onSuccess: ({ data }) => {
if (data?.error) {
setError({ message: data.message });
}
},
onError: () => {
setError({});
},
onSettled: () => {
if (props.setIsPending) {
props.setIsPending(false);
}
},
});
const form = useForm({
defaultValues: {
name: '',
slug: '',
},
resolver: zodResolver(CreateTeamSchema),
});
const nameValue = useWatch({ control: form.control, name: 'name' });
const showSlugField = NON_LATIN_REGEX.test(nameValue ?? '');
return (
<Form {...form}>
<form
data-test={'create-team-form'}
onSubmit={form.handleSubmit((data) => execute(data))}
>
<div className={'flex flex-col space-y-4'}>
<If condition={error}>
<CreateTeamAccountErrorAlert message={error?.message} />
</If>
<FormField
name={'name'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams.teamNameLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-name-input'}
required
minLength={2}
maxLength={50}
placeholder={''}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams.teamNameDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<If condition={showSlugField}>
<FormField
name={'slug'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams.teamSlugLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'team-slug-input'}
required
minLength={2}
maxLength={50}
placeholder={'my-team'}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams.teamSlugDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
</If>
<div className={'flex justify-end space-x-2'}>
<If condition={!!props.onClose}>
<Button
variant={'outline'}
type={'button'}
disabled={isPending || props.isPending}
onClick={props.onClose}
>
<Trans i18nKey={'common.cancel'} />
</Button>
</If>
<Button
type="submit"
data-test={'confirm-create-team-button'}
disabled={isPending || props.isPending}
>
{isPending || props.isPending ? (
<Trans i18nKey={'teams.creatingTeam'} />
) : (
<Trans
i18nKey={props.submitLabel ?? 'teams.createTeamSubmitLabel'}
/>
)}
</Button>
</div>
</div>
</form>
</Form>
);
}
function CreateTeamAccountErrorAlert(props: { message?: string }) {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams.createTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
{props.message ? (
<Trans i18nKey={props.message} defaults={props.message} />
) : (
<Trans i18nKey={'teams.createTeamErrorMessage'} />
)}
</AlertDescription>
</Alert>
);
}

View File

@@ -5,4 +5,5 @@ export * from './invitations/account-invitations-table';
export * from './settings/team-account-settings-container';
export * from './invitations/accept-invitation-container';
export * from './create-team-account-dialog';
export * from './create-team-account-form';
export * from './team-account-workspace-context';

View File

@@ -1,12 +1,16 @@
'use client';
import Image from 'next/image';
import { useAction } from 'next-safe-action/hooks';
import { Button } from '@kit/ui/button';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
import { Trans } from '@kit/ui/trans';
import { acceptInvitationAction } from '../../server/actions/team-invitations-server-actions';
import { InvitationSubmitButton } from './invitation-submit-button';
import { SignOutInvitationButton } from './sign-out-invitation-button';
export function AcceptInvitationContainer(props: {
@@ -28,11 +32,13 @@ export function AcceptInvitationContainer(props: {
nextPath: string;
};
}) {
const { execute, isPending } = useAction(acceptInvitationAction);
return (
<div className={'flex flex-col items-center space-y-4'}>
<Heading className={'text-center'} level={4}>
<Trans
i18nKey={'teams:acceptInvitationHeading'}
i18nKey={'teams.acceptInvitationHeading'}
values={{
accountName: props.invitation.account.name,
}}
@@ -53,7 +59,7 @@ export function AcceptInvitationContainer(props: {
<div className={'text-muted-foreground text-center text-sm'}>
<Trans
i18nKey={'teams:acceptInvitationDescription'}
i18nKey={'teams.acceptInvitationDescription'}
values={{
accountName: props.invitation.account.name,
}}
@@ -64,20 +70,24 @@ export function AcceptInvitationContainer(props: {
<form
data-test={'join-team-form'}
className={'w-full'}
action={acceptInvitationAction}
onSubmit={(e) => {
e.preventDefault();
execute({
inviteToken: props.inviteToken,
nextPath: props.paths.nextPath,
});
}}
>
<input type="hidden" name={'inviteToken'} value={props.inviteToken} />
<input
type={'hidden'}
name={'nextPath'}
value={props.paths.nextPath}
/>
<InvitationSubmitButton
email={props.email}
accountName={props.invitation.account.name}
/>
<Button type={'submit'} className={'w-full'} disabled={isPending}>
<Trans
i18nKey={isPending ? 'teams.joiningTeam' : 'teams.continueAs'}
values={{
accountName: props.invitation.account.name,
email: props.email,
}}
/>
</Button>
</form>
<Separator />
@@ -85,7 +95,7 @@ export function AcceptInvitationContainer(props: {
<SignOutInvitationButton nextPath={props.paths.signOutNext} />
<span className={'text-muted-foreground text-center text-xs'}>
<Trans i18nKey={'teams:signInWithDifferentAccountDescription'} />
<Trans i18nKey={'teams.signInWithDifferentAccountDescription'} />
</span>
</div>
</div>

View File

@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { Ellipsis } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
@@ -43,7 +43,7 @@ export function AccountInvitationsTable({
invitations,
permissions,
}: AccountInvitationsTableProps) {
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const [search, setSearch] = useState('');
const columns = useGetColumns(permissions);
@@ -82,7 +82,7 @@ function useGetColumns(permissions: {
canRemoveInvitation: boolean;
currentUserRoleHierarchy: number;
}): ColumnDef<Invitations[0]>[] {
const { t } = useTranslation('teams');
const t = useTranslations('teams');
return useMemo(
() => [
@@ -96,7 +96,7 @@ function useGetColumns(permissions: {
return (
<span
data-test={'invitation-email'}
className={'flex items-center space-x-4 text-left'}
className={'flex items-center gap-x-2 text-left'}
>
<span>
<ProfileAvatar text={email} />
@@ -172,19 +172,21 @@ function ActionsDropdown({
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuContent className="min-w-52">
<If condition={permissions.canUpdateInvitation}>
<DropdownMenuItem
data-test={'update-invitation-trigger'}
onClick={() => setIsUpdatingRole(true)}
>
<Trans i18nKey={'teams:updateInvitation'} />
<Trans i18nKey={'teams.updateInvitation'} />
</DropdownMenuItem>
<If condition={getIsInviteExpired(invitation.expires_at)}>
@@ -192,7 +194,7 @@ function ActionsDropdown({
data-test={'renew-invitation-trigger'}
onClick={() => setIsRenewingInvite(true)}
>
<Trans i18nKey={'teams:renewInvitation'} />
<Trans i18nKey={'teams.renewInvitation'} />
</DropdownMenuItem>
</If>
</If>
@@ -200,9 +202,10 @@ function ActionsDropdown({
<If condition={permissions.canRemoveInvitation}>
<DropdownMenuItem
data-test={'remove-invitation-trigger'}
variant="destructive"
onClick={() => setIsDeletingInvite(true)}
>
<Trans i18nKey={'teams:removeInvitation'} />
<Trans i18nKey={'teams.removeInvitation'} />
</DropdownMenuItem>
</If>
</DropdownMenuContent>

View File

@@ -1,4 +1,6 @@
import { useState, useTransition } from 'react';
'use client';
import { useAction } from 'next-safe-action/hooks';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -11,6 +13,7 @@ import {
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
@@ -25,22 +28,35 @@ export function DeleteInvitationDialog({
setIsOpen: (isOpen: boolean) => void;
invitationId: number;
}) {
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({
open: isOpen,
onOpenChange: setIsOpen,
});
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="team:deleteInvitation" />
<Trans i18nKey="teams.deleteInvitation" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="team:deleteInvitationDialogDescription" />
<Trans i18nKey="teams.deleteInvitationDialogDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
<DeleteInvitationForm
setIsOpen={setIsOpen}
invitationId={invitationId}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
</AlertDialogContent>
</AlertDialog>
@@ -49,48 +65,45 @@ export function DeleteInvitationDialog({
function DeleteInvitationForm({
invitationId,
setIsOpen,
isPending,
setIsPending,
onSuccess,
}: {
invitationId: number;
setIsOpen: (isOpen: boolean) => void;
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => void;
}) {
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const onInvitationRemoved = () => {
startTransition(async () => {
try {
await deleteInvitationAction({ invitationId });
setIsOpen(false);
} catch {
setError(true);
}
});
};
const { execute, hasErrored } = useAction(deleteInvitationAction, {
onExecute: () => setIsPending(true),
onSuccess: () => onSuccess(),
onSettled: () => setIsPending(false),
});
return (
<form data-test={'delete-invitation-form'} action={onInvitationRemoved}>
<form
data-test={'delete-invitation-form'}
onSubmit={(e) => {
e.preventDefault();
execute({ invitationId });
}}
>
<div className={'flex flex-col space-y-6'}>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
<Trans i18nKey={'common.modalConfirmationQuestion'} />
</p>
<If condition={error}>
<If condition={hasErrored}>
<RemoveInvitationErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<AlertDialogCancel disabled={isPending}>
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<Button
type={'submit'}
variant={'destructive'}
disabled={isSubmitting}
>
<Trans i18nKey={'teams:deleteInvitation'} />
<Button type={'submit'} variant={'destructive'} disabled={isPending}>
<Trans i18nKey={'teams.deleteInvitation'} />
</Button>
</AlertDialogFooter>
</div>
@@ -102,11 +115,11 @@ function RemoveInvitationErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:deleteInvitationErrorTitle'} />
<Trans i18nKey={'teams.deleteInvitationErrorTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:deleteInvitationErrorMessage'} />
<Trans i18nKey={'teams.deleteInvitationErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -14,7 +14,7 @@ export function InvitationSubmitButton(props: {
return (
<Button type={'submit'} className={'w-full'} disabled={pending}>
<Trans
i18nKey={pending ? 'teams:joiningTeam' : 'teams:continueAs'}
i18nKey={pending ? 'teams.joiningTeam' : 'teams.continueAs'}
values={{
accountName: props.accountName,
email: props.email,

View File

@@ -1,4 +1,6 @@
import { useState, useTransition } from 'react';
'use client';
import { useAction } from 'next-safe-action/hooks';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -11,6 +13,7 @@ import {
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
@@ -27,25 +30,38 @@ export function RenewInvitationDialog({
invitationId: number;
email: string;
}) {
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({
open: isOpen,
onOpenChange: setIsOpen,
});
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="team:renewInvitation" />
<Trans i18nKey="team.renewInvitation" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey="team:renewInvitationDialogDescription"
i18nKey="team.renewInvitationDialogDescription"
values={{ email }}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<RenewInvitationForm
setIsOpen={setIsOpen}
invitationId={invitationId}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
</AlertDialogContent>
</AlertDialog>
@@ -54,47 +70,48 @@ export function RenewInvitationDialog({
function RenewInvitationForm({
invitationId,
setIsOpen,
isPending,
setIsPending,
onSuccess,
}: {
invitationId: number;
setIsOpen: (isOpen: boolean) => void;
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => void;
}) {
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const inInvitationRenewed = () => {
startTransition(async () => {
try {
await renewInvitationAction({ invitationId });
setIsOpen(false);
} catch {
setError(true);
}
});
};
const { execute, hasErrored } = useAction(renewInvitationAction, {
onExecute: () => setIsPending(true),
onSuccess: () => onSuccess(),
onSettled: () => setIsPending(false),
});
return (
<form action={inInvitationRenewed}>
<form
onSubmit={(e) => {
e.preventDefault();
execute({ invitationId });
}}
>
<div className={'flex flex-col space-y-6'}>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
<Trans i18nKey={'common.modalConfirmationQuestion'} />
</p>
<If condition={error}>
<If condition={hasErrored}>
<RenewInvitationErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<AlertDialogCancel disabled={isPending}>
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<Button
type={'submit'}
data-test={'confirm-renew-invitation'}
disabled={isSubmitting}
disabled={isPending}
>
<Trans i18nKey={'teams:renewInvitation'} />
<Trans i18nKey={'teams.renewInvitation'} />
</Button>
</AlertDialogFooter>
</div>
@@ -106,11 +123,11 @@ function RenewInvitationErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:renewInvitationErrorTitle'} />
<Trans i18nKey={'teams.renewInvitationErrorTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:renewInvitationErrorDescription'} />
<Trans i18nKey={'teams.renewInvitationErrorDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -24,7 +24,7 @@ export function SignOutInvitationButton(
window.location.assign(safePath);
}}
>
<Trans i18nKey={'teams:signInWithDifferentAccount'} />
<Trans i18nKey={'teams.signInWithDifferentAccount'} />
</Button>
);
}

View File

@@ -1,8 +1,9 @@
import { useState, useTransition } from 'react';
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
@@ -22,6 +23,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
@@ -45,16 +47,21 @@ export function UpdateInvitationDialog({
userRole: Role;
userRoleHierarchy: number;
}) {
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({
open: isOpen,
onOpenChange: setIsOpen,
});
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<Dialog {...dialogProps}>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:updateMemberRoleModalHeading'} />
<Trans i18nKey={'teams.updateMemberRoleModalHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams:updateMemberRoleModalDescription'} />
<Trans i18nKey={'teams.updateMemberRoleModalDescription'} />
</DialogDescription>
</DialogHeader>
@@ -62,7 +69,12 @@ export function UpdateInvitationDialog({
invitationId={invitationId}
userRole={userRole}
userRoleHierarchy={userRoleHierarchy}
setIsOpen={setIsOpen}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
</DialogContent>
</Dialog>
@@ -73,31 +85,24 @@ function UpdateInvitationForm({
invitationId,
userRole,
userRoleHierarchy,
setIsOpen,
isPending,
setIsPending,
onSuccess,
}: React.PropsWithChildren<{
invitationId: number;
userRole: Role;
userRoleHierarchy: number;
setIsOpen: (isOpen: boolean) => void;
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => void;
}>) {
const { t } = useTranslation('teams');
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const t = useTranslations('teams');
const onSubmit = ({ role }: { role: Role }) => {
startTransition(async () => {
try {
await updateInvitationAction({
invitationId,
role,
});
setIsOpen(false);
} catch {
setError(true);
}
});
};
const { execute, hasErrored } = useAction(updateInvitationAction, {
onExecute: () => setIsPending(true),
onSuccess: () => onSuccess(),
onSettled: () => setIsPending(false),
});
const form = useForm({
resolver: zodResolver(
@@ -122,10 +127,12 @@ function UpdateInvitationForm({
<Form {...form}>
<form
data-test={'update-invitation-form'}
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(({ role }) => {
execute({ invitationId, role });
})}
className={'flex flex-col space-y-6'}
>
<If condition={error}>
<If condition={hasErrored}>
<UpdateRoleErrorAlert />
</If>
@@ -135,7 +142,7 @@ function UpdateInvitationForm({
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:roleLabel'} />
<Trans i18nKey={'teams.roleLabel'} />
</FormLabel>
<FormControl>
@@ -145,16 +152,18 @@ function UpdateInvitationForm({
roles={roles}
currentUserRole={userRole}
value={field.value}
onChange={(newRole) =>
form.setValue(field.name, newRole)
}
onChange={(newRole) => {
if (newRole) {
form.setValue(field.name, newRole);
}
}}
/>
)}
</RolesDataProvider>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:updateRoleDescription'} />
<Trans i18nKey={'teams.updateRoleDescription'} />
</FormDescription>
<FormMessage />
@@ -163,8 +172,8 @@ function UpdateInvitationForm({
}}
/>
<Button type={'submit'} disabled={pending}>
<Trans i18nKey={'teams:updateRoleSubmitLabel'} />
<Button type={'submit'} disabled={isPending}>
<Trans i18nKey={'teams.updateRoleSubmitLabel'} />
</Button>
</form>
</Form>
@@ -175,11 +184,11 @@ function UpdateRoleErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:updateRoleErrorHeading'} />
<Trans i18nKey={'teams.updateRoleErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:updateRoleErrorMessage'} />
<Trans i18nKey={'teams.updateRoleErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react';
import { ColumnDef } from '@tanstack/react-table';
import { Ellipsis } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
import { Database } from '@kit/supabase/database';
import { Badge } from '@kit/ui/badge';
@@ -53,7 +53,7 @@ export function AccountMembersTable({
canManageRoles,
}: AccountMembersTableProps) {
const [search, setSearch] = useState('');
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const permissions = {
canUpdateRole: (targetRole: number) => {
@@ -123,7 +123,7 @@ function useGetColumns(
currentRoleHierarchy: number;
},
): ColumnDef<Members[0]>[] {
const { t } = useTranslation('teams');
const t = useTranslations('teams');
return useMemo(
() => [
@@ -136,7 +136,7 @@ function useGetColumns(
const isSelf = member.user_id === params.currentUserId;
return (
<span className={'flex items-center space-x-4 text-left'}>
<span className={'flex items-center gap-x-2 text-left'}>
<span>
<ProfileAvatar
displayName={displayName}
@@ -144,11 +144,13 @@ function useGetColumns(
/>
</span>
<span>{displayName}</span>
<span className={'flex items-center gap-x-2'}>
<span>{displayName}</span>
<If condition={isSelf}>
<Badge variant={'outline'}>{t('youLabel')}</Badge>
</If>
<If condition={isSelf}>
<Badge variant={'secondary'}>{t('youLabel')}</Badge>
</If>
</span>
</span>
);
},
@@ -171,13 +173,7 @@ function useGetColumns(
<RoleBadge role={role} />
<If condition={isPrimaryOwner}>
<span
className={
'rounded-md bg-yellow-400 px-2.5 py-1 text-xs font-medium dark:text-black'
}
>
{t('primaryOwnerLabel')}
</span>
<Badge variant={'warning'}>{t('primaryOwnerLabel')}</Badge>
</If>
</span>
);
@@ -223,6 +219,10 @@ function ActionsDropdown({
const isCurrentUser = member.user_id === currentUserId;
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
const [activeDialog, setActiveDialog] = useState<
'updateRole' | 'transferOwnership' | 'removeMember' | null
>(null);
if (isCurrentUser || isPrimaryOwner) {
return null;
}
@@ -246,50 +246,70 @@ function ActionsDropdown({
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuTrigger
render={
<Button variant={'ghost'} size={'icon'}>
<Ellipsis className={'h-5 w-5'} />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuContent className={'min-w-52'}>
<If condition={canUpdateRole}>
<UpdateMemberRoleDialog
userId={member.user_id}
userRole={member.role}
teamAccountId={currentTeamAccountId}
userRoleHierarchy={currentRoleHierarchy}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:updateRole'} />
</DropdownMenuItem>
</UpdateMemberRoleDialog>
<DropdownMenuItem onClick={() => setActiveDialog('updateRole')}>
<Trans i18nKey={'teams.updateRole'} />
</DropdownMenuItem>
</If>
<If condition={permissions.canTransferOwnership}>
<TransferOwnershipDialog
targetDisplayName={member.name ?? member.email}
accountId={member.account_id}
userId={member.user_id}
<DropdownMenuItem
variant="destructive"
onClick={() => setActiveDialog('transferOwnership')}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:transferOwnership'} />
</DropdownMenuItem>
</TransferOwnershipDialog>
<Trans i18nKey={'teams.transferOwnership'} />
</DropdownMenuItem>
</If>
<If condition={canRemoveFromAccount}>
<RemoveMemberDialog
teamAccountId={currentTeamAccountId}
userId={member.user_id}
<DropdownMenuItem
variant="destructive"
onClick={() => setActiveDialog('removeMember')}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trans i18nKey={'teams:removeMember'} />
</DropdownMenuItem>
</RemoveMemberDialog>
<Trans i18nKey={'teams.removeMember'} />
</DropdownMenuItem>
</If>
</DropdownMenuContent>
</DropdownMenu>
{activeDialog === 'updateRole' && (
<UpdateMemberRoleDialog
open
onOpenChange={(open) => !open && setActiveDialog(null)}
userId={member.user_id}
userRole={member.role}
teamAccountId={currentTeamAccountId}
userRoleHierarchy={currentRoleHierarchy}
/>
)}
{activeDialog === 'transferOwnership' && (
<TransferOwnershipDialog
open
onOpenChange={(open) => !open && setActiveDialog(null)}
targetDisplayName={member.name ?? member.email}
accountId={member.account_id}
userId={member.user_id}
/>
)}
{activeDialog === 'removeMember' && (
<RemoveMemberDialog
open
onOpenChange={(open) => !open && setActiveDialog(null)}
teamAccountId={currentTeamAccountId}
userId={member.user_id}
/>
)}
</>
);
}

View File

@@ -1,12 +1,11 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Mail, Plus, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { useFieldArray, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
@@ -25,6 +24,7 @@ import {
FormItem,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import {
InputGroup,
@@ -64,29 +64,46 @@ export function InviteMembersDialogContainer({
accountSlug: string;
userRoleHierarchy: number;
}>) {
const [pending, startTransition] = useTransition();
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation('teams');
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
const t = useTranslations('teams');
const { execute } = useAction(createInvitationsAction, {
onExecute: () => setIsPending(true),
onSuccess: ({ data }) => {
if (data?.success) {
toast.success(t('inviteMembersSuccessMessage'));
} else {
toast.error(t('inviteMembersErrorMessage'));
}
},
onError: () => {
toast.error(t('inviteMembersErrorMessage'));
},
onSettled: () => {
setIsPending(false);
setOpen(false);
},
});
// Evaluate policies when dialog is open
const {
data: policiesResult,
isLoading: isLoadingPolicies,
error: policiesError,
} = useFetchInvitationsPolicies({ accountSlug, isOpen });
} = useFetchInvitationsPolicies({ accountSlug, isOpen: dialogProps.open });
return (
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
<DialogTrigger asChild>{children}</DialogTrigger>
<Dialog {...dialogProps}>
<DialogTrigger render={children as React.ReactElement} />
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:inviteMembersHeading'} />
<Trans i18nKey={'teams.inviteMembersHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams:inviteMembersDescription'} />
<Trans i18nKey={'teams.inviteMembersDescription'} />
</DialogDescription>
</DialogHeader>
@@ -95,7 +112,7 @@ export function InviteMembersDialogContainer({
<Spinner className="h-6 w-6" />
<span className="text-muted-foreground text-sm">
<Trans i18nKey="teams:checkingPolicies" />
<Trans i18nKey="teams.checkingPolicies" />
</span>
</div>
</If>
@@ -104,7 +121,7 @@ export function InviteMembersDialogContainer({
<Alert variant="destructive">
<AlertDescription>
<Trans
i18nKey="teams:policyCheckError"
i18nKey="teams.policyCheckError"
values={{ error: policiesError?.message }}
/>
</AlertDescription>
@@ -126,28 +143,12 @@ export function InviteMembersDialogContainer({
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
{(roles) => (
<InviteMembersForm
pending={pending}
pending={isPending}
roles={roles}
onSubmit={(data) => {
startTransition(async () => {
const toastId = toast.loading(t('invitingMembers'));
const result = await createInvitationsAction({
accountSlug,
invitations: data.invitations,
});
if (result.success) {
toast.success(t('inviteMembersSuccessMessage'), {
id: toastId,
});
} else {
toast.error(t('inviteMembersErrorMessage'), {
id: toastId,
});
}
setIsOpen(false);
execute({
accountSlug,
invitations: data.invitations,
});
}}
/>
@@ -168,7 +169,7 @@ function InviteMembersForm({
pending: boolean;
roles: string[];
}) {
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const form = useForm({
resolver: zodResolver(InviteMembersSchema),
@@ -237,7 +238,9 @@ function InviteMembersForm({
roles={roles}
value={field.value}
onChange={(role) => {
form.setValue(field.name, role);
if (role) {
form.setValue(field.name, role);
}
}}
/>
</FormControl>
@@ -251,22 +254,24 @@ function InviteMembersForm({
<div className={'flex items-end justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={'ghost'}
size={'icon'}
type={'button'}
disabled={fieldArray.fields.length <= 1}
data-test={'remove-invite-button'}
aria-label={t('removeInviteButtonLabel')}
onClick={() => {
fieldArray.remove(index);
form.clearErrors(emailInputName);
}}
>
<X className={'h-4'} />
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
variant={'ghost'}
size={'icon'}
type={'button'}
disabled={fieldArray.fields.length <= 1}
data-test={'remove-invite-button'}
aria-label={t('removeInviteButtonLabel')}
onClick={() => {
fieldArray.remove(index);
form.clearErrors(emailInputName);
}}
>
<X className={'h-4'} />
</Button>
}
/>
<TooltipContent>
{t('removeInviteButtonLabel')}
@@ -294,7 +299,7 @@ function InviteMembersForm({
<Plus className={'mr-1 h-3'} />
<span>
<Trans i18nKey={'teams:addAnotherMemberButtonLabel'} />
<Trans i18nKey={'teams.addAnotherMemberButtonLabel'} />
</span>
</Button>
</div>
@@ -305,8 +310,8 @@ function InviteMembersForm({
<Trans
i18nKey={
pending
? 'teams:invitingMembers'
: 'teams:inviteMembersButtonLabel'
? 'teams.invitingMembers'
: 'teams.inviteMembersButtonLabel'
}
/>
</Button>

View File

@@ -19,7 +19,7 @@ export function MembershipRoleSelector({
roles: Role[];
value: Role;
currentUserRole?: Role;
onChange: (role: Role) => unknown;
onChange: (role: Role | null) => unknown;
triggerClassName?: string;
}) {
return (
@@ -28,7 +28,15 @@ export function MembershipRoleSelector({
className={triggerClassName}
data-test={'role-selector-trigger'}
>
<SelectValue />
<SelectValue>
{(value) =>
value ? (
<Trans i18nKey={`common.roles.${value}.label`} defaults={value} />
) : (
''
)
}
</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -41,7 +49,7 @@ export function MembershipRoleSelector({
value={role}
>
<span className={'text-sm capitalize'}>
<Trans i18nKey={`common:roles.${role}.label`} defaults={role} />
<Trans i18nKey={`common.roles.${role}.label`} defaults={role} />
</span>
</SelectItem>
);

View File

@@ -1,4 +1,6 @@
import { useState, useTransition } from 'react';
'use client';
import { useAction } from 'next-safe-action/hooks';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -9,38 +11,56 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
export function RemoveMemberDialog({
open,
onOpenChange,
teamAccountId,
userId,
children,
}: React.PropsWithChildren<{
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
teamAccountId: string;
userId: string;
}>) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
}) {
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({
open,
onOpenChange,
});
return (
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="teamS:removeMemberModalHeading" />
<Trans i18nKey="teams.removeMemberModalHeading" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey={'teams:removeMemberModalDescription'} />
<Trans i18nKey={'teams.removeMemberModalDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
<RemoveMemberForm accountId={teamAccountId} userId={userId} />
<RemoveMemberForm
accountId={teamAccountId}
userId={userId}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
</AlertDialogContent>
</AlertDialog>
);
@@ -49,45 +69,50 @@ export function RemoveMemberDialog({
function RemoveMemberForm({
accountId,
userId,
isPending,
setIsPending,
onSuccess,
}: {
accountId: string;
userId: string;
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => void;
}) {
const [isSubmitting, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const onMemberRemoved = () => {
startTransition(async () => {
try {
await removeMemberFromAccountAction({ accountId, userId });
} catch {
setError(true);
}
});
};
const { execute, hasErrored } = useAction(removeMemberFromAccountAction, {
onExecute: () => setIsPending(true),
onSuccess: () => onSuccess(),
onSettled: () => setIsPending(false),
});
return (
<form action={onMemberRemoved}>
<form
onSubmit={(e) => {
e.preventDefault();
execute({ accountId, userId });
}}
>
<div className={'flex flex-col space-y-6'}>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
<Trans i18nKey={'common.modalConfirmationQuestion'} />
</p>
<If condition={error}>
<If condition={hasErrored}>
<RemoveMemberErrorAlert />
</If>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<AlertDialogCancel disabled={isPending}>
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<Button
type={'submit'}
data-test={'confirm-remove-member'}
variant={'destructive'}
disabled={isSubmitting}
disabled={isPending}
>
<Trans i18nKey={'teams:removeMemberSubmitLabel'} />
<Trans i18nKey={'teams.removeMemberSubmitLabel'} />
</Button>
</AlertDialogFooter>
</div>
@@ -99,11 +124,11 @@ function RemoveMemberErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:removeMemberErrorHeading'} />
<Trans i18nKey={'teams.removeMemberErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:removeMemberErrorMessage'} />
<Trans i18nKey={'teams.removeMemberErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -25,7 +25,7 @@ export function RoleBadge({ role }: { role: Role }) {
return (
<Badge className={className} variant={isCustom ? 'outline' : 'default'}>
<span data-test={'member-role-badge'}>
<Trans i18nKey={`common:roles.${role}.label`} defaults={role} />
<Trans i18nKey={`common.roles.${role}.label`} defaults={role} />
</span>
</Badge>
);

View File

@@ -1,8 +1,7 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import { VerifyOtpForm } from '@kit/otp/components';
@@ -16,10 +15,10 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Form } from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
@@ -27,30 +26,36 @@ import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-owner
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
export function TransferOwnershipDialog({
children,
open,
onOpenChange,
targetDisplayName,
accountId,
userId,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
accountId: string;
userId: string;
targetDisplayName: string;
}) {
const [open, setOpen] = useState(false);
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({
open,
onOpenChange,
});
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
<AlertDialog
open={dialogProps.open}
onOpenChange={dialogProps.onOpenChange}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey="team:transferOwnership" />
<Trans i18nKey="teams.transferOwnership" />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="team:transferOwnershipDescription" />
<Trans i18nKey="teams.transferOwnershipDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
@@ -58,7 +63,12 @@ export function TransferOwnershipDialog({
accountId={accountId}
userId={userId}
targetDisplayName={targetDisplayName}
onSuccess={() => setOpen(false)}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
</AlertDialogContent>
</AlertDialog>
@@ -69,17 +79,25 @@ function TransferOrganizationOwnershipForm({
accountId,
userId,
targetDisplayName,
isPending,
setIsPending,
onSuccess,
}: {
userId: string;
accountId: string;
targetDisplayName: string;
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => unknown;
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { data: user } = useUser();
const { execute, hasErrored } = useAction(transferOwnershipAction, {
onExecute: () => setIsPending(true),
onSuccess: () => onSuccess(),
onSettled: () => setIsPending(false),
});
const form = useForm({
resolver: zodResolver(TransferOwnershipConfirmationSchema),
defaultValues: {
@@ -102,8 +120,8 @@ function TransferOrganizationOwnershipForm({
form.setValue('otp', otpValue, { shouldValidate: true });
}}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<AlertDialogCancel disabled={isPending}>
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
}
data-test="verify-otp-form"
@@ -117,25 +135,17 @@ function TransferOrganizationOwnershipForm({
<form
className={'flex flex-col space-y-4 text-sm'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
try {
await transferOwnershipAction(data);
onSuccess();
} catch {
setError(true);
}
});
execute(data);
})}
>
<If condition={error}>
<If condition={hasErrored}>
<TransferOwnershipErrorAlert />
</If>
<div className="border-destructive rounded-md border p-4">
<p className="text-destructive text-sm">
<Trans
i18nKey={'teams:transferOwnershipDisclaimer'}
i18nKey={'teams.transferOwnershipDisclaimer'}
values={{
member: targetDisplayName,
}}
@@ -148,26 +158,26 @@ function TransferOrganizationOwnershipForm({
<div>
<p className={'text-muted-foreground'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
<Trans i18nKey={'common.modalConfirmationQuestion'} />
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<AlertDialogCancel disabled={isPending}>
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<Button
type={'submit'}
data-test={'confirm-transfer-ownership-button'}
variant={'destructive'}
disabled={pending}
disabled={isPending}
>
<If
condition={pending}
fallback={<Trans i18nKey={'teams:transferOwnership'} />}
condition={isPending}
fallback={<Trans i18nKey={'teams.transferOwnership'} />}
>
<Trans i18nKey={'teams:transferringOwnership'} />
<Trans i18nKey={'teams.transferringOwnership'} />
</If>
</Button>
</AlertDialogFooter>
@@ -180,11 +190,11 @@ function TransferOwnershipErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:transferTeamErrorHeading'} />
<Trans i18nKey={'teams.transferTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:transferTeamErrorMessage'} />
<Trans i18nKey={'teams.transferTeamErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -1,10 +1,12 @@
import { useState, useTransition } from 'react';
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AlertDialogCancel } from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Dialog,
@@ -12,7 +14,6 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@kit/ui/dialog';
import {
Form,
@@ -23,6 +24,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Trans } from '@kit/ui/trans';
@@ -34,31 +36,35 @@ import { RolesDataProvider } from './roles-data-provider';
type Role = string;
export function UpdateMemberRoleDialog({
children,
open,
onOpenChange,
userId,
teamAccountId,
userRole,
userRoleHierarchy,
}: React.PropsWithChildren<{
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: string;
teamAccountId: string;
userRole: Role;
userRoleHierarchy: number;
}>) {
const [open, setOpen] = useState(false);
}) {
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog({
open,
onOpenChange,
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<Dialog {...dialogProps}>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'teams:updateMemberRoleModalHeading'} />
<Trans i18nKey={'teams.updateMemberRoleModalHeading'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'teams:updateMemberRoleModalDescription'} />
<Trans i18nKey={'teams.updateMemberRoleModalDescription'} />
</DialogDescription>
</DialogHeader>
@@ -69,7 +75,12 @@ export function UpdateMemberRoleDialog({
teamAccountId={teamAccountId}
userRole={userRole}
roles={data}
onSuccess={() => setOpen(false)}
isPending={isPending}
setIsPending={setIsPending}
onSuccess={() => {
setIsPending(false);
setOpen(false);
}}
/>
)}
</RolesDataProvider>
@@ -83,33 +94,25 @@ function UpdateMemberForm({
userRole,
teamAccountId,
roles,
isPending,
setIsPending,
onSuccess,
}: React.PropsWithChildren<{
userId: string;
userRole: Role;
teamAccountId: string;
roles: Role[];
isPending: boolean;
setIsPending: (pending: boolean) => void;
onSuccess: () => unknown;
}>) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const onSubmit = ({ role }: { role: Role }) => {
startTransition(async () => {
try {
await updateMemberRoleAction({
accountId: teamAccountId,
userId,
role,
});
onSuccess();
} catch {
setError(true);
}
});
};
const { execute, hasErrored } = useAction(updateMemberRoleAction, {
onExecute: () => setIsPending(true),
onSuccess: () => onSuccess(),
onSettled: () => setIsPending(false),
});
const form = useForm({
resolver: zodResolver(
@@ -134,10 +137,16 @@ function UpdateMemberForm({
<Form {...form}>
<form
data-test={'update-member-role-form'}
onSubmit={form.handleSubmit(onSubmit)}
className={'flex flex-col space-y-6'}
onSubmit={form.handleSubmit(({ role }) => {
execute({
accountId: teamAccountId,
userId,
role,
});
})}
className={'flex w-full flex-col space-y-6'}
>
<If condition={error}>
<If condition={hasErrored}>
<UpdateRoleErrorAlert />
</If>
@@ -150,10 +159,15 @@ function UpdateMemberForm({
<FormControl>
<MembershipRoleSelector
triggerClassName={'w-full'}
roles={roles}
currentUserRole={userRole}
value={field.value}
onChange={(newRole) => form.setValue('role', newRole)}
onChange={(newRole) => {
if (newRole) {
form.setValue('role', newRole);
}
}}
/>
</FormControl>
@@ -165,9 +179,19 @@ function UpdateMemberForm({
}}
/>
<Button data-test={'confirm-update-member-role'} disabled={pending}>
<Trans i18nKey={'teams:updateRoleSubmitLabel'} />
</Button>
<div className="flex justify-end gap-x-2">
<AlertDialogCancel disabled={isPending}>
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<Button
type="submit"
data-test={'confirm-update-member-role'}
disabled={isPending}
>
<Trans i18nKey={'teams.updateRoleSubmitLabel'} />
</Button>
</div>
</form>
</Form>
);
@@ -177,11 +201,11 @@ function UpdateRoleErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:updateRoleErrorHeading'} />
<Trans i18nKey={'teams.updateRoleErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'teams:updateRoleErrorMessage'} />
<Trans i18nKey={'teams.updateRoleErrorMessage'} />
</AlertDescription>
</Alert>
);

View File

@@ -1,10 +1,9 @@
'use client';
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import * as z from 'zod';
import { ErrorBoundary } from '@kit/monitoring/components';
import { VerifyOtpForm } from '@kit/otp/components';
@@ -100,12 +99,12 @@ function DeleteTeamContainer(props: {
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm font-medium'}>
<Trans i18nKey={'teams:deleteTeam'} />
<Trans i18nKey={'teams.deleteTeam'} />
</span>
<p className={'text-muted-foreground text-sm'}>
<Trans
i18nKey={'teams:deleteTeamDescription'}
i18nKey={'teams.deleteTeamDescription'}
values={{
teamName: props.account.name,
}}
@@ -115,25 +114,27 @@ function DeleteTeamContainer(props: {
<div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
data-test={'delete-team-trigger'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams:deleteTeam'} />
</Button>
</AlertDialogTrigger>
<AlertDialogTrigger
render={
<Button
data-test={'delete-team-trigger'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams.deleteTeam'} />
</Button>
}
/>
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'teams:deletingTeam'} />
<Trans i18nKey={'teams.deletingTeam'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={'teams:deletingTeamDescription'}
i18nKey={'teams.deletingTeamDescription'}
values={{
teamName: props.account.name,
}}
@@ -161,6 +162,8 @@ function DeleteTeamConfirmationForm({
}) {
const { data: user } = useUser();
const { execute, isPending } = useAction(deleteTeamAccountAction);
const form = useForm({
mode: 'onChange',
reValidateMode: 'onChange',
@@ -188,7 +191,7 @@ function DeleteTeamConfirmationForm({
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
CancelButton={
<AlertDialogCancel className={'m-0'}>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
}
/>
@@ -201,7 +204,10 @@ function DeleteTeamConfirmationForm({
<form
data-test={'delete-team-form'}
className={'flex flex-col space-y-4'}
action={deleteTeamAccountAction}
onSubmit={(e) => {
e.preventDefault();
execute({ accountId: id, otp });
}}
>
<div className={'flex flex-col space-y-2'}>
<div
@@ -211,7 +217,7 @@ function DeleteTeamConfirmationForm({
>
<div>
<Trans
i18nKey={'teams:deleteTeamDisclaimer'}
i18nKey={'teams.deleteTeamDisclaimer'}
values={{
teamName: name,
}}
@@ -219,20 +225,24 @@ function DeleteTeamConfirmationForm({
</div>
<div className={'text-sm'}>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
<Trans i18nKey={'common.modalConfirmationQuestion'} />
</div>
</div>
<input type="hidden" value={id} name={'accountId'} />
<input type="hidden" value={otp} name={'otp'} />
</div>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<DeleteTeamSubmitButton />
<Button
type="submit"
data-test={'delete-team-form-confirm-button'}
disabled={isPending}
variant={'destructive'}
>
<Trans i18nKey={'teams.deleteTeam'} />
</Button>
</AlertDialogFooter>
</form>
</Form>
@@ -240,26 +250,14 @@ function DeleteTeamConfirmationForm({
);
}
function DeleteTeamSubmitButton() {
const { pending } = useFormStatus();
return (
<Button
data-test={'delete-team-form-confirm-button'}
disabled={pending}
variant={'destructive'}
>
<Trans i18nKey={'teams:deleteTeam'} />
</Button>
);
}
function LeaveTeamContainer(props: {
account: {
name: string;
id: string;
};
}) {
const { execute, isPending } = useAction(leaveTeamAccountAction);
const form = useForm({
resolver: zodResolver(
z.object({
@@ -278,7 +276,7 @@ function LeaveTeamContainer(props: {
<div className={'flex flex-col space-y-4'}>
<p className={'text-muted-foreground text-sm'}>
<Trans
i18nKey={'teams:leaveTeamDescription'}
i18nKey={'teams.leaveTeamDescription'}
values={{
teamName: props.account.name,
}}
@@ -286,26 +284,26 @@ function LeaveTeamContainer(props: {
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<div>
<AlertDialogTrigger
render={
<Button
data-test={'leave-team-button'}
type={'button'}
variant={'destructive'}
>
<Trans i18nKey={'teams:leaveTeam'} />
<Trans i18nKey={'teams.leaveTeam'} />
</Button>
</div>
</AlertDialogTrigger>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'teams:leavingTeamModalHeading'} />
<Trans i18nKey={'teams.leavingTeamModalHeading'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey={'teams:leavingTeamModalDescription'} />
<Trans i18nKey={'teams.leavingTeamModalDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
@@ -313,21 +311,20 @@ function LeaveTeamContainer(props: {
<Form {...form}>
<form
className={'flex flex-col space-y-4'}
action={leaveTeamAccountAction}
onSubmit={form.handleSubmit((data) => {
execute({
accountId: props.account.id,
confirmation: data.confirmation,
});
})}
>
<input
type={'hidden'}
value={props.account.id}
name={'accountId'}
/>
<FormField
name={'confirmation'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:leaveTeamInputLabel'} />
<Trans i18nKey={'teams.leaveTeamInputLabel'} />
</FormLabel>
<FormControl>
@@ -344,7 +341,7 @@ function LeaveTeamContainer(props: {
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:leaveTeamInputDescription'} />
<Trans i18nKey={'teams.leaveTeamInputDescription'} />
</FormDescription>
<FormMessage />
@@ -355,10 +352,17 @@ function LeaveTeamContainer(props: {
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<LeaveTeamSubmitButton />
<Button
type="submit"
data-test={'confirm-leave-organization-button'}
disabled={isPending}
variant={'destructive'}
>
<Trans i18nKey={'teams.leaveTeam'} />
</Button>
</AlertDialogFooter>
</form>
</Form>
@@ -369,36 +373,22 @@ function LeaveTeamContainer(props: {
);
}
function LeaveTeamSubmitButton() {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-leave-organization-button'}
disabled={pending}
variant={'destructive'}
>
<Trans i18nKey={'teams:leaveTeam'} />
</Button>
);
}
function LeaveTeamErrorAlert() {
return (
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:leaveTeamErrorHeading'} />
<Trans i18nKey={'teams.leaveTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
<Trans i18nKey={'common.genericError'} />
</AlertDescription>
</Alert>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
</AlertDialogFooter>
</div>
@@ -410,17 +400,17 @@ function DeleteTeamErrorAlert() {
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:deleteTeamErrorHeading'} />
<Trans i18nKey={'teams.deleteTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
<Trans i18nKey={'common.genericError'} />
</AlertDescription>
</Alert>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
</AlertDialogFooter>
</div>
@@ -432,11 +422,11 @@ function DangerZoneCard({ children }: React.PropsWithChildren) {
<Card className={'border-destructive border'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'teams:settings.dangerZone'} />
<Trans i18nKey={'teams.settings.dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'teams:settings.dangerZoneDescription'} />
<Trans i18nKey={'teams.settings.dangerZoneDescription'} />
</CardDescription>
</CardHeader>

View File

@@ -35,11 +35,11 @@ export function TeamAccountSettingsContainer(props: {
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'teams:settings.teamLogo'} />
<Trans i18nKey={'teams.settings.teamLogo'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'teams:settings.teamLogoDescription'} />
<Trans i18nKey={'teams.settings.teamLogoDescription'} />
</CardDescription>
</CardHeader>
@@ -51,11 +51,11 @@ export function TeamAccountSettingsContainer(props: {
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'teams:settings.teamName'} />
<Trans i18nKey={'teams.settings.teamName'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'teams:settings.teamNameDescription'} />
<Trans i18nKey={'teams.settings.teamNameDescription'} />
</CardDescription>
</CardHeader>

View File

@@ -4,7 +4,7 @@ import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { ImageUploader } from '@kit/ui/image-uploader';
@@ -21,7 +21,7 @@ export function UpdateTeamAccountImage(props: {
};
}) {
const client = useSupabase();
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const createToaster = useCallback(
(promise: () => Promise<unknown>) => {
@@ -89,11 +89,11 @@ export function UpdateTeamAccountImage(props: {
>
<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>

View File

@@ -1,13 +1,12 @@
'use client';
import { useTransition } from 'react';
import { isRedirectError } from 'next/dist/client/components/redirect-error';
import { useRef } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Building, Link } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Button } from '@kit/ui/button';
import {
@@ -40,8 +39,7 @@ export const UpdateTeamAccountNameForm = (props: {
path: string;
}) => {
const [pending, startTransition] = useTransition();
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const form = useForm({
resolver: zodResolver(TeamNameFormSchema),
@@ -51,6 +49,28 @@ export const UpdateTeamAccountNameForm = (props: {
},
});
const toastId = useRef<string | number>('');
const { execute, isPending } = useAction(updateTeamAccountName, {
onExecute: () => {
toastId.current = toast.loading(t('updateTeamLoadingMessage'));
},
onSuccess: ({ data }) => {
if (data?.success) {
toast.success(t('updateTeamSuccessMessage'), {
id: toastId.current,
});
} else if (data?.error) {
toast.error(t(data.error), { id: toastId.current });
} else {
toast.error(t('updateTeamErrorMessage'), { id: toastId.current });
}
},
onError: () => {
toast.error(t('updateTeamErrorMessage'), { id: toastId.current });
},
});
const nameValue = useWatch({ control: form.control, name: 'name' });
const showSlugField = containsNonLatinCharacters(nameValue || '');
@@ -61,41 +81,11 @@ export const UpdateTeamAccountNameForm = (props: {
data-test={'update-team-account-name-form'}
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit((data) => {
startTransition(async () => {
const toastId = toast.loading(t('updateTeamLoadingMessage'));
try {
const result = await updateTeamAccountName({
slug: props.account.slug,
name: data.name,
newSlug: data.newSlug || undefined,
path: props.path,
});
if (result.success) {
toast.success(t('updateTeamSuccessMessage'), {
id: toastId,
});
} else if (result.error) {
toast.error(t(result.error), {
id: toastId,
});
} else {
toast.error(t('updateTeamErrorMessage'), {
id: toastId,
});
}
} catch (error) {
if (!isRedirectError(error)) {
toast.error(t('updateTeamErrorMessage'), {
id: toastId,
});
} else {
toast.success(t('updateTeamSuccessMessage'), {
id: toastId,
});
}
}
execute({
slug: props.account.slug,
name: data.name,
newSlug: data.newSlug || undefined,
path: props.path,
});
})}
>
@@ -105,7 +95,7 @@ export const UpdateTeamAccountNameForm = (props: {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameLabel'} />
<Trans i18nKey={'teams.teamNameLabel'} />
</FormLabel>
<FormControl>
@@ -117,7 +107,7 @@ export const UpdateTeamAccountNameForm = (props: {
<InputGroupInput
data-test={'team-name-input'}
required
placeholder={t('teams:teamNameInputLabel')}
placeholder={t('teamNameInputLabel')}
{...field}
/>
</InputGroup>
@@ -136,7 +126,7 @@ export const UpdateTeamAccountNameForm = (props: {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamSlugLabel'} />
<Trans i18nKey={'teams.teamSlugLabel'} />
</FormLabel>
<FormControl>
@@ -155,7 +145,7 @@ export const UpdateTeamAccountNameForm = (props: {
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:teamSlugDescription'} />
<Trans i18nKey={'teams.teamSlugDescription'} />
</FormDescription>
<FormMessage />
@@ -167,11 +157,12 @@ export const UpdateTeamAccountNameForm = (props: {
<div>
<Button
type="submit"
className={'w-full md:w-auto'}
data-test={'update-team-submit-button'}
disabled={pending}
disabled={isPending}
>
<Trans i18nKey={'teams:updateTeamSubmitLabel'} />
<Trans i18nKey={'teams.updateTeamSubmitLabel'} />
</Button>
</div>
</form>

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const AcceptInvitationSchema = z.object({
inviteToken: z.string().uuid(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
/**
* @name RESERVED_NAMES_ARRAY
@@ -40,20 +40,18 @@ export function containsNonLatinCharacters(value: string): boolean {
* @description Schema for validating URL-friendly slugs
*/
export const SlugSchema = z
.string({
description: 'URL-friendly identifier for the team',
})
.string()
.min(2)
.max(50)
.regex(SLUG_REGEX, {
message: 'teams:invalidSlugError',
message: 'teams.invalidSlugError',
})
.refine(
(slug) => {
return !RESERVED_NAMES_ARRAY.includes(slug.toLowerCase());
},
{
message: 'teams:reservedNameError',
message: 'teams.reservedNameError',
},
);
@@ -62,9 +60,7 @@ export const SlugSchema = z
* @description Schema for team name - allows non-Latin characters
*/
export const TeamNameSchema = z
.string({
description: 'The name of the team account',
})
.string()
.min(2)
.max(50)
.refine(
@@ -72,7 +68,7 @@ export const TeamNameSchema = z
return !SPECIAL_CHARACTERS_REGEX.test(name);
},
{
message: 'teams:specialCharactersError',
message: 'teams.specialCharactersError',
},
)
.refine(
@@ -80,7 +76,7 @@ export const TeamNameSchema = z
return !RESERVED_NAMES_ARRAY.includes(name.toLowerCase());
},
{
message: 'teams:reservedNameError',
message: 'teams.reservedNameError',
},
);
@@ -93,10 +89,11 @@ export const CreateTeamSchema = z
.object({
name: TeamNameSchema,
// Transform empty strings to undefined before validation
slug: z.preprocess(
(val) => (val === '' ? undefined : val),
SlugSchema.optional(),
),
slug: z
.string()
.optional()
.transform((val) => (val === '' ? undefined : val))
.pipe(SlugSchema.optional()),
})
.refine(
(data) => {
@@ -107,7 +104,7 @@ export const CreateTeamSchema = z
return true;
},
{
message: 'teams:slugRequiredForNonLatinName',
message: 'teams.slugRequiredForNonLatinName',
path: ['slug'],
},
);

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const DeleteInvitationSchema = z.object({
invitationId: z.number().int(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const DeleteTeamAccountSchema = z.object({
accountId: z.string().uuid(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
const InviteSchema = z.object({
email: z.string().email(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const LeaveTeamAccountSchema = z.object({
accountId: z.string().uuid(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const RemoveMemberSchema = z.object({
accountId: z.string().uuid(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const RenewInvitationSchema = z.object({
invitationId: z.number().positive(),

Some files were not shown because too many files have changed in this diff Show More