From 9ad53ce73a02d00e28a5c0301b182ebbf50b6dcd Mon Sep 17 00:00:00 2001 From: Nehan Ahmed Date: Thu, 16 Jul 2026 12:38:19 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Phase=2010-11=20=E2=80=94=20API=20k?= =?UTF-8?q?ey=20management=20&=20password-protected=20link=20interstitial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 10 — API Key Management (apps/dashboard): - Full CRUD table with scopes, IP allowlist, status, expiry columns - Create/edit dialogs with scope checkboxes, IP chips, datetime-local expiry - Key reveal modal with copy button and once-only warning - Revoke confirmation dialog - 20s rate-limit cooldown on all mutation buttons - New Checkbox UI component - Backend scope enforcement via requireScope middleware - Scopes stored on AuthenticatedUser and checked per-route - Route gating: urls:read/urls:write/urls:delete on URL, collection, tag routes Phase 11 — Password Interstitial (apps/web): - New /link/:code page for entering link passwords - Clean centered layout matching web app auth styling - POST to /verify-password-token for JWT access token - Redirects to /:code?token= for completion - Handles all error states (expired, blocked, not found, rate-limited) - Backend: browsers redirected to /link/:code instead of JSON error - APP_URL env var added for web app redirect URL --- apps/api/src/controllers/url.controllers.ts | 7 + apps/api/src/middleware/auth.ts | 2 +- apps/api/src/middleware/requireScope.ts | 36 ++ apps/api/src/routes/collection.routes.ts | 25 +- apps/api/src/routes/tag.routes.ts | 15 +- apps/api/src/routes/url.routes.ts | 23 +- apps/api/src/types/auth.types.ts | 1 + apps/api/src/utils/env.ts | 1 + apps/dashboard/src/components/ui/checkbox.tsx | 50 ++ apps/dashboard/src/lib/api.ts | 2 +- apps/dashboard/src/pages/ApiKeysPage.tsx | 521 +++++++++++++++++- apps/web/src/App.tsx | 2 + apps/web/src/lib/api.ts | 10 + .../src/pages/PasswordInterstitialPage.tsx | 197 +++++++ 14 files changed, 856 insertions(+), 36 deletions(-) create mode 100644 apps/api/src/middleware/requireScope.ts create mode 100644 apps/dashboard/src/components/ui/checkbox.tsx create mode 100644 apps/web/src/pages/PasswordInterstitialPage.tsx diff --git a/apps/api/src/controllers/url.controllers.ts b/apps/api/src/controllers/url.controllers.ts index f6097a0..327f772 100644 --- a/apps/api/src/controllers/url.controllers.ts +++ b/apps/api/src/controllers/url.controllers.ts @@ -12,6 +12,7 @@ import { AppError } from '../utils/AppError' import { logActionFromReq } from '../services/audit.service' import { isBot } from '../utils/botDetection' import { logger } from '../utils/logger' +import { env } from '../utils/env' import { db } from '../db' import { visits } from '../db/schema' import { count, eq, asc } from 'drizzle-orm' @@ -202,6 +203,12 @@ async function performRedirect(code: string, req: Request, res: Response) { if (url.passwordHash) { const token = req.query.token as string | undefined if (!token) { + const accept = req.headers.accept ?? '' + const acceptsHtml = accept.includes('text/html') + if (acceptsHtml) { + res.redirect(302, `${env.APP_URL}/link/${code}`) + return + } res.status(401).json({ success: false, error: 'This link is password protected. POST /:code/verify-password to get an access token.', diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index fbbbbbe..c64b2f3 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -63,7 +63,7 @@ async function verifyApiKey(token: string, ip?: string): Promise {}) const role = await syncUser(key.userId) - return { id: key.userId, role: role as UserRole, apiKeyName: key.name } + return { id: key.userId, role: role as UserRole, apiKeyName: key.name, scopes: key.scopes ?? undefined } } throw new AppError('Invalid API key', 401, 'AUTH_INVALID_KEY') diff --git a/apps/api/src/middleware/requireScope.ts b/apps/api/src/middleware/requireScope.ts new file mode 100644 index 0000000..b3bad0a --- /dev/null +++ b/apps/api/src/middleware/requireScope.ts @@ -0,0 +1,36 @@ +import type { Request, Response, NextFunction } from 'express' +import { AppError } from '../utils/AppError' + +export function requireScope(...scopes: string[]) { + return (req: Request, _res: Response, next: NextFunction) => { + if (!req.user) { + next(new AppError('Authentication required', 401, 'AUTH_REQUIRED')) + return + } + + // JWT users bypass scope checks entirely + if (!req.user.apiKeyName) { + next() + return + } + + // API key with null/undefined scopes = full access (legacy keys) + if (!req.user.scopes || req.user.scopes.length === 0) { + next() + return + } + + // Check the API key has at least one of the required scopes + const hasScope = scopes.some((s) => req.user!.scopes!.includes(s)) + if (!hasScope) { + next(new AppError( + `API key requires one of: ${scopes.join(', ')}`, + 403, + 'FORBIDDEN', + )) + return + } + + next() + } +} diff --git a/apps/api/src/routes/collection.routes.ts b/apps/api/src/routes/collection.routes.ts index d726fb7..d28caaf 100644 --- a/apps/api/src/routes/collection.routes.ts +++ b/apps/api/src/routes/collection.routes.ts @@ -1,21 +1,20 @@ import { Router } from 'express' import * as collectionController from '../controllers/collection.controller' import { requireAuth } from '../middleware/auth' +import { requireScope } from '../middleware/requireScope' const router = Router() -router.use(requireAuth) - -router.get('/', collectionController.listCollections) -router.post('/', collectionController.createCollection) -router.patch('/reorder', collectionController.reorderCollections) -router.get('/:id', collectionController.getCollection) -router.patch('/:id', collectionController.updateCollection) -router.delete('/:id', collectionController.deleteCollection) -router.post('/:id/share', collectionController.shareCollection) -router.delete('/:id/share', collectionController.revokeShare) -router.get('/:id/urls', collectionController.getCollectionUrls) -router.post('/:id/urls', collectionController.addUrlToCollection) -router.delete('/:id/urls', collectionController.removeUrlFromCollection) +router.get('/', requireAuth, requireScope('urls:read'), collectionController.listCollections) +router.post('/', requireAuth, requireScope('urls:write'), collectionController.createCollection) +router.patch('/reorder', requireAuth, requireScope('urls:write'), collectionController.reorderCollections) +router.get('/:id', requireAuth, requireScope('urls:read'), collectionController.getCollection) +router.patch('/:id', requireAuth, requireScope('urls:write'), collectionController.updateCollection) +router.delete('/:id', requireAuth, requireScope('urls:write'), collectionController.deleteCollection) +router.post('/:id/share', requireAuth, requireScope('urls:write'), collectionController.shareCollection) +router.delete('/:id/share', requireAuth, requireScope('urls:write'), collectionController.revokeShare) +router.get('/:id/urls', requireAuth, requireScope('urls:read'), collectionController.getCollectionUrls) +router.post('/:id/urls', requireAuth, requireScope('urls:write'), collectionController.addUrlToCollection) +router.delete('/:id/urls', requireAuth, requireScope('urls:write'), collectionController.removeUrlFromCollection) export default router diff --git a/apps/api/src/routes/tag.routes.ts b/apps/api/src/routes/tag.routes.ts index e07254e..00cc0cc 100644 --- a/apps/api/src/routes/tag.routes.ts +++ b/apps/api/src/routes/tag.routes.ts @@ -1,17 +1,16 @@ import { Router } from 'express' import * as tagController from '../controllers/tag.controller' import { requireAuth } from '../middleware/auth' +import { requireScope } from '../middleware/requireScope' import { bulkLimiter } from '../middleware/rateLimiter' const router = Router() -router.use(requireAuth) - -router.get('/', tagController.listTags) -router.post('/', tagController.createTag) -router.patch('/:id', tagController.updateTag) -router.delete('/:id', tagController.deleteTag) -router.post('/bulk', bulkLimiter, tagController.bulkTagUrls) -router.get('/:id/urls', tagController.getTagUrls) +router.get('/', requireAuth, requireScope('urls:read'), tagController.listTags) +router.post('/', requireAuth, requireScope('urls:write'), tagController.createTag) +router.patch('/:id', requireAuth, requireScope('urls:write'), tagController.updateTag) +router.delete('/:id', requireAuth, requireScope('urls:write'), tagController.deleteTag) +router.post('/bulk', requireAuth, requireScope('urls:write'), bulkLimiter, tagController.bulkTagUrls) +router.get('/:id/urls', requireAuth, requireScope('urls:read'), tagController.getTagUrls) export default router diff --git a/apps/api/src/routes/url.routes.ts b/apps/api/src/routes/url.routes.ts index c376d34..4a2f65c 100644 --- a/apps/api/src/routes/url.routes.ts +++ b/apps/api/src/routes/url.routes.ts @@ -4,28 +4,29 @@ import * as linkController from '../controllers/link.controller' import * as qrController from '../controllers/qr.controller' import { requireAuth } from '../middleware/auth' import { requireAAL } from '../middleware/requireAAL' +import { requireScope } from '../middleware/requireScope' import { strictLimiter, bulkLimiter, passwordLimiter } from '../middleware/rateLimiter' const router = Router() -router.post('/bulk', requireAuth, bulkLimiter, urlController.createUrlBulk) -router.post('/', requireAuth, strictLimiter, urlController.createUrl) -router.get('/', requireAuth, urlController.listUrls) +router.post('/bulk', requireAuth, requireScope('urls:write'), bulkLimiter, urlController.createUrlBulk) +router.post('/', requireAuth, requireScope('urls:write'), strictLimiter, urlController.createUrl) +router.get('/', requireAuth, requireScope('urls:read'), urlController.listUrls) router.get('/:code', urlController.redirectUrl) router.get('/:code/info', urlController.getUrlInfo) router.get('/:code/visits', urlController.getVisits) router.get('/:code/stats', urlController.getUrlStats) router.get('/:code/visits/export', urlController.exportVisits) -router.get('/:code/qr', qrController.generateQr) +router.get('/:code/qr', requireAuth, requireScope('urls:read'), qrController.generateQr) router.post('/:code/verify-password', passwordLimiter, urlController.verifyPasswordRedirect) -router.delete('/:code', requireAuth, urlController.deleteUrl) -router.delete('/:code/purge', requireAuth, requireAAL(), urlController.purgeUrl) +router.delete('/:code', requireAuth, requireScope('urls:delete'), urlController.deleteUrl) +router.delete('/:code/purge', requireAuth, requireScope('urls:delete'), requireAAL(), urlController.purgeUrl) -router.patch('/:code/settings', requireAuth, linkController.updateLinkSettings) -router.post('/:code/password', requireAuth, linkController.setPassword) -router.delete('/:code/password', requireAuth, linkController.removePassword) +router.patch('/:code/settings', requireAuth, requireScope('urls:write'), linkController.updateLinkSettings) +router.post('/:code/password', requireAuth, requireScope('urls:write'), linkController.setPassword) +router.delete('/:code/password', requireAuth, requireScope('urls:write'), linkController.removePassword) router.post('/:code/verify-password-token', passwordLimiter, linkController.verifyPassword) -router.post('/bulk-operations', requireAuth, bulkLimiter, linkController.executeBulkOperation) -router.post('/import/csv', requireAuth, bulkLimiter, linkController.importCsv) +router.post('/bulk-operations', requireAuth, requireScope('urls:write'), bulkLimiter, linkController.executeBulkOperation) +router.post('/import/csv', requireAuth, requireScope('urls:write'), bulkLimiter, linkController.importCsv) export default router diff --git a/apps/api/src/types/auth.types.ts b/apps/api/src/types/auth.types.ts index 78ade42..f0247df 100644 --- a/apps/api/src/types/auth.types.ts +++ b/apps/api/src/types/auth.types.ts @@ -6,6 +6,7 @@ export interface AuthenticatedUser { role: UserRole aal?: 'aal1' | 'aal2' apiKeyName?: string + scopes?: string[] } declare global { diff --git a/apps/api/src/utils/env.ts b/apps/api/src/utils/env.ts index fe75fc7..8f18e4d 100644 --- a/apps/api/src/utils/env.ts +++ b/apps/api/src/utils/env.ts @@ -31,6 +31,7 @@ const envSchema = z.object({ BILLING_SUCCESS_URL: z.string().optional(), BILLING_CANCEL_URL: z.string().optional(), BILLING_RETURN_URL: z.string().optional(), + APP_URL: z.string().default('http://localhost:5173'), // Feature flags FEATURE_BULK_OPERATIONS: z.enum(['enabled', 'disabled']).default('enabled'), diff --git a/apps/dashboard/src/components/ui/checkbox.tsx b/apps/dashboard/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..79012f7 --- /dev/null +++ b/apps/dashboard/src/components/ui/checkbox.tsx @@ -0,0 +1,50 @@ +import { cn } from "@/lib/utils" +import { forwardRef, type InputHTMLAttributes } from "react" + +export interface CheckboxProps extends Omit, "type"> { + onCheckedChange?: (checked: boolean) => void +} + +const Checkbox = forwardRef( + ({ className, checked, onChange, onCheckedChange, disabled, ...props }, ref) => { + return ( + + ) + } +) +Checkbox.displayName = "Checkbox" + +export { Checkbox } diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index c98ae12..50873b2 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -247,7 +247,7 @@ export function removeUrlPassword(token: string, code: string) { export function verifyUrlPassword(code: string, password: string) { return request<{ token: string }>( - `/api/urls/${code}/verify-password`, + `/api/urls/${code}/verify-password-token`, { method: "POST", body: JSON.stringify({ password }), diff --git a/apps/dashboard/src/pages/ApiKeysPage.tsx b/apps/dashboard/src/pages/ApiKeysPage.tsx index a459273..70745cb 100644 --- a/apps/dashboard/src/pages/ApiKeysPage.tsx +++ b/apps/dashboard/src/pages/ApiKeysPage.tsx @@ -1,12 +1,529 @@ +import { useState, useEffect, useCallback, useRef } from "react" +import { useAuth } from "@/hooks/use-auth" +import { createApiKey, listApiKeys, updateApiKey, revokeApiKey } from "@/lib/api" +import type { ApiKey } from "@linkify/shared" +import { toast } from "sonner" import PageHeader from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { Checkbox } from "@/components/ui/checkbox" +import CopyButton from "@/components/copy-button" +import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { KeyRound, Plus, Pencil, Trash2, Loader2 } from "lucide-react" + +const SCOPE_OPTIONS = [ + { value: "urls:read", label: "URLs Read" }, + { value: "urls:write", label: "URLs Write" }, + { value: "urls:delete", label: "URLs Delete" }, + { value: "analytics:read", label: "Analytics Read" }, +] + +function formatDate(iso: string | null | undefined): string { + if (!iso) return "Never" + return new Date(iso).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }) +} + +function formatDateTime(iso: string | null | undefined): string { + if (!iso) return "" + const d = new Date(iso) + const pad = (n: number) => n.toString().padStart(2, "0") + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +function isExpired(expiresAt: string | null | undefined): boolean { + if (!expiresAt) return false + return new Date(expiresAt) < new Date() +} + +const COOLDOWN_SECONDS = 20 export default function ApiKeysPage() { + const { session } = useAuth() + const token = session?.access_token ?? "" + + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(true) + + const [createOpen, setCreateOpen] = useState(false) + const [createName, setCreateName] = useState("") + const [createScopes, setCreateScopes] = useState([]) + const [createIps, setCreateIps] = useState([]) + const [createIpInput, setCreateIpInput] = useState("") + const [createExpiresAt, setCreateExpiresAt] = useState("") + const [creating, setCreating] = useState(false) + + const [newKey, setNewKey] = useState<{ key: string; name: string } | null>(null) + + const [editTarget, setEditTarget] = useState(null) + const [editName, setEditName] = useState("") + const [editScopes, setEditScopes] = useState([]) + const [editIps, setEditIps] = useState([]) + const [editIpInput, setEditIpInput] = useState("") + const [editExpiresAt, setEditExpiresAt] = useState("") + const [editing, setEditing] = useState(false) + + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + + const [cooldown, setCooldown] = useState(0) + const cooldownRef = useRef | null>(null) + + const startCooldown = useCallback(() => { + setCooldown(COOLDOWN_SECONDS) + cooldownRef.current = setInterval(() => { + setCooldown((prev) => { + if (prev <= 1) { + clearInterval(cooldownRef.current!) + return 0 + } + return prev - 1 + }) + }, 1000) + }, []) + + useEffect(() => { + return () => { + if (cooldownRef.current) clearInterval(cooldownRef.current) + } + }, []) + + const fetchData = useCallback(async () => { + if (!token) return + try { + const data = await listApiKeys(token) + setKeys(data) + } catch { + toast.error("Failed to load API keys") + } finally { + setLoading(false) + } + }, [token]) + + useEffect(() => { fetchData() }, [fetchData]) + + const toggleScope = (scopes: string[], setScopes: (v: string[]) => void, value: string) => { + setScopes( + scopes.includes(value) + ? scopes.filter((s) => s !== value) + : [...scopes, value] + ) + } + + const addIp = (ips: string[], setIps: (v: string[]) => void, input: string, setInput: (v: string) => void) => { + const trimmed = input.trim() + if (!trimmed) return + if (ips.includes(trimmed)) return + setIps([...ips, trimmed]) + setInput("") + } + + const removeIp = (ips: string[], setIps: (v: string[]) => void, idx: number) => { + setIps(ips.filter((_, i) => i !== idx)) + } + + const handleCreate = async () => { + if (!createName.trim()) { toast.error("Name is required"); return } + setCreating(true) + try { + const result = await createApiKey(token, { + name: createName.trim(), + scopes: createScopes.length > 0 ? createScopes : undefined, + allowedIps: createIps.length > 0 ? createIps : undefined, + expiresAt: createExpiresAt ? new Date(createExpiresAt).toISOString() : undefined, + }) + setNewKey({ key: result.key, name: result.name }) + setCreateOpen(false) + setCreateName("") + setCreateScopes([]) + setCreateIps([]) + setCreateIpInput("") + setCreateExpiresAt("") + startCooldown() + fetchData() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to create API key" + toast.error(msg) + } finally { + setCreating(false) + } + } + + const handleEdit = async () => { + if (!editTarget) return + if (!editName.trim()) { toast.error("Name is required"); return } + setEditing(true) + try { + await updateApiKey(token, editTarget.id, { + name: editName.trim(), + scopes: editScopes.length > 0 ? editScopes : undefined, + allowedIps: editIps.length > 0 ? editIps : undefined, + expiresAt: editExpiresAt ? new Date(editExpiresAt).toISOString() : undefined, + }) + toast.success("API key updated") + setEditTarget(null) + startCooldown() + fetchData() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to update API key" + toast.error(msg) + } finally { + setEditing(false) + } + } + + const handleRevoke = async () => { + if (!deleteTarget) return + setDeleting(true) + try { + await revokeApiKey(token, deleteTarget.id) + toast.success("API key revoked") + setDeleteTarget(null) + startCooldown() + fetchData() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to revoke API key" + toast.error(msg) + } finally { + setDeleting(false) + } + } + + const onCooldown = cooldown > 0 + + if (loading) { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ) + } + return ( -
+
setCreateOpen(true)} disabled={onCooldown}> + {onCooldown ? ( + {cooldown}s + ) : ( + <>New key + )} + + } /> + + {keys.length === 0 ? ( +
+ +

No API keys yet

+

+ Create your first key to access the API programmatically. +

+ +
+ ) : ( +
+ + + + + + + + + + + + + + {keys.map((key) => { + const expired = isExpired(key.expiresAt) + return ( + + + + + + + + + + ) + })} + +
NameScopesIP AllowlistLast usedExpiresStatusActions
{key.name} +
+ {key.scopes && key.scopes.length > 0 ? ( + key.scopes.map((s) => ( + + {s} + + )) + ) : ( + All + )} +
+
+ {key.allowedIps && key.allowedIps.length > 0 ? ( +
+ {key.allowedIps.map((ip) => ( + + {ip} + + ))} +
+ ) : ( + Any IP + )} +
+ {formatDate(key.lastUsedAt)} + + {formatDate(key.expiresAt)} + + + {expired ? "Expired" : "Active"} + + +
+ + +
+
+
+ )} + + {onCooldown && ( +

+ Rate limit: {cooldown}s before next API key change +

+ )} + + {/* Create Dialog */} + { setCreateOpen(false); setCreateName(""); setCreateScopes([]); setCreateIps([]); setCreateIpInput(""); setCreateExpiresAt("") }}> + + Create API key + Generate a new API key for programmatic access. + +
+
+ + setCreateName(e.target.value)} placeholder="e.g. CI/CD pipeline" autoFocus /> +
+
+ +
+ {SCOPE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ +
+ setCreateIpInput(e.target.value)} + placeholder="e.g. 203.0.113.0/24" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addIp(createIps, setCreateIps, createIpInput, setCreateIpInput) + } + }} + /> + +
+ {createIps.length > 0 && ( +
+ {createIps.map((ip, i) => ( + + {ip} + + + ))} +
+ )} +
+
+ + setCreateExpiresAt(e.target.value)} /> +
+
+ + + + +
+ + {/* Key Reveal Dialog */} + {}}> + + API key created + + Your new key is ready. Copy it now — you won't be able to see it again. + + + {newKey && ( +
+
+ + {newKey.key} + + +
+

+ Warning: This key will only be shown once. If you lose it, you'll need to create a new one. +

+
+ )} + + + +
+ + {/* Edit Dialog */} + setEditTarget(null)}> + + Edit API key + Update the key name, scopes, or restrictions. + +
+
+ + setEditName(e.target.value)} autoFocus /> +
+
+ +
+ {SCOPE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ +
+ setEditIpInput(e.target.value)} + placeholder="e.g. 203.0.113.0/24" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + addIp(editIps, setEditIps, editIpInput, setEditIpInput) + } + }} + /> + +
+ {editIps.length > 0 && ( +
+ {editIps.map((ip, i) => ( + + {ip} + + + ))} +
+ )} +
+
+ + setEditExpiresAt(e.target.value)} /> +
+
+ + + + +
+ + {/* Revoke Dialog */} + setDeleteTarget(null)}> + + Revoke API key + + Are you sure you want to revoke {deleteTarget?.name}? + This will immediately invalidate this key. + + + + + + +
) } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index df5138e..824f55a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,6 +8,7 @@ import ForgotPasswordPage from "@/pages/ForgotPasswordPage" import ResetPasswordPage from "@/pages/ResetPasswordPage" import AuthCallback from "@/pages/AuthCallback" import NotFound from "@/pages/NotFound" +import PasswordInterstitialPage from "@/pages/PasswordInterstitialPage" export default function App() { return ( @@ -47,6 +48,7 @@ export default function App() { } /> } /> + } /> } /> diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3bb5098..f486da7 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -48,4 +48,14 @@ export function fetchMe(token: string) { ) } +export function verifyUrlPassword(code: string, password: string) { + return request<{ token: string }>( + `/api/urls/${code}/verify-password-token`, + { + method: "POST", + body: JSON.stringify({ password }), + } + ) +} + diff --git a/apps/web/src/pages/PasswordInterstitialPage.tsx b/apps/web/src/pages/PasswordInterstitialPage.tsx new file mode 100644 index 0000000..fda9b86 --- /dev/null +++ b/apps/web/src/pages/PasswordInterstitialPage.tsx @@ -0,0 +1,197 @@ +import { useState, type FormEvent, useEffect, useRef } from "react" +import { useParams, Link } from "react-router-dom" +import { verifyUrlPassword } from "@/lib/api" +import { Lock, TriangleAlert, Clock, ShieldOff, ArrowLeft } from "lucide-react" + +interface ErrorState { + message: string + variant: "password" | "static" +} + +const COOLDOWN_SECONDS = 12 + +export default function PasswordInterstitialPage() { + const { code } = useParams<{ code: string }>() + + const [password, setPassword] = useState("") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const [staticError, setStaticError] = useState<{ + title: string + description: string + } | null>(null) + + const [cooldown, setCooldown] = useState(0) + const cooldownRef = useRef | null>(null) + + useEffect(() => { + return () => { + if (cooldownRef.current) clearInterval(cooldownRef.current) + } + }, []) + + const startCooldown = () => { + setCooldown(COOLDOWN_SECONDS) + cooldownRef.current = setInterval(() => { + setCooldown((prev) => { + if (prev <= 1) { + clearInterval(cooldownRef.current!) + return 0 + } + return prev - 1 + }) + }, 1000) + } + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + if (!code || !password.trim()) return + + setError(null) + setSubmitting(true) + + try { + const { token } = await verifyUrlPassword(code, password) + window.location.href = `${import.meta.env.VITE_API_URL ?? "http://localhost:3000"}/${code}?token=${token}` + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Something went wrong" + + if (msg.includes("INCORRECT_PASSWORD")) { + setError({ message: "Incorrect password. Please try again.", variant: "password" }) + } else if (msg.includes("LINK_EXPIRED")) { + setStaticError({ title: "Link expired", description: "This link has expired and is no longer available." }) + } else if (msg.includes("LINK_NOT_ACTIVE")) { + setStaticError({ title: "Link not active", description: "This link is scheduled but not yet active." }) + } else if (msg.includes("BOTS_BLOCKED")) { + setStaticError({ title: "Access blocked", description: "Automated access to this link is blocked." }) + } else if (msg.includes("URL_NOT_FOUND")) { + setStaticError({ title: "Link not found", description: "This short link doesn't exist." }) + } else if (msg.includes("NO_PASSWORD")) { + window.location.href = `${import.meta.env.VITE_API_URL ?? "http://localhost:3000"}/${code}` + return + } else if (msg.includes("429") || msg.includes("Too many")) { + setError({ message: "Too many attempts. Please wait.", variant: "password" }) + startCooldown() + } else { + setError({ message: msg, variant: "password" }) + } + } finally { + setSubmitting(false) + } + } + + if (!code) { + return ( +
+

Invalid link.

+
+ ) + } + + if (staticError) { + return ( +
+
+ + Linkify + +
+
+
+
+ {staticError.title === "Link expired" || staticError.title === "Link not active" ? ( + + ) : ( + + )} +
+

{staticError.title}

+

{staticError.description}

+ + + Back to home + +
+
+
+ ) + } + + return ( +
+
+ + Linkify + +
+
+
+
+
+ +
+

Password required

+

+ This link is password protected. +

+

+ /{code} +

+
+ +
+
+ + { + setPassword(e.target.value) + if (error?.variant === "password") setError(null) + }} + required + autoFocus + className="flex h-10 w-full rounded-[10px] border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ + {error && error.variant === "password" && ( +
+ + + {error.message} + {cooldown > 0 && ( + ({cooldown}s) + )} + +
+ )} + + +
+
+
+
+ ) +} From 6108ffb18fa82a3b85c55c2541bdf069e635be6d Mon Sep 17 00:00:00 2001 From: Nehan Ahmed Date: Thu, 16 Jul 2026 12:39:21 +0500 Subject: [PATCH 2/2] chore: add phase spec documents --- specs/05-seventh-eigth-ninth-phase.md | 33 +++++++++++++++++++++++++++ specs/06-tenth-eleventh-phase.md | 33 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 specs/05-seventh-eigth-ninth-phase.md create mode 100644 specs/06-tenth-eleventh-phase.md diff --git a/specs/05-seventh-eigth-ninth-phase.md b/specs/05-seventh-eigth-ninth-phase.md new file mode 100644 index 0000000..ae71455 --- /dev/null +++ b/specs/05-seventh-eigth-ninth-phase.md @@ -0,0 +1,33 @@ +# Building the Phase 7, 8 and 9 + +You have to build the Phase 7, 8 and 9 of the phases.md file. + +## Instruction + +You are a professional and experienced software developer with years of experience. Write professional and production-ready code and professional code logics and best-practices. think like a 100x engineer. do same as what he would do. follow proper typescript. Do not break anything. Build everything written in the phase. + +## What to DO. + +write production-ready code and professional code and best-practices. Follow professional code guidelines.Create a minimal aesthetic professional and popping out UI and UX that follows modern design principles and professional design hirarchy and strictly follow the DESIGN.md file for the design theme and layout and everything. + +## What not to do. + +- Dont use hardcoded talwind colors. +- Dont break existing functionality. +- Dont add any new features that are not mentioned in the phases.md file. +- Dont change the existing UI/UX unless explicitly asked. + +## Additional Context + +The phases.md file contains the detailed specifications for each phase. Make sure to follow them exactly. + +## Context + +- AGENTS.md +- docs/API.md +- docs/PROJECT.md +- README.md +- phases.md +- DESIGN.md + +these all files have all the context related to project backend and full guides. \ No newline at end of file diff --git a/specs/06-tenth-eleventh-phase.md b/specs/06-tenth-eleventh-phase.md new file mode 100644 index 0000000..8c64e22 --- /dev/null +++ b/specs/06-tenth-eleventh-phase.md @@ -0,0 +1,33 @@ +# Building the Phase 10 and 11 + +You have to build the Phase 10 and 11 of the phases.md file. + +## Instruction + +You are a professional and experienced software developer with years of experience. Write professional and production-ready code and professional code logics and best-practices. think like a 100x engineer. do same as what he would do. follow proper typescript. Do not break anything. Build everything written in the phase. + +## What to DO. + +write production-ready code and professional code and best-practices. Follow professional code guidelines.Create a minimal aesthetic professional and popping out UI and UX that follows modern design principles and professional design hirarchy and strictly follow the DESIGN.md file for the design theme and layout and everything. + +## What not to do. + +- Dont use hardcoded talwind colors. +- Dont break existing functionality. +- Dont add any new features that are not mentioned in the phases.md file. +- Dont change the existing UI/UX unless explicitly asked. + +## Additional Context + +The phases.md file contains the detailed specifications for each phase. Make sure to follow them exactly. + +## Context + +- AGENTS.md +- docs/API.md +- docs/PROJECT.md +- README.md +- phases.md +- DESIGN.md + +these all files have all the context related to project backend and full guides. \ No newline at end of file