Skip to content

Commit 02dfd71

Browse files
[dev] [Itsnotaka] daniel/table-ui (#1814)
* feat(grants): add search and filter functionality to grants tab * feat(trust): enhance NDA response with portal URL and status messages * feat(trust): enhance NDA response with portal URL and status messages --------- Co-authored-by: Daniel Fu <[email protected]>
1 parent fa97962 commit 02dfd71

File tree

11 files changed

+477
-228
lines changed

11 files changed

+477
-228
lines changed

apps/api/src/trust-portal/trust-access.service.ts

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -485,33 +485,67 @@ export class TrustAccessService {
485485
organization: true,
486486
},
487487
},
488+
grant: true,
488489
},
489490
});
490491

491492
if (!nda) {
492493
throw new NotFoundException('NDA agreement not found');
493494
}
494495

496+
const trust = await db.trust.findUnique({
497+
where: { organizationId: nda.organizationId },
498+
select: { friendlyUrl: true },
499+
});
500+
501+
const portalUrl = trust?.friendlyUrl
502+
? `${this.TRUST_APP_URL}/${trust.friendlyUrl}`
503+
: null;
504+
505+
const baseResponse = {
506+
id: nda.id,
507+
organizationName: nda.accessRequest.organization.name,
508+
requesterName: nda.accessRequest.name,
509+
requesterEmail: nda.accessRequest.email,
510+
expiresAt: nda.signTokenExpiresAt,
511+
portalUrl,
512+
};
513+
495514
if (nda.signTokenExpiresAt < new Date()) {
496-
throw new BadRequestException('NDA signing link has expired');
515+
return {
516+
...baseResponse,
517+
status: 'expired',
518+
message: 'NDA signing link has expired',
519+
};
497520
}
498521

499522
if (nda.status === 'void') {
500-
throw new BadRequestException(
501-
'This NDA has been revoked and is no longer valid',
502-
);
523+
return {
524+
...baseResponse,
525+
status: 'void',
526+
message: 'This NDA has been revoked and is no longer valid',
527+
};
503528
}
504529

505-
if (nda.status !== 'pending') {
506-
throw new BadRequestException('NDA has already been signed');
530+
if (nda.status === 'signed') {
531+
let accessUrl = portalUrl;
532+
if (nda.grant?.accessToken && nda.grant.status === 'active') {
533+
if (trust?.friendlyUrl) {
534+
accessUrl = `${this.TRUST_APP_URL}/${trust.friendlyUrl}/access/${nda.grant.accessToken}`;
535+
}
536+
}
537+
538+
return {
539+
...baseResponse,
540+
status: 'signed',
541+
message: 'NDA has already been signed',
542+
portalUrl: accessUrl,
543+
};
507544
}
508545

509546
return {
510-
id: nda.id,
511-
organizationName: nda.accessRequest.organization.name,
512-
requesterName: nda.accessRequest.name,
513-
requesterEmail: nda.accessRequest.email,
514-
expiresAt: nda.signTokenExpiresAt,
547+
...baseResponse,
548+
status: 'pending',
515549
};
516550
}
517551

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
'use client';
2+
3+
import type { AccessGrant } from '@/hooks/use-access-requests';
4+
import { Badge } from '@comp/ui/badge';
5+
import { Button } from '@comp/ui/button';
6+
import type { ColumnDef } from '@tanstack/react-table';
7+
import { Copy } from 'lucide-react';
8+
import { toast } from 'sonner';
9+
10+
export type GrantTableRow = AccessGrant;
11+
12+
interface GrantColumnHandlers {
13+
onRevoke: (row: AccessGrant) => void;
14+
}
15+
16+
export function buildGrantColumns({
17+
onRevoke,
18+
}: GrantColumnHandlers): ColumnDef<GrantTableRow>[] {
19+
return [
20+
{
21+
id: 'date',
22+
accessorKey: 'createdAt',
23+
header: 'Date',
24+
cell: ({ row }) => {
25+
return (
26+
<span className="text-muted-foreground whitespace-nowrap text-xs">
27+
{new Date(row.original.createdAt).toLocaleDateString()}
28+
</span>
29+
);
30+
},
31+
},
32+
{
33+
id: 'identity',
34+
accessorKey: 'subjectEmail',
35+
header: 'Identity',
36+
cell: ({ row }) => {
37+
return <span className="font-medium text-sm">{row.original.subjectEmail}</span>;
38+
},
39+
},
40+
{
41+
id: 'status',
42+
accessorKey: 'status',
43+
header: 'Status',
44+
cell: ({ row }) => {
45+
const status = row.original.status;
46+
return (
47+
<Badge
48+
variant={
49+
status === 'active'
50+
? 'default'
51+
: status === 'revoked'
52+
? 'destructive'
53+
: 'secondary'
54+
}
55+
className="capitalize"
56+
>
57+
{status}
58+
</Badge>
59+
);
60+
},
61+
},
62+
{
63+
id: 'expires',
64+
accessorKey: 'expiresAt',
65+
header: 'Expires',
66+
cell: ({ row }) => {
67+
return (
68+
<span className="text-sm">
69+
{new Date(row.original.expiresAt).toLocaleDateString()}
70+
</span>
71+
);
72+
},
73+
},
74+
{
75+
id: 'revokedAt',
76+
accessorKey: 'revokedAt',
77+
header: 'Revoked',
78+
cell: ({ row }) => {
79+
if (!row.original.revokedAt) {
80+
return <span className="text-muted-foreground text-sm"></span>;
81+
}
82+
return (
83+
<span className="text-sm">
84+
{new Date(row.original.revokedAt).toLocaleDateString()}
85+
</span>
86+
);
87+
},
88+
},
89+
{
90+
id: 'actions',
91+
header: 'Actions',
92+
cell: ({ row }) => {
93+
const grant = row.original;
94+
95+
if (grant.status === 'active') {
96+
return (
97+
<Button
98+
size="sm"
99+
variant="destructive"
100+
onClick={() => onRevoke(grant)}
101+
className="h-8 px-2"
102+
>
103+
Revoke
104+
</Button>
105+
);
106+
}
107+
108+
return null;
109+
},
110+
},
111+
];
112+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use client';
2+
3+
import { DataTable } from '@/components/ui/data-table/DataTable';
4+
import type { AccessGrant } from '@/hooks/use-access-requests';
5+
import { buildGrantColumns, type GrantTableRow } from './grant-columns';
6+
7+
interface GrantDataTableProps {
8+
data: AccessGrant[];
9+
isLoading?: boolean;
10+
onRevoke: (row: AccessGrant) => void;
11+
}
12+
13+
export function GrantDataTable({ data, isLoading, onRevoke }: GrantDataTableProps) {
14+
const columns = buildGrantColumns({ onRevoke });
15+
16+
return (
17+
<DataTable
18+
data={data}
19+
columns={columns}
20+
isLoading={isLoading}
21+
emptyMessage="No access grants yet"
22+
density="compact"
23+
/>
24+
);
25+
}
Lines changed: 42 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,57 @@
11
import { useAccessGrants } from '@/hooks/use-access-requests';
2-
import { Badge } from '@comp/ui/badge';
3-
import { Button } from '@comp/ui/button';
4-
import { Skeleton } from '@comp/ui/skeleton';
5-
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@comp/ui/table';
2+
import { Input } from '@comp/ui/input';
3+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select';
64
import { useState } from 'react';
5+
import { GrantDataTable } from './grant-data-table';
76
import { RevokeDialog } from './revoke-dialog';
87

98
export function GrantsTab({ orgId }: { orgId: string }) {
109
const { data, isLoading } = useAccessGrants(orgId);
1110
const [revokeId, setRevokeId] = useState<string | null>(null);
1211

12+
const [search, setSearch] = useState('');
13+
const [status, setStatus] = useState<string | 'all'>('all');
14+
15+
const filtered = (data ?? []).filter((grant) => {
16+
const matchesSearch =
17+
!search || grant.subjectEmail.toLowerCase().includes(search.toLowerCase());
18+
19+
const matchesStatus = status === 'all' || grant.status === status;
20+
return matchesSearch && matchesStatus;
21+
});
22+
1323
return (
14-
<div className="space-y-3">
15-
<Table>
16-
<TableHeader>
17-
<TableRow>
18-
<TableHead>Email</TableHead>
19-
<TableHead>Status</TableHead>
20-
<TableHead>Expires</TableHead>
21-
<TableHead>Revoked</TableHead>
22-
<TableHead>Actions</TableHead>
23-
</TableRow>
24-
</TableHeader>
25-
<TableBody>
26-
{isLoading
27-
? Array.from({ length: 5 }).map((_, index) => (
28-
<TableRow key={index} className="h-[45px]">
29-
<TableCell className="w-[260px]">
30-
<Skeleton className="h-3.5 w-[80%]" />
31-
</TableCell>
32-
<TableCell className="w-[120px]">
33-
<Skeleton className="h-5 w-[70%]" />
34-
</TableCell>
35-
<TableCell className="w-[160px]">
36-
<Skeleton className="h-3.5 w-[60%]" />
37-
</TableCell>
38-
<TableCell className="w-[160px]">
39-
<Skeleton className="h-3.5 w-[60%]" />
40-
</TableCell>
41-
<TableCell className="w-[140px]">
42-
<Skeleton className="h-3.5 w-[70%]" />
43-
</TableCell>
44-
</TableRow>
45-
))
46-
: data && data.length > 0
47-
? data.map((grant) => (
48-
<TableRow key={grant.id}>
49-
<TableCell>{grant.subjectEmail}</TableCell>
50-
<TableCell>
51-
<Badge
52-
variant={
53-
grant.status === 'active'
54-
? 'default'
55-
: grant.status === 'revoked'
56-
? 'destructive'
57-
: 'secondary'
58-
}
59-
>
60-
{grant.status}
61-
</Badge>
62-
</TableCell>
63-
<TableCell>{new Date(grant.expiresAt).toLocaleDateString()}</TableCell>
64-
<TableCell>
65-
{grant.revokedAt ? new Date(grant.revokedAt).toLocaleDateString() : '-'}
66-
</TableCell>
67-
<TableCell>
68-
{grant.status === 'active' && (
69-
<Button
70-
size="sm"
71-
variant="destructive"
72-
onClick={() => setRevokeId(grant.id)}
73-
>
74-
Revoke
75-
</Button>
76-
)}
77-
</TableCell>
78-
</TableRow>
79-
))
80-
: (
81-
<TableRow>
82-
<TableCell
83-
colSpan={5}
84-
className="py-8 text-center text-sm text-muted-foreground"
85-
>
86-
No access grants yet
87-
</TableCell>
88-
</TableRow>
89-
)}
90-
</TableBody>
91-
</Table>
24+
<div className="space-y-4">
25+
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
26+
<Input
27+
placeholder="Search by email"
28+
value={search}
29+
onChange={(e) => setSearch(e.target.value)}
30+
className="h-8 max-w-md"
31+
/>
32+
<Select value={status} onValueChange={setStatus}>
33+
<SelectTrigger className="h-8 w-full md:w-[200px]">
34+
<SelectValue placeholder="Filter status" />
35+
</SelectTrigger>
36+
<SelectContent>
37+
<SelectItem value="all">All statuses</SelectItem>
38+
<SelectItem value="active">Active</SelectItem>
39+
<SelectItem value="revoked">Revoked</SelectItem>
40+
<SelectItem value="expired">Expired</SelectItem>
41+
</SelectContent>
42+
</Select>
43+
</div>
44+
45+
<GrantDataTable
46+
data={filtered}
47+
isLoading={isLoading}
48+
onRevoke={(row) => setRevokeId(row.id)}
49+
/>
50+
9251
{revokeId && (
9352
<RevokeDialog orgId={orgId} grantId={revokeId} onClose={() => setRevokeId(null)} />
9453
)}
9554
</div>
9655
);
9756
}
57+

0 commit comments

Comments
 (0)