Skip to content

Commit

Permalink
[SOA-4] Create base Admin BO (#28)
Browse files Browse the repository at this point in the history
* feat 🎸 : prepare admin routing and guards

* chore 🏗️ (format): add prettierignore until address linting issues

* feat 🎸 : added administrative controllers, services, and redirects revisions

* refactor ✨ : tweak ssr config

* styles 💅 : ui enhacements to input and slottables

* fix ✅ : run lint

* chore 🏗️ : add husky
  • Loading branch information
mariadriana-deemaze authored Nov 15, 2024
1 parent 328ca63 commit 219b8ab
Show file tree
Hide file tree
Showing 21 changed files with 451 additions and 44 deletions.
22 changes: 22 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

echo '🏗️👷 Styling, testing and building your project before committing'

yarn format ||
(
echo '😤🏀👋😤 Prettier Check Failed. Run yarn format, add changes and try to commit again.';
false;
)

yarn lint ||
(
echo '😤🏀👋😤 ESLint Check Failed. Make the required changes listed above and try to commit again.'
false;
)

yarn typecheck ||
(
echo '😤🏀👋😤 TS Check Failed. Make the required changes listed above and try to commit again.'
false;
)
6 changes: 3 additions & 3 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
build/**
database/migrations/**
README.md
build/**
database/migrations/**
README.md
16 changes: 16 additions & 0 deletions app/controllers/admin_auth_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import AdminAuthService from '#services/admin_auth_service'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'

@inject()
export default class AdminAuthController {
constructor(protected readonly service: AdminAuthService) {}

async show(ctx: HttpContext) {
return await this.service.show(ctx)
}

async destroy(ctx: HttpContext) {
return await this.service.destroy(ctx)
}
}
7 changes: 7 additions & 0 deletions app/controllers/admin_users_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { HttpContext } from '@adonisjs/core/http'

export default class AdminUsersController {
async show(_ctx: HttpContext) {
// TODO: Implement
}
}
23 changes: 20 additions & 3 deletions app/middleware/auth_middleware.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Authenticators } from '@adonisjs/auth/types'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

Expand All @@ -11,12 +12,28 @@ export default class AuthMiddleware {
*/
redirectTo = '/auth/sign-in'

async handle(ctx: HttpContext, next: NextFn) {
/**
* The URL to redirect to, when authentication fails
*/
adminRedirectTo = '/admin/auth/sign-in'

async handle(
ctx: HttpContext,
next: NextFn,
_options: {
guards?: (keyof Authenticators)[]
} = {}
) {
let guard: keyof Authenticators = 'web'
if (ctx.route?.pattern.includes('admin')) guard = 'admin-web'

try {
await ctx.auth.authenticate()
await ctx.auth.authenticateUsing([guard])
return next()
} catch (error) {
return ctx.response.redirect().toPath(this.redirectTo)
return ctx.response
.redirect()
.toPath(guard === 'web' ? this.redirectTo : this.adminRedirectTo)
}
}
}
7 changes: 6 additions & 1 deletion app/models/user.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DateTime } from 'luxon'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm'
import { BaseModel, column, computed, hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { DbRememberMeTokensProvider } from '@adonisjs/auth/session'
Expand Down Expand Up @@ -38,6 +38,11 @@ export default class User extends compose(BaseModel, AuthFinder) {
@column()
role: AccountRole = AccountRole.USER

@computed({ serializeAs: null })
get isAdmin() {
return this.role === AccountRole.ADMIN
}

@column({ serializeAs: null })
declare password: string

Expand Down
41 changes: 41 additions & 0 deletions app/services/admin_auth_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { HttpContext } from '@adonisjs/core/http'
import User from '#models/user'
import Session from '#models/session'

export default class AdminAuthService {
async show(ctx: HttpContext) {
const { request, response, session } = ctx
const { email, password } = request.only(['email', 'password'])
try {
const user = await User.verifyCredentials(email, password)
return await this.authenticate(ctx, user)
} catch (error) {
session.flash('errors', {
email: 'Invalid authentication credentials.',
})
return response.redirect().back()
}
}

async destroy({ auth, session, response }: HttpContext) {
await auth.user!.related('sessions').query().where('sessionToken', session.sessionId).delete()
await auth.use('admin-web').logout()
return response.redirect().toPath('/admin/auth/sign-in')
}

private async authenticate(
{ auth, session, request, response }: HttpContext,
user: User
): Promise<void> {
if (!user.isAdmin) throw new Error('Restricted')
await auth.use('admin-web').login(user)
await Session.create({
userId: user.id,
ipAddress: request.ip(),
userAgent: request.header('User-Agent'),
sessionToken: session.sessionId,
})
session.put('admin-session-token', session.sessionId)
return response.redirect().toPath('/admin/index')
}
}
8 changes: 7 additions & 1 deletion config/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import type { InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
const authConfig = defineConfig({
default: 'web',
guards: {
web: sessionGuard({
'web': sessionGuard({
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
'admin-web': sessionGuard({
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
Expand Down
2 changes: 1 addition & 1 deletion config/inertia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const inertiaConfig = defineConfig({
*/
ssr: {
enabled: true,
entrypoint: 'inertia/app/ssr.tsx',
pages: (_ctx, page) => !page.startsWith('admin'),
},
})

Expand Down
20 changes: 20 additions & 0 deletions inertia/app/admin_layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ReactNode } from 'react'
import { Toaster } from '@/components/ui/toaster'
import { usePage } from '@inertiajs/react'
import favicon from '../../public/assets/images/favicon.svg'
import type { SharedProps } from '@adonisjs/inertia/types'
import NavBar from '@/components/admin/generic/nav'

export default function AdminLayout({ children }: { children: ReactNode }) {
const {
props: { user },
} = usePage<SharedProps>()
return (
<>
<link rel="icon" type="image/svg+xml" href={favicon} />
<NavBar user={user} />
<div className="container flex justify-start pt-20">{children}</div>
<Toaster />
</>
)
}
10 changes: 8 additions & 2 deletions inertia/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import '../css/app.css'
import { hydrateRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import { resolvePageComponent } from '@adonisjs/inertia/helpers'
import Layout from './layout'
import Layout from '@/app/layout'
import AdminLayout from '@/app/admin_layout'

const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS'

Expand All @@ -17,7 +18,12 @@ createInertiaApp({
`../pages/${name}.tsx`,
import.meta.glob(['../pages/**/*.tsx', '../images/**'])
)
page.default.layout ??= (children: any) => <Layout children={children} />
if (name.includes('admin/')) {
page.default.layout ??= (children: any) => <AdminLayout children={children} />
} else {
page.default.layout ??= (children: any) => <Layout children={children} />
}

return page
},
setup({ el, App, props }) {
Expand Down
99 changes: 99 additions & 0 deletions inertia/components/admin/generic/nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Link } from '@inertiajs/react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuShortcut,
} from '@/components/ui/dropdown_menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import AdonisLogo from '@/components/svg/logo'
import { UserResponse } from '#interfaces/user'
import { cn } from '@/lib/utils'

export default function NavBar({ user }: { user: UserResponse | null }) {
const LINKS: Record<'title' | 'link', string>[] = [
{
title: 'Home',
link: '/admin/index',
},
{
title: 'Reports',
link: '/admin/index',
},
]

return (
<div className="fixed top-0 bg-black w-full z-10">
<div className="border-b">
<div className="flex h-16 items-center px-4">
<nav className={cn('flex items-center space-x-4 lg:space-x-6')}>
<AdonisLogo className="h-6 w-6 fill-white" />
{LINKS.map(({ title, link }, index) => (
<Link
key={`link-${index}`}
href={link}
className="text-white text-sm font-medium transition-colors hover:text-primary"
>
{title}
</Link>
))}
</nav>
<div className="ml-auto flex items-center space-x-4">
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
<AvatarImage
src={user?.attachments ? user?.attachments?.avatar?.link : '#'}
alt={`${user.name} avatar image`}
/>
<AvatarFallback>{user.name ? user.name[0] : '-'}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.name} {user.surname}
</p>
<p className="text-xs truncate leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Link href={`/users/${user.id}`}>Profile</Link>
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={`/users/${user.id}/settings`}>Settings</Link>
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Link as="button" href={'/admin/auth/sign-out'} method="delete">
Log out
</Link>
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<></>
)}
</div>
</div>
</div>
</div>
)
}
Loading

0 comments on commit 219b8ab

Please sign in to comment.