diff --git a/app/Http/Controllers/ApiKeyController.php b/app/Http/Controllers/ApiKeyController.php new file mode 100644 index 00000000..d7929243 --- /dev/null +++ b/app/Http/Controllers/ApiKeyController.php @@ -0,0 +1,63 @@ +authorize('viewAny', PersonalAccessToken::class); + + return Inertia::render('api-keys/index', [ + 'apiKeys' => ApiKeyResource::collection(user()->tokens()->simplePaginate(config('web.pagination_size'))), + ]); + } + + #[Post('/', name: 'api-keys.store')] + public function store(Request $request): RedirectResponse + { + $this->authorize('create', PersonalAccessToken::class); + + $this->validate($request, [ + 'name' => 'required|string|max:255', + 'permission' => 'required|in:read,write', + ]); + + $permissions = ['read']; + if ($request->input('permission') === 'write') { + $permissions[] = 'write'; + } + $token = user()->createToken($request->input('name'), $permissions); + + return back() + ->with('success', 'Api key created.') + ->with('data', [ + 'token' => $token->plainTextToken, + ]); + } + + #[Delete('/{apiKey}', name: 'api-keys.destroy')] + public function destroy(PersonalAccessToken $apiKey): RedirectResponse + { + $this->authorize('delete', $apiKey); + + $apiKey->delete(); + + return back()->with('success', 'Api Key deleted.'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 149359a4..e6309bf9 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -68,6 +68,10 @@ public function share(Request $request): array 'location' => $request->url(), ], 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', + 'flash' => [ + 'success' => fn () => $request->session()->get('success'), + 'data' => fn () => $request->session()->get('data'), + ], ]; } } diff --git a/app/Http/Resources/ApiKeyResource.php b/app/Http/Resources/ApiKeyResource.php new file mode 100644 index 00000000..e5e992be --- /dev/null +++ b/app/Http/Resources/ApiKeyResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'permissions' => $this->abilities, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 95d53232..31ab24b5 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -1,5 +1,5 @@ import { type BreadcrumbItem, type NavItem } from '@/types'; -import { BellIcon, CloudIcon, CodeIcon, DatabaseIcon, KeyIcon, ListIcon, TagIcon, UserIcon, UsersIcon } from 'lucide-react'; +import { BellIcon, CloudIcon, CodeIcon, DatabaseIcon, KeyIcon, ListIcon, PlugIcon, TagIcon, UserIcon, UsersIcon } from 'lucide-react'; import { ReactNode } from 'react'; import Layout from '@/layouts/app/layout'; @@ -49,6 +49,11 @@ const sidebarNavItems: NavItem[] = [ href: route('tags'), icon: TagIcon, }, + { + title: 'API Keys', + href: route('api-keys'), + icon: PlugIcon, + }, ]; export default function SettingsLayout({ children, breadcrumbs }: { children: ReactNode; breadcrumbs?: BreadcrumbItem[] }) { diff --git a/resources/js/pages/api-keys/components/columns.tsx b/resources/js/pages/api-keys/components/columns.tsx new file mode 100644 index 00000000..d3796a1a --- /dev/null +++ b/resources/js/pages/api-keys/components/columns.tsx @@ -0,0 +1,109 @@ +import { ColumnDef } from '@tanstack/react-table'; +import DateTime from '@/components/date-time'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import { useForm } from '@inertiajs/react'; +import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; +import FormSuccessful from '@/components/form-successful'; +import { useState } from 'react'; +import { ApiKey } from '@/types/api-key'; + +function Delete({ apiKey }: { apiKey: ApiKey }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete(route('api-keys.destroy', apiKey.id), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete {apiKey.name} + Delete api key + + + Are you sure you want to delete {apiKey.name}? + + + + Cancel + + + {form.processing && } + + Delete + + + + + ); +} + +export const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'permissions', + header: 'Permissions', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return row.original.permissions.includes('write') ? read & write : read; + }, + }, + { + accessorKey: 'created_at', + header: 'Created at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( + + + + + Open menu + + + + + + + + + ); + }, + }, +]; diff --git a/resources/js/pages/api-keys/components/create-api-key.tsx b/resources/js/pages/api-keys/components/create-api-key.tsx new file mode 100644 index 00000000..a9dfa92c --- /dev/null +++ b/resources/js/pages/api-keys/components/create-api-key.tsx @@ -0,0 +1,119 @@ +import { ClipboardCheckIcon, ClipboardIcon, LoaderCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { useForm } from '@inertiajs/react'; +import React, { FormEventHandler, ReactNode, useRef, useState } from 'react'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/ui/input-error'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; + +type ApiKeyForm = { + name: string; + permission: string; +}; + +export default function CreateApiKey({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [token, setToken] = useState(); + const tokenInputRef = useRef(null); + const [copySuccess, setCopySuccess] = useState(false); + const copyToClipboard = () => { + tokenInputRef.current?.select(); + navigator.clipboard.writeText(token || '').then(() => { + setCopySuccess(true); + setTimeout(() => { + setCopySuccess(false); + }, 2000); + }); + }; + + const form = useForm>({ + name: '', + permission: '', + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + form.post(route('api-keys.store'), { + onSuccess: (page) => { + const flash = page.props.flash as { data?: { token?: string } }; + setToken(flash.data?.token); + }, + }); + }; + + return ( + + {children} + + + Create an API key + Create a new api key + + + {token ? ( + + + + Token {copySuccess ? : } + + + + + ) : ( + + + Name + form.setData('name', e.target.value)} /> + + + + Name + form.setData('permission', value)}> + + + + + + + read + + + read & write + + + + + + + + )} + + {!token && ( + + + + Cancel + + + + {form.processing && } + Create + + + )} + + + ); +} diff --git a/resources/js/pages/api-keys/index.tsx b/resources/js/pages/api-keys/index.tsx new file mode 100644 index 00000000..bc53c8f9 --- /dev/null +++ b/resources/js/pages/api-keys/index.tsx @@ -0,0 +1,37 @@ +import SettingsLayout from '@/layouts/settings/layout'; +import { Head, usePage } from '@inertiajs/react'; +import Container from '@/components/container'; +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { DataTable } from '@/components/data-table'; +import React from 'react'; +import { ApiKey } from '@/types/api-key'; +import { columns } from '@/pages/api-keys/components/columns'; +import CreateApiKey from '@/pages/api-keys/components/create-api-key'; + +export default function ApiKeys() { + const page = usePage<{ + apiKeys: { + data: ApiKey[]; + }; + }>(); + return ( + + + + + + + + Docs + + + Create + + + + + + + ); +} diff --git a/resources/js/types/api-key.d.ts b/resources/js/types/api-key.d.ts new file mode 100644 index 00000000..8b124a5a --- /dev/null +++ b/resources/js/types/api-key.d.ts @@ -0,0 +1,9 @@ +export interface ApiKey { + id: number; + name: string; + permissions: string[]; + created_at: string; + updated_at: string; + + [key: string]: unknown; +}
+ Are you sure you want to delete {apiKey.name}? +