Skip to content

Commit 5356064

Browse files
committed
feat: add design and revocation to credentials page
1 parent 17de48a commit 5356064

File tree

6 files changed

+290
-40
lines changed

6 files changed

+290
-40
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { authOptions } from '@/lib/auth';
2+
import { getServerSession } from 'next-auth';
3+
import { NextResponse, type NextRequest } from 'next/server';
4+
5+
const AUTHORIZED_ACCOUNTS = process.env.AUTHORIZED_ACCOUNTS
6+
? process.env.AUTHORIZED_ACCOUNTS.split(',')
7+
: [];
8+
9+
export async function POST(
10+
req: NextRequest,
11+
{ params }: { params: Promise<{ id: string }> },
12+
) {
13+
const id = (await params).id;
14+
let authorized = false;
15+
16+
if (
17+
process.env.BFF_API_KEY &&
18+
process.env.BFF_API_KEY === req.headers.get('x-api-key')
19+
) {
20+
authorized = true;
21+
}
22+
23+
if (!authorized) {
24+
const session = await getServerSession(authOptions);
25+
26+
if (!session) {
27+
return NextResponse.json(
28+
{
29+
error: 'Not authenticated',
30+
},
31+
{
32+
status: 401,
33+
},
34+
);
35+
}
36+
37+
if (
38+
!session ||
39+
!session.user?.email ||
40+
!AUTHORIZED_ACCOUNTS.includes(session.user.email)
41+
) {
42+
return NextResponse.json(
43+
{
44+
error: 'Not authorized',
45+
},
46+
{
47+
status: 401,
48+
},
49+
);
50+
}
51+
52+
const headers = new Headers();
53+
headers.append('Content-Type', 'application/json');
54+
headers.append('x-api-key', process.env.API_KEY || '');
55+
56+
try {
57+
const response = await fetch(
58+
`${process.env.NEXT_PUBLIC_ISSUER_ENDPOINT}/revocation/${id}`,
59+
{
60+
method: 'POST',
61+
headers,
62+
body: JSON.stringify({}),
63+
},
64+
);
65+
66+
if (!response.ok) {
67+
throw new Error('Something went wrong');
68+
}
69+
70+
return NextResponse.json({
71+
success: true,
72+
data: {},
73+
});
74+
} catch (error) {
75+
// console.error(error);
76+
return NextResponse.json(
77+
{
78+
error: 'Failed to revoke credential',
79+
},
80+
{
81+
status: 500,
82+
},
83+
);
84+
}
85+
}
86+
}
+3-40
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { CredentialsView } from '@/components/CredentialsView';
12
import { authOptions } from '@/lib/auth';
23
import { getServerSession } from 'next-auth';
34

@@ -18,6 +19,7 @@ export default async function Page() {
1819
// Not authorized
1920
return <div className="h-screen bg-gray-50">Not authorized</div>;
2021
}
22+
2123
const getCredentials = async () => {
2224
try {
2325
const result = await fetch(
@@ -38,44 +40,5 @@ export default async function Page() {
3840

3941
const credentials = await getCredentials();
4042

41-
return (
42-
<div className="h-screen bg-gray-50">
43-
<div className="flex flex-col items-center justify-center h-full">
44-
<h1 className="text-2xl font-bold">Credentials</h1>
45-
<div className="mt-4 flex flex-col gap-4">
46-
{credentials.map((credential) => (
47-
<div key={credential.vc.id} className="flex flex-row gap-2">
48-
<div>{credential.vc.id}</div>
49-
<div>{credential.vc.type.slice(1).join(', ')}</div>
50-
<div>{new Date(credential.iat * 1000).toLocaleString()}</div>
51-
<div>{credentialSubject(credential)}</div>
52-
</div>
53-
))}
54-
</div>
55-
</div>
56-
</div>
57-
);
43+
return <CredentialsView credentials={credentials} />;
5844
}
59-
60-
const credentialSubject = (credential: any) => {
61-
const credentialSubject = credential.vc.credentialSubject;
62-
63-
if (credential.vc.type.includes('EducationCredential')) {
64-
return (
65-
<div>
66-
{credentialSubject.currentFamilyName}{' '}
67-
{credentialSubject.currentGivenName}
68-
</div>
69-
);
70-
}
71-
72-
if (credential.vc.type.includes('CouponCredential')) {
73-
return (
74-
<div>
75-
{credentialSubject.couponId} {credentialSubject.couponName}
76-
</div>
77-
);
78-
}
79-
80-
return JSON.stringify(credentialSubject);
81-
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useModalStore } from '@/stores/modalStore';
2+
import { Modal, ModalContent } from '@nextui-org/react';
3+
4+
export const CredentialModal = () => {
5+
const isOpen = useModalStore.use.isCredentialModalOpen();
6+
const credential = useModalStore.use.selectedCredential();
7+
const setIsOpen = useModalStore.use.setIsCredentialModalOpen();
8+
9+
return (
10+
<Modal
11+
className="p-8 overflow-auto max-w-2xl"
12+
isOpen={isOpen}
13+
onClose={() => setIsOpen(false)}
14+
>
15+
<ModalContent className="overflow-auto">
16+
<textarea
17+
className="dark:text-navy-blue-800 dark:bg-navy-blue-300 scrollbar-thin scrollbar-thumb-orange-300/0 scrollbar-thumb-rounded-full font-jetbrains-mono min-h-[60vh] w-full resize-none rounded-2xl bg-gray-100 p-2 text-gray-700 focus:outline-none"
18+
disabled
19+
value={JSON.stringify(credential, null, 4)}
20+
/>
21+
</ModalContent>
22+
</Modal>
23+
);
24+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
'use client';
2+
3+
import { useModalStore } from '@/stores/modalStore';
4+
import clsx from 'clsx';
5+
import { useRouter } from 'next/navigation';
6+
import { CredentialModal } from './CredentialModal';
7+
8+
export const CredentialsView = ({
9+
credentials,
10+
}: {
11+
credentials: any[];
12+
}) => {
13+
const { refresh } = useRouter();
14+
15+
const setIsCredentialModalOpen = useModalStore.use.setIsCredentialModalOpen();
16+
const setSelectedCredential = useModalStore.use.setSelectedCredential();
17+
18+
const handleRevoke = async (id: string) => {
19+
try {
20+
const response = await fetch(`/api/revoke-credential/${id}`, {
21+
method: 'POST',
22+
headers: {
23+
'x-api-key': process.env.API_KEY || '',
24+
},
25+
});
26+
27+
if (!response.ok) {
28+
throw new Error('Something went wrong');
29+
}
30+
refresh();
31+
} catch (error) {
32+
console.error(error);
33+
}
34+
};
35+
36+
return (
37+
<>
38+
<div className="w-full max-w-7xl mx-auto h-full flex flex-col p-8">
39+
<div className="bg-green-500 text-white py-4 px-6 rounded-t-lg">
40+
<h1 className="text-2xl font-bold text-center">Issued Credentials</h1>
41+
</div>
42+
<div className="flex-grow overflow-auto bg-white shadow-md rounded-b-lg">
43+
<table className="min-w-full">
44+
<tbody>
45+
{credentials.map(({ credential, isRevoked }, index) => (
46+
<tr
47+
key={credential.vc.id}
48+
className={clsx(
49+
'border-b border-gray-100 transition-colors',
50+
credential.revoked
51+
? 'bg-red-50 hover:bg-red-100'
52+
: index % 2 === 0
53+
? 'bg-white hover:bg-gray-50'
54+
: 'bg-gray-50 hover:bg-gray-100',
55+
)}
56+
>
57+
<td className="py-4 px-6">
58+
<div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4">
59+
<div className="flex-1 min-w-0">
60+
<div className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4">
61+
<div className="text-xs font-mono truncate max-w-[150px] md:max-w-[250px]">
62+
{credential.vc.id}
63+
</div>
64+
<span className="inline-block px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-md">
65+
{credential.vc.type.slice(1).join(', ')}
66+
</span>
67+
<span className="text-sm text-gray-600">
68+
{new Date(credential.iat * 1000).toLocaleString()}
69+
</span>
70+
<span className="text-sm font-medium text-gray-800">
71+
{credentialSubject(credential)}
72+
</span>
73+
</div>
74+
</div>
75+
<div className="flex-shrink-0 mt-2 md:mt-0">
76+
<button
77+
type="button"
78+
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md transition-colors"
79+
onClick={() => {
80+
setSelectedCredential(credential);
81+
setIsCredentialModalOpen(true);
82+
}}
83+
>
84+
View
85+
</button>{' '}
86+
{isRevoked ? (
87+
<span className="px-4 py-2 bg-red-200 text-red-800 rounded-md">
88+
Revoked
89+
</span>
90+
) : (
91+
<button
92+
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md transition-colors"
93+
type="button"
94+
onClick={() => handleRevoke(credential.vc.id)}
95+
>
96+
Revoke
97+
</button>
98+
)}
99+
</div>
100+
</div>
101+
</td>
102+
</tr>
103+
))}
104+
</tbody>
105+
</table>
106+
</div>
107+
</div>
108+
<CredentialModal />
109+
</>
110+
);
111+
};
112+
113+
const credentialSubject = (credential: any) => {
114+
const credentialSubject = credential.vc.credentialSubject;
115+
116+
if (credential.vc.type.includes('EducationCredential')) {
117+
return (
118+
<div>
119+
{credentialSubject.currentFamilyName}{' '}
120+
{credentialSubject.currentGivenName}
121+
</div>
122+
);
123+
}
124+
125+
if (credential.vc.type.includes('CouponCredential')) {
126+
return (
127+
<div>
128+
{credentialSubject.couponId} {credentialSubject.couponName}
129+
</div>
130+
);
131+
}
132+
133+
return JSON.stringify(credentialSubject);
134+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { StoreApi, UseBoundStore } from 'zustand';
2+
3+
type WithSelectors<S> = S extends { getState: () => infer T }
4+
? S & { use: { [K in keyof T]: () => T[K] } }
5+
: never;
6+
7+
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
8+
_store: S,
9+
) => {
10+
const store = _store as WithSelectors<typeof _store>;
11+
store.use = {};
12+
for (const k of Object.keys(store.getState())) {
13+
(store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
14+
}
15+
16+
return store;
17+
};
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { create } from 'zustand';
2+
import { createSelectors } from './createSelectors';
3+
4+
type ModalStore = {
5+
isCredentialModalOpen: boolean;
6+
selectedCredential: any;
7+
8+
setIsCredentialModalOpen: (open: boolean) => void;
9+
setSelectedCredential: (credential: any) => void;
10+
};
11+
12+
export const modalStoreInitialState = {
13+
isCredentialModalOpen: false,
14+
selectedCredential: null,
15+
};
16+
17+
const useModalStoreBase = create<ModalStore>()((set) => ({
18+
...modalStoreInitialState,
19+
20+
setIsCredentialModalOpen: (open: boolean) =>
21+
set((state) => ({ ...state, isCredentialModalOpen: open })),
22+
setSelectedCredential: (credential: any) =>
23+
set((state) => ({ ...state, selectedCredential: credential })),
24+
}));
25+
26+
export const useModalStore = createSelectors(useModalStoreBase);

0 commit comments

Comments
 (0)