diff --git a/dashboard/bun.lockb b/dashboard/bun.lockb
index 457965a..4148b6f 100755
Binary files a/dashboard/bun.lockb and b/dashboard/bun.lockb differ
diff --git a/dashboard/package.json b/dashboard/package.json
index 9641b8b..589ca60 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -14,13 +14,15 @@
"@ory/integrations": "^1.1.5",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4",
- "@radix-ui/react-dialog": "^1.1.2",
+ "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-scroll-area": "^1.0.5",
+ "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.4",
"@serwist/next": "^9.0.0-preview.21",
@@ -30,6 +32,7 @@
"@tanstack/react-table": "^8.20.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
+ "cmdk": "1.0.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3",
"lucide-react": "^0.462.0",
diff --git a/dashboard/src/app/(inside)/client/create/page.tsx b/dashboard/src/app/(inside)/client/create/page.tsx
new file mode 100644
index 0000000..3aa3c9a
--- /dev/null
+++ b/dashboard/src/app/(inside)/client/create/page.tsx
@@ -0,0 +1,28 @@
+import { CreateClientForm } from '@/components/forms/client-form';
+import { createClient } from '@/lib/action/client';
+import { checkPermission, requireSession } from '@/lib/action/authentication';
+import { permission, relation } from '@/lib/permission';
+import { redirect } from 'next/navigation';
+
+export default async function CreateClientPage() {
+
+ const session = await requireSession();
+ const identityId = session.identity!.id;
+
+ const pmCreateClient = await checkPermission(permission.client.it, relation.create, identityId);
+ if (!pmCreateClient) {
+ return redirect('/client');
+ }
+
+ return (
+
+
+
Create OAuth2 Client
+
+ Configure your new OAuth2 Client.
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/dashboard/src/app/(inside)/client/page.tsx b/dashboard/src/app/(inside)/client/page.tsx
index 0d3f5a3..42e0092 100644
--- a/dashboard/src/app/(inside)/client/page.tsx
+++ b/dashboard/src/app/(inside)/client/page.tsx
@@ -1,4 +1,9 @@
import { getOAuth2Api } from '@/ory/sdk/server';
+import { Button } from '@/components/ui/button';
+import Link from 'next/link';
+import { checkPermission, requireSession } from '@/lib/action/authentication';
+import { permission, relation } from '@/lib/permission';
+import InsufficientPermission from '@/components/insufficient-permission';
import { ClientDataTable } from '@/app/(inside)/client/data-table';
export interface FetchClientPageProps {
@@ -29,6 +34,12 @@ function parseTokens(link: string) {
async function fetchClientPage({ pageSize, pageToken }: FetchClientPageProps) {
'use server';
+ const session = await requireSession();
+ const allowed = await checkPermission(permission.client.it, relation.access, session.identity!.id);
+ if (!allowed) {
+ throw Error('Unauthorised');
+ }
+
const oAuth2Api = await getOAuth2Api();
const response = await oAuth2Api.listOAuth2Clients({
pageSize: pageSize,
@@ -43,24 +54,49 @@ async function fetchClientPage({ pageSize, pageToken }: FetchClientPageProps) {
export default async function ListClientPage() {
+ const session = await requireSession();
+ const identityId = session.identity!.id;
+
+ const pmAccessClient = await checkPermission(permission.client.it, relation.access, identityId);
+ const pmCreateClient = await checkPermission(permission.client.it, relation.create, identityId);
+
let pageSize = 100;
let pageToken: string = '00000000-0000-0000-0000-000000000000';
- const initialFetch = await fetchClientPage({ pageSize, pageToken });
+ const initialFetch = pmAccessClient && await fetchClientPage({ pageSize, pageToken });
return (
-
+
OAuth2 Clients
See and manage all OAuth2 clients registered with your Ory Hydra instance
+ {
+ pmCreateClient && (
+
+ )
+ }
-
+ {
+ pmAccessClient ?
+ (
+ initialFetch &&
+ )
+ :
+
+ }
);
}
diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css
index 262da57..65bc799 100644
--- a/dashboard/src/app/globals.css
+++ b/dashboard/src/app/globals.css
@@ -75,4 +75,15 @@
body {
@apply bg-sidebar text-foreground;
}
+}
+
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+
+ body {
+ @apply bg-background text-foreground;
+ }
}
\ No newline at end of file
diff --git a/dashboard/src/components/forms/client-form.tsx b/dashboard/src/components/forms/client-form.tsx
new file mode 100644
index 0000000..0ca84b6
--- /dev/null
+++ b/dashboard/src/components/forms/client-form.tsx
@@ -0,0 +1,588 @@
+'use client';
+
+import { z } from 'zod';
+import { clientFormSchema } from '@/lib/forms/client-form';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { useState } from 'react';
+import { OAuth2Client } from '@ory/client';
+import { AxiosResponse } from 'axios';
+import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { useRouter } from 'next/navigation';
+import { Minus } from 'lucide-react';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Switch } from '@/components/ui/switch';
+import Link from 'next/link';
+
+interface CreateClientFormProps {
+ action: (data: z.infer
) => Promise>;
+}
+
+export function CreateClientForm({ action }: CreateClientFormProps) {
+
+ const router = useRouter();
+
+ const [redirectUris, setRedirectUris] = useState(['']);
+ const [postLogoutRedirectUris, setPostLogoutRedirectUris] = useState(['']);
+
+ const form = useForm>({
+ resolver: zodResolver(clientFormSchema),
+ defaultValues: {
+ client_name: '',
+ scope: '',
+ redirect_uris: [''],
+ skip: false,
+ logo_uri: '',
+ policy_uri: '',
+ tos_uri: '',
+ owner: '',
+ },
+ });
+
+ const [successDialogOpen, setSuccessDialogOpen] = useState(false);
+ const [createdClient, setCreatedClient] = useState();
+ const handleSubmit = async (data: z.infer) => {
+ await action(data)
+ .then((response) => {
+ console.log(response);
+ return response.data;
+ })
+ .then((client) => {
+ setCreatedClient(client);
+ setSuccessDialogOpen(true);
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+ };
+
+ const addRedirectUri = () => {
+ setRedirectUris([...redirectUris, '']);
+ };
+
+ const addPostLogoutRedirectUri = () => {
+ setPostLogoutRedirectUris([...postLogoutRedirectUris, '']);
+ };
+
+ const removeRedirectUri = (index: number) => {
+ const updatedRedirectUris = redirectUris.filter((_, i) => i !== index);
+ setRedirectUris(updatedRedirectUris);
+ form.setValue('redirect_uris', updatedRedirectUris);
+ };
+
+ const removePostLogoutRedirectUri = (index: number) => {
+ const updatedPostLogoutRedirectUris = postLogoutRedirectUris.filter((_, i) => i !== index);
+ setPostLogoutRedirectUris(postLogoutRedirectUris);
+ form.setValue('post_logout_redirect_uris', updatedPostLogoutRedirectUris);
+ };
+
+ const handleInputChange = (index: number, event: any) => {
+ const updatedRedirectUris = [...redirectUris];
+ updatedRedirectUris[index] = event.target.value;
+ setRedirectUris(updatedRedirectUris);
+ form.setValue('redirect_uris', updatedRedirectUris);
+ };
+
+ const handlePostLogoutInputChange = (index: number, event: any) => {
+ const updatedPostLogoutRedirectUris = [...postLogoutRedirectUris];
+ updatedPostLogoutRedirectUris[index] = event.target.value;
+ setPostLogoutRedirectUris(updatedPostLogoutRedirectUris);
+ form.setValue('post_logout_redirect_uris', updatedPostLogoutRedirectUris);
+ };
+
+ return (
+ <>
+ {
+ createdClient && (
+ setSuccessDialogOpen(false)}>
+
+
+ Client created
+
+ Your client was created successfully. Make sure to safe the client secret!
+
+
+
+ )
+ }
+
+
+ >
+ );
+}
diff --git a/dashboard/src/components/ui/card.tsx b/dashboard/src/components/ui/card.tsx
index 3c69472..22ccc98 100644
--- a/dashboard/src/components/ui/card.tsx
+++ b/dashboard/src/components/ui/card.tsx
@@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
));
@@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
CardContent.displayName = 'CardContent';
@@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
));
diff --git a/dashboard/src/components/ui/command.tsx b/dashboard/src/components/ui/command.tsx
new file mode 100644
index 0000000..f485776
--- /dev/null
+++ b/dashboard/src/components/ui/command.tsx
@@ -0,0 +1,154 @@
+'use client';
+
+import * as React from 'react';
+import { type DialogProps } from '@radix-ui/react-dialog';
+import { Command as CommandPrimitive } from 'cmdk';
+import { Search } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+import { Dialog, DialogContent } from '@/components/ui/dialog';
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+Command.displayName = CommandPrimitive.displayName;
+
+const CommandDialog = ({ children, ...props }: DialogProps) => {
+ return (
+
+ );
+};
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+
+CommandInput.displayName = CommandPrimitive.Input.displayName;
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandList.displayName = CommandPrimitive.List.displayName;
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName;
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+
+CommandItem.displayName = CommandPrimitive.Item.displayName;
+
+const CommandShortcut = ({
+ className,
+ ...props
+ }: React.HTMLAttributes) => {
+ return (
+
+ );
+};
+CommandShortcut.displayName = 'CommandShortcut';
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+};
diff --git a/dashboard/src/components/ui/dialog.tsx b/dashboard/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..c7b4160
--- /dev/null
+++ b/dashboard/src/components/ui/dialog.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import * as React from 'react';
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { X } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+ }: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = 'DialogHeader';
+
+const DialogFooter = ({
+ className,
+ ...props
+ }: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = 'DialogFooter';
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/dashboard/src/components/ui/multi-select.tsx b/dashboard/src/components/ui/multi-select.tsx
new file mode 100644
index 0000000..266eea7
--- /dev/null
+++ b/dashboard/src/components/ui/multi-select.tsx
@@ -0,0 +1,129 @@
+'use client';
+
+import * as React from 'react';
+import { X } from 'lucide-react';
+
+import { Badge } from '@/components/ui/badge';
+import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
+import { Command as CommandPrimitive } from 'cmdk';
+
+type MultiSelectItem = Record<'value' | 'label', string>;
+
+interface MultiSelectProps {
+ items: MultiSelectItem[];
+ placeholder?: string;
+}
+
+// Credits: https://github.com/mxkaske/mxkaske.dev/blob/main/components/craft/fancy-multi-select.tsx
+export function MultiSelect({ items, placeholder }: MultiSelectProps) {
+ const inputRef = React.useRef(null);
+ const [open, setOpen] = React.useState(false);
+ const [selected, setSelected] = React.useState([]);
+ const [inputValue, setInputValue] = React.useState('');
+
+ const handleUnselect = React.useCallback((item: MultiSelectItem) => {
+ setSelected((prev) => prev.filter((s) => s.value !== item.value));
+ }, []);
+
+ const handleKeyDown = React.useCallback(
+ (e: React.KeyboardEvent) => {
+ const input = inputRef.current;
+ if (input) {
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ if (input.value === '') {
+ setSelected((prev) => {
+ const newSelected = [...prev];
+ newSelected.pop();
+ return newSelected;
+ });
+ }
+ }
+ // This is not a default behaviour of the field
+ if (e.key === 'Escape') {
+ input.blur();
+ }
+ }
+ },
+ [],
+ );
+
+ const selectables = items.filter(
+ (item) => !selected.includes(item),
+ );
+
+ console.log(selectables, selected, inputValue);
+
+ return (
+
+
+
+ {selected.map((item) => {
+ return (
+
+ {item.label}
+
+
+ );
+ })}
+ {/* Avoid having the "Search" Icon */}
+ setOpen(false)}
+ onFocus={() => setOpen(true)}
+ placeholder={placeholder ?? 'Select an item...'}
+ className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
+ />
+
+
+
+
+ {open && selectables.length > 0 ? (
+
+
+ {selectables.map((item) => {
+ return (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onSelect={(value) => {
+ setInputValue('');
+ setSelected((prev) => [...prev, item]);
+ }}
+ className={'cursor-pointer'}
+ >
+ {item.label}
+
+ );
+ })}
+
+
+ ) : null}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/dashboard/src/components/ui/select.tsx b/dashboard/src/components/ui/select.tsx
new file mode 100644
index 0000000..ddd77ac
--- /dev/null
+++ b/dashboard/src/components/ui/select.tsx
@@ -0,0 +1,160 @@
+'use client';
+
+import * as React from 'react';
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { Check, ChevronDown, ChevronUp } from 'lucide-react';
+
+import { cn } from '@/lib/utils';
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = 'popper', ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/dashboard/src/components/ui/switch.tsx b/dashboard/src/components/ui/switch.tsx
new file mode 100644
index 0000000..4c01734
--- /dev/null
+++ b/dashboard/src/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import * as React from 'react';
+import * as SwitchPrimitives from '@radix-ui/react-switch';
+
+import { cn } from '@/lib/utils';
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+Switch.displayName = SwitchPrimitives.Root.displayName;
+
+export { Switch };
diff --git a/dashboard/src/lib/action/client.ts b/dashboard/src/lib/action/client.ts
new file mode 100644
index 0000000..fb5156b
--- /dev/null
+++ b/dashboard/src/lib/action/client.ts
@@ -0,0 +1,23 @@
+'use server';
+
+import { clientFormSchema } from '@/lib/forms/client-form';
+import { z } from 'zod';
+import { getOAuth2Api } from '@/ory/sdk/server';
+import { checkPermission, requireSession } from '@/lib/action/authentication';
+import { permission, relation } from '@/lib/permission';
+
+export async function createClient(
+ formData: z.infer,
+) {
+
+ const session = await requireSession();
+ const allowed = await checkPermission(permission.client.it, relation.create, session.identity!.id);
+ if (!allowed) {
+ throw Error('Unauthorised');
+ }
+
+ console.log(session.identity?.traits.email, 'posted form', formData);
+
+ const oauthApi = await getOAuth2Api();
+ return await oauthApi.createOAuth2Client({ oAuth2Client: formData });
+}
diff --git a/dashboard/src/lib/forms/client-form.ts b/dashboard/src/lib/forms/client-form.ts
new file mode 100644
index 0000000..ea3814e
--- /dev/null
+++ b/dashboard/src/lib/forms/client-form.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+
+export const clientFormSchema = z.object({
+ access_token_strategy: z.string().default('opaque').readonly(),
+ client_name: z.string().min(1, 'Client name is required'),
+ scope: z.string(),
+ redirect_uris: z.array(z.string().url({ message: 'Invalid URL' })).min(1, { message: 'At least one redirect URI is required' }),
+ skip: z.boolean(),
+ logo_uri: z.string().url(),
+ tos_uri: z.string().url(),
+ policy_uri: z.string().url(),
+ owner: z.string().min(1, 'Owner is required'),
+ grant_types: z.array(z.string()),
+ response_types: z.array(z.string()),
+ token_endpoint_auth_method: z.string(),
+ backchannel_logout_session_required: z.boolean().default(false),
+ backchannel_logout_uri: z.string().url(),
+ frontchannel_logout_session_required: z.boolean().default(false),
+ frontchannel_logout_uri: z.string().url(),
+ skip_logout_consent: z.boolean().default(false),
+ post_logout_redirect_uris: z.array(z.string().url({ message: 'Invalid URL' })).min(1, { message: 'At least one redirect URI is required' }),
+});
diff --git a/dashboard/src/lib/permission.ts b/dashboard/src/lib/permission.ts
index 9fc8ba0..38619ac 100644
--- a/dashboard/src/lib/permission.ts
+++ b/dashboard/src/lib/permission.ts
@@ -13,6 +13,9 @@ export const permission = {
state: 'admin.user.state',
trait: 'admin.user.trait',
},
+ client: {
+ it: 'admin.client',
+ },
};
export const relation = {