feat: add web PAT API client

This commit is contained in:
Anish Sarkar 2026-06-19 20:28:48 +05:30
parent 096dea45d4
commit e5ab0e5342
4 changed files with 146 additions and 66 deletions

View file

@ -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<typeof pat>;
export type CreatePatRequest = z.infer<typeof createPatRequest>;
export type CreatedPat = z.infer<typeof createPatResponse>;

View file

@ -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<void>;
}
export function useApiKey(): UseApiKeyReturn {
const [apiKey, setApiKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}

View file

@ -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<PersonalAccessToken[]>([]);
const [createdToken, setCreatedToken] = useState<CreatedPat | null>(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,
};
}

View file

@ -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();