From e5ab0e534210de999b34b56842b74754b8172914 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:28:48 +0530 Subject: [PATCH] feat: add web PAT API client --- surfsense_web/contracts/types/pat.types.ts | 30 ++++++++ surfsense_web/hooks/use-api-key.ts | 66 ----------------- surfsense_web/hooks/use-pats.ts | 83 ++++++++++++++++++++++ surfsense_web/lib/apis/pats-api.service.ts | 33 +++++++++ 4 files changed, 146 insertions(+), 66 deletions(-) create mode 100644 surfsense_web/contracts/types/pat.types.ts delete mode 100644 surfsense_web/hooks/use-api-key.ts create mode 100644 surfsense_web/hooks/use-pats.ts create mode 100644 surfsense_web/lib/apis/pats-api.service.ts diff --git a/surfsense_web/contracts/types/pat.types.ts b/surfsense_web/contracts/types/pat.types.ts new file mode 100644 index 000000000..a1d50fb4d --- /dev/null +++ b/surfsense_web/contracts/types/pat.types.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +export const pat = z.object({ + id: z.number(), + label: z.string(), + prefix: z.string(), + expires_at: z.string().nullable(), + last_used_at: z.string().nullable(), + created_at: z.string(), +}); + +export const createPatRequest = z.object({ + label: z.string().min(1).max(120), + expires_in_days: z.number().int().positive().nullable().optional(), +}); + +export const createPatResponse = z.object({ + id: z.number(), + label: z.string(), + token: z.string(), + prefix: z.string(), + expires_at: z.string().nullable(), +}); + +export const listPatsResponse = z.array(pat); +export const deletePatResponse = z.void(); + +export type PersonalAccessToken = z.infer; +export type CreatePatRequest = z.infer; +export type CreatedPat = z.infer; diff --git a/surfsense_web/hooks/use-api-key.ts b/surfsense_web/hooks/use-api-key.ts deleted file mode 100644 index b50dd65f1..000000000 --- a/surfsense_web/hooks/use-api-key.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { getBearerToken } from "@/lib/auth-utils"; -import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils"; - -interface UseApiKeyReturn { - apiKey: string | null; - isLoading: boolean; - copied: boolean; - copyToClipboard: () => Promise; -} - -export function useApiKey(): UseApiKeyReturn { - const [apiKey, setApiKey] = useState(null); - const [copied, setCopied] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const copyTimerRef = useRef | undefined>(undefined); - - useEffect(() => { - return () => { - if (copyTimerRef.current) clearTimeout(copyTimerRef.current); - }; - }, []); - - useEffect(() => { - // Load API key from localStorage - const loadApiKey = () => { - try { - const token = getBearerToken(); - setApiKey(token); - } catch (error) { - console.error("Error loading API key:", error); - toast.error("Failed to load API key"); - } finally { - setIsLoading(false); - } - }; - - // Add a small delay to simulate loading - const timer = setTimeout(loadApiKey, 500); - return () => clearTimeout(timer); - }, []); - - const copyToClipboard = useCallback(async () => { - if (!apiKey) return; - - const success = await copyToClipboardUtil(apiKey); - if (success) { - setCopied(true); - toast.success("API key copied to clipboard"); - if (copyTimerRef.current) clearTimeout(copyTimerRef.current); - copyTimerRef.current = setTimeout(() => { - setCopied(false); - }, 2000); - } else { - toast.error("Failed to copy API key"); - } - }, [apiKey]); - - return { - apiKey, - isLoading, - copied, - copyToClipboard, - }; -} diff --git a/surfsense_web/hooks/use-pats.ts b/surfsense_web/hooks/use-pats.ts new file mode 100644 index 000000000..978f26272 --- /dev/null +++ b/surfsense_web/hooks/use-pats.ts @@ -0,0 +1,83 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import type { + CreatePatRequest, + CreatedPat, + PersonalAccessToken, +} from "@/contracts/types/pat.types"; +import { patsApiService } from "@/lib/apis/pats-api.service"; + +export function usePats() { + const [tokens, setTokens] = useState([]); + const [createdToken, setCreatedToken] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isMutating, setIsMutating] = useState(false); + + const refresh = useCallback(async () => { + setIsLoading(true); + try { + const data = await patsApiService.listPats(); + setTokens(data); + } catch (error) { + console.error("Failed to load personal access tokens:", error); + toast.error("Failed to load personal access tokens"); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const createToken = useCallback( + async (request: CreatePatRequest) => { + setIsMutating(true); + try { + const data = await patsApiService.createPat(request); + setCreatedToken(data); + await refresh(); + toast.success("Personal access token created"); + return data; + } catch (error) { + console.error("Failed to create personal access token:", error); + toast.error("Failed to create personal access token"); + throw error; + } finally { + setIsMutating(false); + } + }, + [refresh] + ); + + const deleteToken = useCallback( + async (id: number) => { + setIsMutating(true); + try { + await patsApiService.deletePat(id); + await refresh(); + toast.success("Personal access token deleted"); + } catch (error) { + console.error("Failed to delete personal access token:", error); + toast.error("Failed to delete personal access token"); + throw error; + } finally { + setIsMutating(false); + } + }, + [refresh] + ); + + return { + tokens, + createdToken, + setCreatedToken, + isLoading, + isMutating, + refresh, + createToken, + deleteToken, + }; +} diff --git a/surfsense_web/lib/apis/pats-api.service.ts b/surfsense_web/lib/apis/pats-api.service.ts new file mode 100644 index 000000000..c517f1f33 --- /dev/null +++ b/surfsense_web/lib/apis/pats-api.service.ts @@ -0,0 +1,33 @@ +import { + type CreatePatRequest, + createPatRequest, + createPatResponse, + deletePatResponse, + listPatsResponse, +} from "@/contracts/types/pat.types"; +import { ValidationError } from "../error"; +import { baseApiService } from "./base-api.service"; + +class PatsApiService { + listPats = async () => { + return baseApiService.get("/api/v1/pats", listPatsResponse); + }; + + createPat = async (request: CreatePatRequest) => { + const parsedRequest = createPatRequest.safeParse(request); + if (!parsedRequest.success) { + const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post("/api/v1/pats", createPatResponse, { + body: parsedRequest.data, + }); + }; + + deletePat = async (id: number) => { + return baseApiService.delete(`/api/v1/pats/${id}`, deletePatResponse); + }; +} + +export const patsApiService = new PatsApiService();