diff --git a/apps/dashboard-api/src/controllers/pat.controller.js b/apps/dashboard-api/src/controllers/pat.controller.js index 60aebf8b1..40cd34fbc 100644 --- a/apps/dashboard-api/src/controllers/pat.controller.js +++ b/apps/dashboard-api/src/controllers/pat.controller.js @@ -2,6 +2,10 @@ const { Developer, PAT, generatePAT, redis, AppError, ApiResponse } = require('@ exports.createPAT = async (req, res, next) => { try { + if (!req.user || !req.user.isVerified) { + return next(new AppError(403, 'Forbidden: You must verify your account to access Personal Access Tokens.')); + } + const { label, type = 'human', scopes, ttlDays } = req.body; if (!label) return next(new AppError(400, "Token label is required.")); @@ -48,6 +52,10 @@ exports.createPAT = async (req, res, next) => { exports.listPATs = async (req, res, next) => { try { + if (!req.user || !req.user.isVerified) { + return next(new AppError(403, 'Forbidden: You must verify your account to access Personal Access Tokens.')); + } + const pats = await PAT.find({ developer: req.user._id }).sort({ createdAt: -1 }); // only show masked suffix and metadata @@ -72,19 +80,22 @@ exports.listPATs = async (req, res, next) => { exports.revokePAT = async (req, res, next) => { try { + if (!req.user || !req.user.isVerified) { + return next(new AppError(403, 'Forbidden: You must verify your account to access Personal Access Tokens.')); + } + const { id } = req.params; const patToRevoke = await PAT.findOneAndDelete({ _id: id, developer: req.user._id }); if (!patToRevoke) { return next(new AppError(404, "Token not found")); - } - // forcefully clear the Redis cache so ongoing sessions are immediately killed try { await redis.del(`cli:pat:cache:${patToRevoke.tokenHash}`); } catch (redisErr) { console.error("Failed to clear PAT from Redis cache:", redisErr); + return next(new AppError(503, "Unable to revoke token right now")); } return new ApiResponse({}, "Token revoked successfully").send(res, 200); diff --git a/apps/web-dashboard/src/components/PATManager.jsx b/apps/web-dashboard/src/components/PATManager.jsx new file mode 100644 index 000000000..e71ab67cf --- /dev/null +++ b/apps/web-dashboard/src/components/PATManager.jsx @@ -0,0 +1,330 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import api from '../utils/api'; +import toast from 'react-hot-toast'; +import { Key, Trash2, Plus, Copy, CheckCircle, AlertTriangle } from 'lucide-react'; +import ConfirmationModal from '../pages/ConfirmationModal'; + +const formatDate = (dateString) => { + if (!dateString) return 'Never'; + const d = new Date(dateString); + return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +}; + +const formatRelativeTime = (dateString) => { + if (!dateString) return 'Never'; + const diffMs = new Date(dateString) - new Date(); + if (diffMs <= 0) return 'Expired'; + + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays === 1) return 'in 1 day'; + if (diffDays > 300) return 'in 1 year'; + return `in ${diffDays} days`; +}; + +const DEFAULT_FORM = { label: '', ttlDays: 30 }; + +export default function PATManager() { + const [pats, setPats] = useState([]); + const [loading, setLoading] = useState(true); + + // Create Modal State + const [showCreateModal, setShowCreateModal] = useState(false); + const [creating, setCreating] = useState(false); + const [newPatForm, setNewPatForm] = useState(DEFAULT_FORM); + + // Token Reveal State + const [newRawToken, setNewRawToken] = useState(null); + const [copied, setCopied] = useState(false); + + // Revoke Modal State + const [revokeId, setRevokeId] = useState(null); + const [revoking, setRevoking] = useState(false); + + // Ref for clipboard timeout cleanup + const copyTimerRef = useRef(null); + + const fetchPats = useCallback(async () => { + try { + setLoading(true); + const res = await api.get('/api/user/pats'); + setPats(res.data.data?.pats || []); + } catch (err) { + console.error(err); + toast.error("Failed to load Personal Access Tokens"); + } finally { + setLoading(false); + } + }, []); + + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + fetchPats(); + }, [fetchPats]); + /* eslint-enable react-hooks/set-state-in-effect */ + + // Cleanup copy timer on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }; + }, []); + + const handleCreate = useCallback(async (e) => { + e.preventDefault(); + if (!newPatForm.label.trim()) return toast.error("Label is required"); + + setCreating(true); + try { + const res = await api.post('/api/user/pats', { + label: newPatForm.label, + ttlDays: Number(newPatForm.ttlDays), + scopes: ['api:all'] + }); + + const created = res.data.data; + + // Show the PAT only once + setNewRawToken(created.rawToken); + setShowCreateModal(false); + setNewPatForm(DEFAULT_FORM); + + // Optimistic local append — avoids a redundant GET /api/user/pats round-trip + if (created.id || created._id) { + setPats(prev => [...prev, { + id: created.id || created._id, + label: created.label, + suffix: created.suffix, + expiresAt: created.expiresAt, + createdAt: created.createdAt, + lastUsedAt: null, + lastUsedIp: null + }]); + } else { + // Fallback: if backend doesn't return the full object, refetch + fetchPats(); + } + } catch (err) { + toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to generate token"); + } finally { + setCreating(false); + } + }, [newPatForm, fetchPats]); + + const handleRevoke = useCallback(async () => { + if (!revokeId || revoking) return; + setRevoking(true); + const currentRevokeId = revokeId; + + try { + await api.delete(`/api/user/pats/${currentRevokeId}`); + toast.success("Token revoked successfully"); + // Optimistic local filter — no server round-trip needed + setPats(prev => prev.filter(p => (p._id || p.id) !== currentRevokeId)); + } catch (err) { + if (err.response?.status !== 404) { + toast.error(err.response?.data?.message || "Failed to revoke token"); + } else { + // Already deleted on server, just clean up local state + setPats(prev => prev.filter(p => (p._id || p.id) !== currentRevokeId)); + } + } finally { + setRevokeId(null); + setRevoking(false); + } + }, [revokeId, revoking]); + + const copyToClipboard = useCallback(async (text) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + } catch { + toast.error("Failed to copy token"); + return; + } + // Clear any existing timer before setting a new one + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopied(false), 2000); + }, []); + + return ( +
+
+
+
+ +
+
+

Personal Access Tokens

+

Generate tokens to securely authenticate with the urBackend CLI.

+
+
+ +
+ + {loading ? ( +
Loading tokens...
+ ) : pats.length === 0 ? ( +
+

You don't have any Active Personal Access Tokens.

+
+ ) : ( +
+ + + + + + + + + + + + {pats.map((pat) => ( + + + + + + + + ))} + +
LabelTokenExpiresLast Used
{pat.label}ubpat_***{pat.suffix}{formatRelativeTime(pat.expiresAt)} +
+ {formatDate(pat.lastUsedAt)} + {pat.lastUsedIp && {pat.lastUsedIp}} +
+
+ +
+
+ )} + + {/* Create PAT Modal */} + {showCreateModal && ( +
+
+

Generate New Token

+

+ This token will give full access to your developer account from the CLI. +

+ +
+
+ + setNewPatForm({...newPatForm, label: e.target.value})} + required + autoFocus + maxLength={100} + /> +
+
+ + +
+
+ + +
+
+
+
+ )} + + {/* One Time Reveal Modal */} + {newRawToken && ( +
+
+
+ +

Token Generated Successfully

+
+ +
+ +

+ Save this token. This is one time view token, cannot be seen again. +

+
+ +
+ + +
+ + +
+
+ )} + + setRevokeId(null)} + /> +
+ ); +} diff --git a/apps/web-dashboard/src/pages/Settings.jsx b/apps/web-dashboard/src/pages/Settings.jsx index 88304dc88..e6c099efb 100644 --- a/apps/web-dashboard/src/pages/Settings.jsx +++ b/apps/web-dashboard/src/pages/Settings.jsx @@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import toast from 'react-hot-toast'; import { Lock, Trash2, AlertTriangle, Save, CheckCircle } from 'lucide-react'; import ConfirmationModal from './ConfirmationModal'; +import PATManager from '../components/PATManager'; export default function Settings() { const { logout, user, isLoading } = useAuth(); @@ -193,6 +194,9 @@ if (pageLoading) return ; + {/* PAT Visual */} + {user?.isVerified && } + {/* Danger Zone */}