1
0
Fork 0
mirror of https://codeberg.org/MarkusThielker/next-ory.git synced 2025-04-19 09:01:18 +00:00

Compare commits

..

22 commits

Author SHA1 Message Date
7961b50adb NORY-59: Handling of CVE-2025-29927 (#60)
Reviewed-on: #60
2025-04-07 09:40:29 +00:00
d4b453d71c NORY-59: refactor server action parameters 2025-04-07 11:36:57 +02:00
b4a7c6f396 NORY-59: update comments explaining the existing scripts 2025-04-06 19:17:17 +02:00
e4a62b914c NORY-59: create new script to initialise the admin role 2025-04-06 19:17:01 +02:00
bf9a3aac3b NORY-59: remove unused permission 2025-04-06 19:16:31 +02:00
50bedbb976 NORY-59: disable identity traits form if edit permission is missing 2025-04-06 19:16:01 +02:00
3151103195 NORY-59: fix permission in delete-identity server action 2025-04-06 17:59:45 +02:00
5494233e59 NORY-59: protect all missing cards in identity details 2025-04-06 15:08:05 +02:00
85234b4465 NORY-59: fix path-revalidation during rendering 2025-04-06 15:07:16 +02:00
b3be0440d1 NORY-59: refactor identity queries to use server actions 2025-04-06 13:02:56 +02:00
b72a45f39d NORY-59: add permission checks to identity action UI 2025-04-06 11:14:22 +02:00
Markus Thielker
0da4158d60 NORY-59: add protection to identity actions 2025-04-04 20:20:45 +02:00
Markus Thielker
f794f7d700 NORY-59: refactor permission-checks to use constant values 2025-04-04 19:58:57 +02:00
Markus Thielker
a72ca49271 NORY-59: replace 'force-admin-role' with new permission 2025-04-04 19:48:39 +02:00
Markus Thielker
86412e0133 NORY-59: introduce permission constants 2025-04-04 19:32:26 +02:00
Markus Thielker
3693b0b1f9 NORY-59: add authentication and authorisation to user page 2025-04-04 19:23:45 +02:00
Markus Thielker
4f06445869 NORY-59: refactor middleware to use new authentication functions 2025-04-04 16:31:15 +02:00
Markus Thielker
cbc6b05173 NORY-59: move stack status requests to protected server actions 2025-04-04 16:22:47 +02:00
Markus Thielker
48c14c08a2 NORY-59: add component to display insufficient permissions 2025-04-04 16:22:00 +02:00
Markus Thielker
7d7782a92c NORY-59: add authentication and authorisation actions 2025-04-04 16:20:32 +02:00
Markus Thielker
007098ca91 NORY-59: add new script to create Keto relationships 2025-04-04 16:20:25 +02:00
Markus Thielker
b2ce32a076 NORY-59: fix database relations file 2025-04-01 14:42:50 +02:00
15 changed files with 11 additions and 1320 deletions

Binary file not shown.

View file

@ -14,15 +14,13 @@
"@ory/integrations": "^1.1.5", "@ory/integrations": "^1.1.5",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-scroll-area": "^1.0.5", "@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-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@serwist/next": "^9.0.0-preview.21", "@serwist/next": "^9.0.0-preview.21",
@ -32,7 +30,6 @@
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.20.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"cmdk": "1.0.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3", "drizzle-orm": "^0.38.3",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",

View file

@ -1,28 +0,0 @@
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 (
<div className="space-y-4">
<div>
<p className="text-3xl font-bold leading-tight tracking-tight">Create OAuth2 Client</p>
<p className="text-lg font-light">
Configure your new OAuth2 Client.
</p>
</div>
<CreateClientForm action={createClient}/>
</div>
);
}

View file

@ -1,9 +1,4 @@
import { getOAuth2Api } from '@/ory/sdk/server'; 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'; import { ClientDataTable } from '@/app/(inside)/client/data-table';
export interface FetchClientPageProps { export interface FetchClientPageProps {
@ -34,12 +29,6 @@ function parseTokens(link: string) {
async function fetchClientPage({ pageSize, pageToken }: FetchClientPageProps) { async function fetchClientPage({ pageSize, pageToken }: FetchClientPageProps) {
'use server'; '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 oAuth2Api = await getOAuth2Api();
const response = await oAuth2Api.listOAuth2Clients({ const response = await oAuth2Api.listOAuth2Clients({
pageSize: pageSize, pageSize: pageSize,
@ -54,49 +43,24 @@ async function fetchClientPage({ pageSize, pageToken }: FetchClientPageProps) {
export default async function ListClientPage() { 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 pageSize = 100;
let pageToken: string = '00000000-0000-0000-0000-000000000000'; let pageToken: string = '00000000-0000-0000-0000-000000000000';
const initialFetch = pmAccessClient && await fetchClientPage({ pageSize, pageToken }); const initialFetch = await fetchClientPage({ pageSize, pageToken });
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="relative"> <div>
<p className="text-3xl font-bold leading-tight tracking-tight">OAuth2 Clients</p> <p className="text-3xl font-bold leading-tight tracking-tight">OAuth2 Clients</p>
<p className="text-lg font-light"> <p className="text-lg font-light">
See and manage all OAuth2 clients registered with your Ory Hydra instance See and manage all OAuth2 clients registered with your Ory Hydra instance
</p> </p>
{
pmCreateClient && (
<Button className="absolute bottom-0 right-0" asChild>
<Link href="/client/create">
Create new client
</Link>
</Button>
)
}
</div> </div>
{ <ClientDataTable
pmAccessClient ? data={initialFetch.data}
( pageSize={pageSize}
initialFetch && <ClientDataTable pageToken={initialFetch.tokens.get('next')}
data={initialFetch.data} fetchClientPage={fetchClientPage}/>
pageSize={pageSize}
pageToken={initialFetch.tokens.get('next')}
fetchClientPage={fetchClientPage}/>
)
:
<InsufficientPermission
permission={permission.client.it}
relation={relation.access}
identityId={identityId}/>
}
</div> </div>
); );
} }

View file

@ -76,14 +76,3 @@
@apply bg-sidebar text-foreground; @apply bg-sidebar text-foreground;
} }
} }
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -1,588 +0,0 @@
'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<typeof clientFormSchema>) => Promise<AxiosResponse<OAuth2Client, any>>;
}
export function CreateClientForm({ action }: CreateClientFormProps) {
const router = useRouter();
const [redirectUris, setRedirectUris] = useState<string[]>(['']);
const [postLogoutRedirectUris, setPostLogoutRedirectUris] = useState<string[]>(['']);
const form = useForm<z.infer<typeof clientFormSchema>>({
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<OAuth2Client>();
const handleSubmit = async (data: z.infer<typeof clientFormSchema>) => {
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 && (
<AlertDialog open={successDialogOpen} onOpenChange={() => setSuccessDialogOpen(false)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Client created</AlertDialogTitle>
</AlertDialogHeader>
Your client was created successfully. Make sure to safe the client secret!
<Input value={createdClient.client_secret} readOnly/>
</AlertDialogContent>
</AlertDialog>
)
}
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<Card>
<CardHeader>
<CardTitle>
Essentials
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="client_name"
render={({ field }) => (
<FormItem>
<FormLabel>Client Name</FormLabel>
<FormDescription>
The human-readable name of the client to be presented to the end-user during
authorization.
</FormDescription>
<FormControl>
<Input placeholder="ACME INC SSO" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="scope"
render={({ field }) => (
<FormItem>
<FormLabel>Scopes</FormLabel>
<FormDescription>
Scope is a string containing a space-separated list of scope values (as
described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use
when requesting access tokens.
</FormDescription>
<FormControl>
<Input placeholder="post:read post:write user:read" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
{redirectUris.map((uri, index) => (
<div key={index} className="mb-4">
<FormItem>
<FormLabel htmlFor={`redirect_uri-${index}`}>Redirect
URI {index + 1}</FormLabel>
<FormControl>
<div className="relative">
<Input
type="text"
id={`redirect_uri-${index}`}
value={uri}
placeholder="https://"
className="pr-10"
{...form.register(`redirect_uris.${index}`)}
onChange={(event) => handleInputChange(index, event)}
/>
{redirectUris.length > 1 && (
<Button
type="button"
size="icon"
variant="destructive"
className="absolute inset-y-0 right-0 rounded-l-none"
onClick={() => removeRedirectUri(index)}>
<Minus/>
</Button>
)}
</div>
</FormControl>
{form.formState.errors?.redirect_uris && form.formState.errors.redirect_uris[index] && (
<FormMessage>{form.formState.errors.redirect_uris[index].message}</FormMessage>
)}
</FormItem>
</div>
))}
<Button type="button" onClick={addRedirectUri}>
Add Redirect URI
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
Consent Screen
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="skip"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg">
<div className="space-y-0.5">
<FormLabel>
Skip consent
</FormLabel>
<FormDescription>
Whether or not the consent screen is skipped for this client
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{
!form.getValues('skip') && (
<>
<FormField
control={form.control}
name="logo_uri"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URI</FormLabel>
<FormDescription>
A URL string referencing the client's logo.
</FormDescription>
<FormControl>
<Input
placeholder="https://" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="policy_uri"
render={({ field }) => (
<FormItem>
<FormLabel>Policy URI</FormLabel>
<FormDescription>
A URL string pointing to a human-readable privacy policy
document
for the client that describes how the deployment organization
collects, uses, retains, and discloses personal data.
</FormDescription>
<FormControl>
<Input
placeholder="https://"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="tos_uri"
render={({ field }) => (
<FormItem>
<FormLabel>Terms URI</FormLabel>
<FormDescription>
A URL string pointing to a human-readable terms of service
document for the client that describes a contractual
relationship between the end-user and the client that the
end-user accepts when authorizing the client.
</FormDescription>
<FormControl>
<Input
placeholder="https://" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="owner"
render={({ field }) => (
<FormItem>
<FormLabel>Owner</FormLabel>
<FormDescription>
Owner is a string identifying the owner of the OAuth 2.0 Client.
</FormDescription>
<FormControl>
<Input placeholder="ACME INC" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</>
)
}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
Supported OAuth2 flows
</CardTitle>
<CardDescription>
Configure allowed grant types and response types for this OAuth2 Client.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="grant_types"
render={({ field }) => (
<FormItem>
<FormLabel>Grant types</FormLabel>
<FormControl>
{/* TODO: add multiselect component */}
<Input value="TODO: add multiselect component" readOnly disabled/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="response_types"
render={({ field }) => (
<FormItem>
<FormLabel>Response types</FormLabel>
<FormControl>
{/* TODO: add multiselect component */}
<Input value="TODO: add multiselect component" readOnly disabled/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormItem>
<FormLabel>Access token type</FormLabel>
<FormControl>
<Input value="opaque" readOnly disabled/>
</FormControl>
<FormMessage/>
</FormItem>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
Client authentication mechanism
</CardTitle>
<CardDescription>
Set the client authentication method for the token endpoint. By default the client
credentials must be sent in the body of an HTTP POST. This option can also specify for
sending the credentials encoded in the HTTP Authorization header or by using JSON Web
Tokens. Specify none for public clients (native apps, mobile apps) which can not have
secrets.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="token_endpoint_auth_method"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication method</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an authentication mechanism"/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="client_secret_post">HTTP Body <span
className="text-sm text-gray-500 ml-2">(client_secret_post)</span></SelectItem>
<SelectItem value="client_secret_basic">HTTP Basic Authorization<span
className="text-sm text-gray-500 ml-2">(client_secret_basic)</span></SelectItem>
<SelectItem value="private_key_jwt">JWT Authentication <span
className="text-sm text-gray-500 ml-2">(private_key_jwt)</span></SelectItem>
<SelectItem value="none">None <span
className="text-sm text-gray-500 ml-2">(none)</span></SelectItem>
</SelectContent>
</Select>
<FormMessage/>
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
OpenID Connect logout
</CardTitle>
<CardDescription>
Get more information about using front and backchannels here&nbsp;
<Button variant="link" className="p-0" asChild>
<Link target="_blank"
href="https://www.ory.sh/docs/oauth2-oidc/oidc-logout#openid-connect-front-channel-logout-10">
documentation
</Link>
</Button>.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="frontchannel_logout_session_required"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg">
<div className="space-y-0.5">
<FormLabel>
Frontchannel Logout Session Required
</FormLabel>
<FormDescription>
Boolean value specifying whether the Relay Party (RP) requires that
issuer and session ID query parameters be included to identify the RP
session with the OpenID provider (OP) when the Frontchannel Logout URI
is used. The default value is false.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="frontchannel_logout_uri"
render={({ field }) => (
<FormItem>
<FormLabel>Frontchannel Logout URI</FormLabel>
<FormDescription>
URL that will cause the Relying Party (RP) to log itself out when rendered
in an iframe by the OpenID provider (OP). An issuer query parameter and a
session ID query parameter MAY be included by the OpenID provider (OP) to
enable the Relying Party (RP) to validate the request and to determine which
of the potentially multiple sessions is to be logged out; if either is
included, both MUST be.
</FormDescription>
<FormControl>
<Input placeholder="https://" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="backchannel_logout_session_required"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg">
<div className="space-y-0.5">
<FormLabel>
Backchannel Logout Session Required
</FormLabel>
<FormDescription>
Boolean value specifying whether the Relying Party (RP) requires that a
session ID Claim be included in the Logout Token to identify the Relying
Party session with the OpenID provider (OP) when the Backchannel Logout
URI is used. If omitted, the default value is false.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="backchannel_logout_uri"
render={({ field }) => (
<FormItem>
<FormLabel>Backchannel Logout URI</FormLabel>
<FormDescription>
URL that will cause the Relying Party (RP) to log itself out when rendered
in an iframe by the OpenID provider (OP). An issuer query parameter and a
session ID query parameter MAY be included by the OpenID provider (OP) to
enable the Relying Party (RP) to validate the request and to determine which
of the potentially multiple sessions is to be logged out; if either is
included, both MUST be.
</FormDescription>
<FormControl>
<Input placeholder="https://" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="skip_logout_consent"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg">
<div className="space-y-0.5">
<FormLabel className="text-base">
Skip logout consent
</FormLabel>
<FormDescription>
Boolean value specifying whether the additional logout consent screen
should be skipped.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{postLogoutRedirectUris.map((uri, index) => (
<div key={index} className="mb-4">
<FormItem>
<FormLabel htmlFor={`post_logout_redirect_uri-${index}`}>
Post Logout Redirect URI {index + 1}</FormLabel>
<FormControl>
<div className="relative">
<Input
type="text"
id={`post_logout_redirect_uri-${index}`}
value={uri}
placeholder="https://"
className="pr-10"
{...form.register(`post_logout_redirect_uris.${index}`)}
onChange={(event) => handlePostLogoutInputChange(index, event)}
/>
{postLogoutRedirectUris.length > 1 && (
<Button
type="button"
size="icon"
variant="destructive"
className="absolute inset-y-0 right-0 rounded-l-none"
onClick={() => removePostLogoutRedirectUri(index)}>
<Minus/>
</Button>
)}
</div>
</FormControl>
{form.formState.errors?.post_logout_redirect_uris && form.formState.errors.post_logout_redirect_uris[index] && (
<FormMessage>{form.formState.errors.post_logout_redirect_uris[index].message}</FormMessage>
)}
</FormItem>
</div>
))}
<Button type="button" onClick={addPostLogoutRedirectUri}>
Add Post Logout Redirect URI
</Button>
</CardContent>
</Card>
<div className="space-x-2">
<Button type="button" variant="outline" onClick={() => {
router.back();
}}>Cancel</Button>
<Button type="submit">Create client</Button>
</div>
</form>
</Form>
</>
);
}

View file

@ -23,7 +23,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('flex flex-col space-y-1.5 pb-6 pt-6 sm:p-6', className)} className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props} {...props}
/> />
)); ));
@ -60,7 +60,7 @@ const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn('pb-6 sm:p-6 pt-0', className)} {...props} /> <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)); ));
CardContent.displayName = 'CardContent'; CardContent.displayName = 'CardContent';
@ -70,7 +70,7 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('flex items-center pb-6 sm:p-6 pt-0', className)} className={cn('flex items-center p-6 pt-0', className)}
{...props} {...props}
/> />
)); ));

View file

@ -1,154 +0,0 @@
'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<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50"/>
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=\'true\']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View file

@ -1,123 +0,0 @@
'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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay/>
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4"/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View file

@ -1,129 +0,0 @@
'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<HTMLInputElement>(null);
const [open, setOpen] = React.useState(false);
const [selected, setSelected] = React.useState<MultiSelectItem[]>([]);
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<HTMLDivElement>) => {
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 <input /> field
if (e.key === 'Escape') {
input.blur();
}
}
},
[],
);
const selectables = items.filter(
(item) => !selected.includes(item),
);
console.log(selectables, selected, inputValue);
return (
<Command
onKeyDown={handleKeyDown}
className="overflow-visible bg-transparent"
>
<div
className="group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<div className="flex flex-wrap gap-1">
{selected.map((item) => {
return (
<Badge key={item.value} variant="secondary">
{item.label}
<button
className="ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleUnselect(item);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(item)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground"/>
</button>
</Badge>
);
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder={placeholder ?? 'Select an item...'}
className="ml-2 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="relative mt-2">
<CommandList>
{open && selectables.length > 0 ? (
<div
className="absolute top-0 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
<CommandGroup className="h-full overflow-auto">
{selectables.map((item) => {
return (
<CommandItem
key={item.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue('');
setSelected((prev) => [...prev, item]);
}}
className={'cursor-pointer'}
>
{item.label}
</CommandItem>
);
})}
</CommandGroup>
</div>
) : null}
</CommandList>
</div>
</Command>
);
}

View file

@ -1,160 +0,0 @@
'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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50"/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4"/>
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4"/>
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton/>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton/>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4"/>
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View file

@ -1,29 +0,0 @@
'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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View file

@ -1,23 +0,0 @@
'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<typeof clientFormSchema>,
) {
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 });
}

View file

@ -1,22 +0,0 @@
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' }),
});

View file

@ -13,9 +13,6 @@ export const permission = {
state: 'admin.user.state', state: 'admin.user.state',
trait: 'admin.user.trait', trait: 'admin.user.trait',
}, },
client: {
it: 'admin.client',
},
}; };
export const relation = { export const relation = {