Skip to content

Commit 030f187

Browse files
Ayush4958yash-pouranikcoderabbitai[bot]
authored
Created PAT visual for managing (#338)
* Enabled Visuals for PAT * Fixes the comments from CodeRabbit * Apply suggestions from code review Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Handle clipboard write errors to prevent misleading "Copied" state Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Yash Pouranik <yashpouranik124@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent a0d3066 commit 030f187

3 files changed

Lines changed: 347 additions & 2 deletions

File tree

apps/dashboard-api/src/controllers/pat.controller.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ const { Developer, PAT, generatePAT, redis, AppError, ApiResponse } = require('@
22

33
exports.createPAT = async (req, res, next) => {
44
try {
5+
if (!req.user || !req.user.isVerified) {
6+
return next(new AppError(403, 'Forbidden: You must verify your account to access Personal Access Tokens.'));
7+
}
8+
59
const { label, type = 'human', scopes, ttlDays } = req.body;
610

711
if (!label) return next(new AppError(400, "Token label is required."));
@@ -48,6 +52,10 @@ exports.createPAT = async (req, res, next) => {
4852

4953
exports.listPATs = async (req, res, next) => {
5054
try {
55+
if (!req.user || !req.user.isVerified) {
56+
return next(new AppError(403, 'Forbidden: You must verify your account to access Personal Access Tokens.'));
57+
}
58+
5159
const pats = await PAT.find({ developer: req.user._id }).sort({ createdAt: -1 });
5260

5361
// only show masked suffix and metadata
@@ -72,19 +80,22 @@ exports.listPATs = async (req, res, next) => {
7280

7381
exports.revokePAT = async (req, res, next) => {
7482
try {
83+
if (!req.user || !req.user.isVerified) {
84+
return next(new AppError(403, 'Forbidden: You must verify your account to access Personal Access Tokens.'));
85+
}
86+
7587
const { id } = req.params;
7688

7789
const patToRevoke = await PAT.findOneAndDelete({ _id: id, developer: req.user._id });
7890

7991
if (!patToRevoke) {
8092
return next(new AppError(404, "Token not found"));
81-
}
82-
8393
// forcefully clear the Redis cache so ongoing sessions are immediately killed
8494
try {
8595
await redis.del(`cli:pat:cache:${patToRevoke.tokenHash}`);
8696
} catch (redisErr) {
8797
console.error("Failed to clear PAT from Redis cache:", redisErr);
98+
return next(new AppError(503, "Unable to revoke token right now"));
8899
}
89100

90101
return new ApiResponse({}, "Token revoked successfully").send(res, 200);
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import { useState, useEffect, useCallback, useRef } from 'react';
2+
import api from '../utils/api';
3+
import toast from 'react-hot-toast';
4+
import { Key, Trash2, Plus, Copy, CheckCircle, AlertTriangle } from 'lucide-react';
5+
import ConfirmationModal from '../pages/ConfirmationModal';
6+
7+
const formatDate = (dateString) => {
8+
if (!dateString) return 'Never';
9+
const d = new Date(dateString);
10+
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
11+
};
12+
13+
const formatRelativeTime = (dateString) => {
14+
if (!dateString) return 'Never';
15+
const diffMs = new Date(dateString) - new Date();
16+
if (diffMs <= 0) return 'Expired';
17+
18+
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
19+
if (diffDays === 1) return 'in 1 day';
20+
if (diffDays > 300) return 'in 1 year';
21+
return `in ${diffDays} days`;
22+
};
23+
24+
const DEFAULT_FORM = { label: '', ttlDays: 30 };
25+
26+
export default function PATManager() {
27+
const [pats, setPats] = useState([]);
28+
const [loading, setLoading] = useState(true);
29+
30+
// Create Modal State
31+
const [showCreateModal, setShowCreateModal] = useState(false);
32+
const [creating, setCreating] = useState(false);
33+
const [newPatForm, setNewPatForm] = useState(DEFAULT_FORM);
34+
35+
// Token Reveal State
36+
const [newRawToken, setNewRawToken] = useState(null);
37+
const [copied, setCopied] = useState(false);
38+
39+
// Revoke Modal State
40+
const [revokeId, setRevokeId] = useState(null);
41+
const [revoking, setRevoking] = useState(false);
42+
43+
// Ref for clipboard timeout cleanup
44+
const copyTimerRef = useRef(null);
45+
46+
const fetchPats = useCallback(async () => {
47+
try {
48+
setLoading(true);
49+
const res = await api.get('/api/user/pats');
50+
setPats(res.data.data?.pats || []);
51+
} catch (err) {
52+
console.error(err);
53+
toast.error("Failed to load Personal Access Tokens");
54+
} finally {
55+
setLoading(false);
56+
}
57+
}, []);
58+
59+
/* eslint-disable react-hooks/set-state-in-effect */
60+
useEffect(() => {
61+
fetchPats();
62+
}, [fetchPats]);
63+
/* eslint-enable react-hooks/set-state-in-effect */
64+
65+
// Cleanup copy timer on unmount to prevent memory leaks
66+
useEffect(() => {
67+
return () => {
68+
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
69+
};
70+
}, []);
71+
72+
const handleCreate = useCallback(async (e) => {
73+
e.preventDefault();
74+
if (!newPatForm.label.trim()) return toast.error("Label is required");
75+
76+
setCreating(true);
77+
try {
78+
const res = await api.post('/api/user/pats', {
79+
label: newPatForm.label,
80+
ttlDays: Number(newPatForm.ttlDays),
81+
scopes: ['api:all']
82+
});
83+
84+
const created = res.data.data;
85+
86+
// Show the PAT only once
87+
setNewRawToken(created.rawToken);
88+
setShowCreateModal(false);
89+
setNewPatForm(DEFAULT_FORM);
90+
91+
// Optimistic local append — avoids a redundant GET /api/user/pats round-trip
92+
if (created.id || created._id) {
93+
setPats(prev => [...prev, {
94+
id: created.id || created._id,
95+
label: created.label,
96+
suffix: created.suffix,
97+
expiresAt: created.expiresAt,
98+
createdAt: created.createdAt,
99+
lastUsedAt: null,
100+
lastUsedIp: null
101+
}]);
102+
} else {
103+
// Fallback: if backend doesn't return the full object, refetch
104+
fetchPats();
105+
}
106+
} catch (err) {
107+
toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to generate token");
108+
} finally {
109+
setCreating(false);
110+
}
111+
}, [newPatForm, fetchPats]);
112+
113+
const handleRevoke = useCallback(async () => {
114+
if (!revokeId || revoking) return;
115+
setRevoking(true);
116+
const currentRevokeId = revokeId;
117+
118+
try {
119+
await api.delete(`/api/user/pats/${currentRevokeId}`);
120+
toast.success("Token revoked successfully");
121+
// Optimistic local filter — no server round-trip needed
122+
setPats(prev => prev.filter(p => (p._id || p.id) !== currentRevokeId));
123+
} catch (err) {
124+
if (err.response?.status !== 404) {
125+
toast.error(err.response?.data?.message || "Failed to revoke token");
126+
} else {
127+
// Already deleted on server, just clean up local state
128+
setPats(prev => prev.filter(p => (p._id || p.id) !== currentRevokeId));
129+
}
130+
} finally {
131+
setRevokeId(null);
132+
setRevoking(false);
133+
}
134+
}, [revokeId, revoking]);
135+
136+
const copyToClipboard = useCallback(async (text) => {
137+
try {
138+
await navigator.clipboard.writeText(text);
139+
setCopied(true);
140+
} catch {
141+
toast.error("Failed to copy token");
142+
return;
143+
}
144+
// Clear any existing timer before setting a new one
145+
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
146+
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
147+
}, []);
148+
149+
return (
150+
<div className="card" style={{ marginBottom: '2.5rem' }}>
151+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
152+
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
153+
<div style={{ padding: '10px', background: 'rgba(62, 207, 142, 0.1)', borderRadius: '10px', color: 'var(--color-primary)' }}>
154+
<Key size={20} />
155+
</div>
156+
<div>
157+
<h3 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: '2px' }}>Personal Access Tokens</h3>
158+
<p style={{ fontSize: '0.85rem', color: 'var(--color-text-muted)' }}>Generate tokens to securely authenticate with the urBackend CLI.</p>
159+
</div>
160+
</div>
161+
<button
162+
className="btn btn-primary"
163+
onClick={() => setShowCreateModal(true)}
164+
style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}
165+
>
166+
<Plus size={16} /> Generate Token
167+
</button>
168+
</div>
169+
170+
{loading ? (
171+
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)' }}>Loading tokens...</div>
172+
) : pats.length === 0 ? (
173+
<div style={{ padding: '2rem', textAlign: 'center', background: 'var(--color-bg-input)', borderRadius: '8px', border: '1px dashed var(--color-border)' }}>
174+
<p style={{ color: 'var(--color-text-muted)', marginBottom: '1rem' }}>You don't have any Active Personal Access Tokens.</p>
175+
</div>
176+
) : (
177+
<div style={{ overflowX: 'auto', borderRadius: '8px', border: '1px solid var(--color-border)' }}>
178+
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', fontSize: '0.9rem' }}>
179+
<thead style={{ background: 'var(--color-bg-input)', borderBottom: '1px solid var(--color-border)' }}>
180+
<tr>
181+
<th style={{ padding: '12px 16px', fontWeight: 500, color: 'var(--color-text-muted)' }}>Label</th>
182+
<th style={{ padding: '12px 16px', fontWeight: 500, color: 'var(--color-text-muted)' }}>Token</th>
183+
<th style={{ padding: '12px 16px', fontWeight: 500, color: 'var(--color-text-muted)' }}>Expires</th>
184+
<th style={{ padding: '12px 16px', fontWeight: 500, color: 'var(--color-text-muted)' }}>Last Used</th>
185+
<th style={{ padding: '12px 16px', fontWeight: 500, color: 'var(--color-text-muted)', width: '60px' }} aria-label="Actions"></th>
186+
</tr>
187+
</thead>
188+
<tbody>
189+
{pats.map((pat) => (
190+
<tr key={pat.id || pat._id} style={{ borderBottom: '1px solid var(--color-border)' }}>
191+
<td style={{ padding: '12px 16px', fontWeight: 500 }}>{pat.label}</td>
192+
<td style={{ padding: '12px 16px', fontFamily: 'monospace', color: 'var(--color-text-muted)' }}>ubpat_***{pat.suffix}</td>
193+
<td style={{ padding: '12px 16px' }}>{formatRelativeTime(pat.expiresAt)}</td>
194+
<td style={{ padding: '12px 16px' }}>
195+
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
196+
<span>{formatDate(pat.lastUsedAt)}</span>
197+
{pat.lastUsedIp && <span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>{pat.lastUsedIp}</span>}
198+
</div>
199+
</td>
200+
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
201+
<button
202+
onClick={() => setRevokeId(pat.id || pat._id)}
203+
style={{ background: 'none', border: 'none', color: '#ea5455', cursor: 'pointer', padding: '6px' }}
204+
title="Revoke Token"
205+
aria-label={`Revoke token ${pat.label}`}
206+
>
207+
<Trash2 size={18} />
208+
</button>
209+
</td>
210+
</tr>
211+
))}
212+
</tbody>
213+
</table>
214+
</div>
215+
)}
216+
217+
{/* Create PAT Modal */}
218+
{showCreateModal && (
219+
<div
220+
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}
221+
role="dialog"
222+
aria-modal="true"
223+
aria-labelledby="create-pat-modal-title"
224+
>
225+
<div className="card" style={{ width: '100%', maxWidth: '400px', margin: '20px', padding: '2rem' }}>
226+
<h3 id="create-pat-modal-title" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Generate New Token</h3>
227+
<p style={{ fontSize: '0.85rem', color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>
228+
This token will give full access to your developer account from the CLI.
229+
</p>
230+
231+
<form onSubmit={handleCreate}>
232+
<div className="form-group" style={{ marginBottom: '1rem' }}>
233+
<label className="form-label">Token Label</label>
234+
<input
235+
type="text"
236+
className="input-field"
237+
placeholder="e.g. GitHub Actions CI"
238+
value={newPatForm.label}
239+
onChange={(e) => setNewPatForm({...newPatForm, label: e.target.value})}
240+
required
241+
autoFocus
242+
maxLength={100}
243+
/>
244+
</div>
245+
<div className="form-group" style={{ marginBottom: '1.5rem' }}>
246+
<label className="form-label">Expiration</label>
247+
<select
248+
className="input-field"
249+
value={newPatForm.ttlDays}
250+
onChange={(e) => setNewPatForm({...newPatForm, ttlDays: e.target.value})}
251+
style={{ width: '100%' }}
252+
>
253+
<option value="7">7 Days</option>
254+
<option value="30">30 Days</option>
255+
<option value="90">90 Days</option>
256+
<option value="365">1 Year</option>
257+
</select>
258+
</div>
259+
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end' }}>
260+
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateModal(false)} disabled={creating}>
261+
Cancel
262+
</button>
263+
<button type="submit" className="btn btn-primary" disabled={creating}>
264+
{creating ? 'Generating...' : 'Generate Token'}
265+
</button>
266+
</div>
267+
</form>
268+
</div>
269+
</div>
270+
)}
271+
272+
{/* One Time Reveal Modal */}
273+
{newRawToken && (
274+
<div
275+
style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100, backdropFilter: 'blur(4px)' }}
276+
role="dialog"
277+
aria-modal="true"
278+
aria-labelledby="reveal-pat-modal-title"
279+
>
280+
<div className="card" style={{ width: '100%', maxWidth: '500px', margin: '20px', border: '1px solid var(--color-primary)' }}>
281+
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '1rem', color: 'var(--color-primary)' }}>
282+
<CheckCircle size={24} />
283+
<h3 id="reveal-pat-modal-title" style={{ fontSize: '1.25rem' }}>Token Generated Successfully</h3>
284+
</div>
285+
286+
<div style={{ background: 'rgba(234, 84, 85, 0.1)', border: '1px solid rgba(234, 84, 85, 0.3)', padding: '12px', borderRadius: '8px', color: '#ea5455', display: 'flex', gap: '10px', alignItems: 'flex-start', marginBottom: '1.5rem' }}>
287+
<AlertTriangle size={20} style={{ flexShrink: 0, marginTop: '2px' }} />
288+
<p style={{ fontSize: '0.85rem', lineHeight: 1.5 }}>
289+
<strong>Save this token.</strong> This is one time view token, cannot be seen again.
290+
</p>
291+
</div>
292+
293+
<div style={{ display: 'flex', gap: '8px', marginBottom: '2rem' }}>
294+
<input
295+
type="text"
296+
readOnly
297+
value={newRawToken}
298+
className="input-field"
299+
style={{ flex: 1, fontFamily: 'monospace', fontSize: '1rem', background: '#000' }}
300+
/>
301+
<button
302+
onClick={() => copyToClipboard(newRawToken)}
303+
className="btn btn-secondary"
304+
style={{ width: '100px', display: 'flex', justifyContent: 'center' }}
305+
>
306+
{copied ? <span style={{ color: 'var(--color-primary)' }}>Copied!</span> : <><Copy size={16} /> Copy</>}
307+
</button>
308+
</div>
309+
310+
<button
311+
className="btn btn-primary"
312+
style={{ width: '100%', padding: '12px' }}
313+
onClick={() => setNewRawToken(null)}
314+
>
315+
I have saved my token safely
316+
</button>
317+
</div>
318+
</div>
319+
)}
320+
321+
<ConfirmationModal
322+
open={!!revokeId}
323+
title="Revoke Token?"
324+
message="Are you sure you want to revoke this Personal Access Token? Any CLI sessions or scripts using this token will instantly lose access. This action cannot be undone."
325+
onConfirm={handleRevoke}
326+
onCancel={() => setRevokeId(null)}
327+
/>
328+
</div>
329+
);
330+
}

0 commit comments

Comments
 (0)