Skip to content

Commit

Permalink
Add a simple user list to admin panel (#36)
Browse files Browse the repository at this point in the history
  • Loading branch information
byn9826 authored Jul 21, 2024
1 parent e6cc34b commit df95b07
Show file tree
Hide file tree
Showing 15 changed files with 358 additions and 13 deletions.
7 changes: 5 additions & 2 deletions admin-panel/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
NEXT_PUBLIC_CLIENT_ID=a1b2c3 # Replace with your real clientId
NEXT_PUBLIC_REDIRECT_URI=http://localhost:3000 # Replace with the host of your react app
NEXT_PUBLIC_CLIENT_ID=a1b2c3 # Replace with your real SPA clientId
NEXT_PUBLIC_CLIENT_URI=http://localhost:3000 # Replace with the host of your react app
NEXT_PUBLIC_SERVER_URI=http://localhost:8787 # Replace with your melody-auth server base uri
SERVER_CLIENT_ID=b2c3d4 # Replace with your real S2S clientId
SERVER_CLIENT_SECRET=abc # Replace with your real S2S clientSecret
CLIENT_JWT_SECRET=abc # Replace with your ACCESS_TOKEN_SECRET
8 changes: 5 additions & 3 deletions admin-panel/app/Setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const LayoutSetup = ({ children } : PropsWithChildren) => {
const { logoutRedirect } = useAuth()

const handleLogout = () => {
logoutRedirect({ postLogoutRedirectUri: process.env.NEXT_PUBLIC_REDIRECT_URI })
logoutRedirect({ postLogoutRedirectUri: process.env.NEXT_PUBLIC_CLIENT_URI })
}

return (
Expand Down Expand Up @@ -91,7 +91,9 @@ const LayoutSetup = ({ children } : PropsWithChildren) => {
</Navbar.Link>
</Navbar.Collapse>
</Navbar>
{children}
<section className='p-6'>
{children}
</section>
</>
)
}
Expand All @@ -100,7 +102,7 @@ const Setup = ({ children } : PropsWithChildren) => {
return (
<AuthProvider
clientId={process.env.NEXT_PUBLIC_CLIENT_ID ?? ''}
redirectUri={process.env.NEXT_PUBLIC_REDIRECT_URI ?? ''}
redirectUri={`${process.env.NEXT_PUBLIC_CLIENT_URI}/en/dashboard` ?? ''}
serverUri={process.env.NEXT_PUBLIC_SERVER_URI ?? ''}
>
<AuthSetup>
Expand Down
24 changes: 24 additions & 0 deletions admin-panel/app/[lang]/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client'

import { useTranslations } from 'next-intl'
import { Button } from 'flowbite-react'
import Link from 'next/link'
import useCurrentLocale from 'hooks/useCurrentLocale'
import { routeTool } from 'tools'

const Page = () => {
const local = useCurrentLocale()
const t = useTranslations()
return (
<section className='flex'>
<Button
size='sm'
as={Link}
href={`/${local}/${routeTool.Internal.Users}`}>
{t('layout.users')}
</Button>
</section>
)
}

export default Page
6 changes: 3 additions & 3 deletions admin-panel/app/[lang]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

export default function Home () {
return (
<section>Home Page</section>
<section>
Home
</section>
)
}
64 changes: 64 additions & 0 deletions admin-panel/app/[lang]/users/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client'

import { useAuth } from '@melody-auth/react'
import {
Badge, Table,
} from 'flowbite-react'
import { useTranslations } from 'next-intl'
import {
useEffect, useState,
} from 'react'
import { proxyTool } from 'tools'

const Page = () => {
const t = useTranslations()

const [users, setUsers] = useState([])
const { acquireToken } = useAuth()

useEffect(
() => {
const getUsers = async () => {
const token = await acquireToken()
const data = await proxyTool.sendNextRequest({
endpoint: '/api/users',
method: 'GET',
token,
})
setUsers(data.users)
}

getUsers()
},
[acquireToken],
)

return (
<section>
<Table>
<Table.Head>
<Table.HeadCell>{t('users.authId')}</Table.HeadCell>
<Table.HeadCell>{t('users.email')}</Table.HeadCell>
<Table.HeadCell>{t('users.name')}</Table.HeadCell>
<Table.HeadCell>{t('users.status')}</Table.HeadCell>
</Table.Head>
<Table.Body className='divide-y'>
{users.map((user) => (
<Table.Row key={user.id}>
<Table.Cell>{user.authId}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell>{`${user.firstName ?? ''} ${user.lastName ?? ''}`} </Table.Cell>
<Table.Cell>
<div className='flex'>
{user.deletedAt ? (<Badge color='failure'>Disabled</Badge>) : (<Badge color='success'>Active</Badge>)}
</div>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</section>
)
}

export default Page
95 changes: 95 additions & 0 deletions admin-panel/app/api/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import jwt from 'jsonwebtoken'

let accessToken: string | null = null
let accessTokenExpiresOn: number | null = null

const basicAuth = btoa(`${process.env.SERVER_CLIENT_ID}:${process.env.SERVER_CLIENT_SECRET}`)

export const throwForbiddenError = (message?: string) => {
throw NextResponse.json(
{},
{
status: 400, statusText: message,
},
)
}

export const verifyAccessToken = () => {
const headersList = headers()
const authHeader = headersList.get('authorization')
const accessToken = authHeader?.split(' ')[1]
if (!accessToken) throwForbiddenError()

const tokenBody = jwt.verify(
accessToken,
process.env.CLIENT_JWT_SECRET,
)
if (!tokenBody) throwForbiddenError()

if (!tokenBody.roles || !tokenBody.roles.includes('super_admin')) throwForbiddenError()
}

export const obtainS2SAccessToken = async () => {
if (accessToken && accessTokenExpiresOn) {
const currentTime = new Date().getTime() / 1000
if (currentTime + 5 < accessTokenExpiresOn) return accessToken
}

const body = {
grant_type: 'client_credentials',
scope: 'read_user write_user',
}
const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URI}/oauth2/v1/token`,
{
method: 'POST',
headers: {
Authorization: `Basic ${basicAuth}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(body).toString(),
},
)
if (res.ok) {
const data = await res.json()
if (!data.scope || !data.access_token || !data.expires_on) {
throwForbiddenError()
}

if (!data.scope.includes('read_user')) throwForbiddenError('read_user scope required.')
if (!data.scope.includes('write_user')) throwForbiddenError('write_user scope required.')

accessToken = data.access_token
accessTokenExpiresOn = data.expires_on

return accessToken
} else {
throwForbiddenError()
}
}

export const sendS2SRequest = async ({
method,
uri,
}: {
uri: string;
method: 'GET';
}) => {
const token = await obtainS2SAccessToken()

const res = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URI}/api/v1${uri}`,
{
method,
headers: { Authorization: `Bearer ${token}` },
},
)
if (res.ok) {
const data = await res.json()
return data
} else {
throwForbiddenError()
}
}
14 changes: 14 additions & 0 deletions admin-panel/app/api/users/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server'
import {
verifyAccessToken, sendS2SRequest,
} from 'app/api/request'

export async function GET () {
verifyAccessToken()

const data = await sendS2SRequest({
method: 'GET',
uri: '/users?include_disabled=true',
})
return NextResponse.json(data)
}
1 change: 1 addition & 0 deletions admin-panel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@heroicons/react": "^2.1.4",
"@preact/signals-react": "^2.1.0",
"flowbite-react": "^0.10.1",
"jsonwebtoken": "^9.0.2",
"next": "14.2.5",
"next-intl": "^3.15.0",
"react": "^18",
Expand Down
2 changes: 2 additions & 0 deletions admin-panel/tools/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * as proxyTool from 'tools/proxy'
export * as routeTool from 'tools/route'
23 changes: 23 additions & 0 deletions admin-panel/tools/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export const sendNextRequest = async ({
endpoint,
method,
token,
}: {
endpoint: string;
method: 'GET';
token: string;
}) => {
const res = await fetch(
endpoint,
{
method,
headers: { Authorization: `bearer ${token}` },
},
)
if (res.ok) {
const data = await res.json()
return data
} else {
throw new Error('Can not fetch data')
}
}
4 changes: 4 additions & 0 deletions admin-panel/tools/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum Internal {
Dashboard = '/dashboard',
Users = '/users',
}
9 changes: 8 additions & 1 deletion admin-panel/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
"layout": {
"blocked": "Only super admin can access this app.",
"brand": "Melody Auth",
"logout": "Logout"
"logout": "Logout",
"users": "Manage Users"
},
"users": {
"authId": "Auth ID",
"email": "Email",
"name": "Name",
"status": "Status"
}
}
2 changes: 1 addition & 1 deletion docs/s2s-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ fetch('/oauth2/v1/token', {

| Property | Type | Required | Description |
| -------- | ---- | -------- | ----------- |
| ``includeDisabled`` | 'true' | false | If disabled users should be included. |
| ``include_disabled`` | 'true' | false | If disabled users should be included. |

### Request example

Expand Down
Loading

0 comments on commit df95b07

Please sign in to comment.