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:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
@@ -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`
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const DeletePersonalAccountSchema = z.object({
|
||||
otp: z.string().min(6),
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const UpdateEmailSchema = {
|
||||
withTranslation: (errorMessage: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const PasswordUpdateSchema = {
|
||||
withTranslation: (errorMessage: string) => {
|
||||
|
||||
@@ -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('/');
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'server-only';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
const ConfirmationSchema = z.object({
|
||||
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
/**
|
||||
* Schema for resetting a user's password
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -4,7 +4,5 @@
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -32,13 +32,13 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
|
||||
const methodKey = useMemo(() => {
|
||||
switch (methodType) {
|
||||
case 'password':
|
||||
return 'auth:methodPassword';
|
||||
return 'auth.methodPassword';
|
||||
case 'otp':
|
||||
return 'auth:methodOtp';
|
||||
return 'auth.methodOtp';
|
||||
case 'magic_link':
|
||||
return 'auth:methodMagicLink';
|
||||
return 'auth.methodMagicLink';
|
||||
case 'oauth':
|
||||
return 'auth:methodOauth';
|
||||
return 'auth.methodOauth';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -61,10 +61,10 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) {
|
||||
<Lightbulb className="h-3 w-3" />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey="auth:lastUsedMethodPrefix" />{' '}
|
||||
<Trans i18nKey="auth.lastUsedMethodPrefix" />{' '}
|
||||
<If condition={isOAuth && Boolean(providerName)}>
|
||||
<Trans
|
||||
i18nKey="auth:methodOauthWithProvider"
|
||||
i18nKey="auth.methodOauthWithProvider"
|
||||
values={{ provider: providerName }}
|
||||
components={{
|
||||
provider: <span className="text-muted-foreground font-medium" />,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ export const OauthProviders: React.FC<{
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={'auth:signInWithProvider'}
|
||||
i18nKey={'auth.signInWithProvider'}
|
||||
values={{
|
||||
provider: getProviderName(provider),
|
||||
}}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -102,7 +102,7 @@ export function PasswordSignUpForm({
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'auth:repeatPasswordDescription'} />
|
||||
<Trans i18nKey={'auth.repeatPasswordDescription'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
@@ -123,13 +123,13 @@ export function PasswordSignUpForm({
|
||||
>
|
||||
<If condition={captchaLoading}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth:verifyingCaptcha'} />
|
||||
<Trans i18nKey={'auth.verifyingCaptcha'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
<If condition={loading && !captchaLoading}>
|
||||
<span className={'animate-in fade-in slide-in-from-bottom-24'}>
|
||||
<Trans i18nKey={'auth:signingUp'} />
|
||||
<Trans i18nKey={'auth.signingUp'} />
|
||||
</span>
|
||||
</If>
|
||||
|
||||
@@ -139,7 +139,7 @@ export function PasswordSignUpForm({
|
||||
'animate-in fade-in slide-in-from-bottom-24 flex items-center'
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={'auth:signUpWithEmail'} />
|
||||
<Trans i18nKey={'auth.signUpWithEmail'} />
|
||||
|
||||
<ArrowRight
|
||||
className={
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { 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>
|
||||
|
||||
@@ -86,7 +86,7 @@ export function SignInMethodsContainer(props: {
|
||||
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background text-muted-foreground px-2">
|
||||
<Trans i18nKey="auth:orContinueWith" />
|
||||
<Trans i18nKey="auth.orContinueWith" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function SignUpMethodsContainer(props: {
|
||||
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background text-muted-foreground px-2">
|
||||
<Trans i18nKey="auth:orContinueWith" />
|
||||
<Trans i18nKey="auth.orContinueWith" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function TermsAndConditionsFormField(
|
||||
|
||||
<div className={'text-xs'}>
|
||||
<Trans
|
||||
i18nKey={'auth:acceptTermsAndConditions'}
|
||||
i18nKey={'auth.acceptTermsAndConditions'}
|
||||
components={{
|
||||
TermsOfServiceLink: (
|
||||
<Link
|
||||
@@ -29,7 +29,7 @@ export function TermsAndConditionsFormField(
|
||||
className={'underline'}
|
||||
href={'/terms-of-service'}
|
||||
>
|
||||
<Trans i18nKey={'auth:termsOfService'} />
|
||||
<Trans i18nKey={'auth.termsOfService'} />
|
||||
</Link>
|
||||
),
|
||||
PrivacyPolicyLink: (
|
||||
@@ -38,7 +38,7 @@ export function TermsAndConditionsFormField(
|
||||
className={'underline'}
|
||||
href={'/privacy-policy'}
|
||||
>
|
||||
<Trans i18nKey={'auth:privacyPolicy'} />
|
||||
<Trans i18nKey={'auth.privacyPolicy'} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { 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>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { PasswordSchema } from './password.schema';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ export function SignOutInvitationButton(
|
||||
window.location.assign(safePath);
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey={'teams:signInWithDifferentAccount'} />
|
||||
<Trans i18nKey={'teams.signInWithDifferentAccount'} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const AcceptInvitationSchema = z.object({
|
||||
inviteToken: z.string().uuid(),
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const DeleteInvitationSchema = z.object({
|
||||
invitationId: z.number().int(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const DeleteTeamAccountSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
const InviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const LeaveTeamAccountSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
export const RemoveMemberSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user