| import { useState } from "react"; |
| import { Settings, CheckCircle, XCircle, Loader2, Eye, EyeOff, ExternalLink, Trash2, RefreshCw } from "lucide-react"; |
| import { useQueryClient } from "@tanstack/react-query"; |
| import { |
| useGetConfigToken, |
| useSetConfigToken, |
| useDeleteConfigToken, |
| getGetConfigTokenQueryKey, |
| } from "@workspace/api-client-react"; |
| import { |
| Dialog, |
| DialogContent, |
| DialogHeader, |
| DialogTitle, |
| DialogTrigger, |
| } from "@/components/ui/dialog"; |
| import { Button } from "@/components/ui/button"; |
| import { Input } from "@/components/ui/input"; |
| import { Label } from "@/components/ui/label"; |
| import { useToast } from "@/hooks/use-toast"; |
| import { useLang } from "@/contexts/LanguageContext"; |
|
|
| export function SettingsButton() { |
| const [open, setOpen] = useState(false); |
| const [accessToken, setAccessToken] = useState(""); |
| const [refreshToken, setRefreshToken] = useState(""); |
| const [showToken, setShowToken] = useState(false); |
| const queryClient = useQueryClient(); |
| const { toast } = useToast(); |
| const { t } = useLang(); |
|
|
| const { data: tokenStatus, isLoading } = useGetConfigToken({ |
| query: { queryKey: getGetConfigTokenQueryKey() }, |
| }); |
|
|
| const { mutate: saveToken, isPending: isSaving } = useSetConfigToken(); |
| const { mutate: removeToken, isPending: isRemoving } = useDeleteConfigToken(); |
|
|
| const handleSave = () => { |
| if (!accessToken.trim()) return; |
| saveToken( |
| { data: { token: accessToken.trim(), refreshToken: refreshToken.trim() || undefined } as any }, |
| { |
| onSuccess: () => { |
| toast({ title: t.settingsSavedTitle, description: t.settingsSavedDesc }); |
| setAccessToken(""); |
| setRefreshToken(""); |
| queryClient.invalidateQueries({ queryKey: getGetConfigTokenQueryKey() }); |
| }, |
| onError: () => { |
| toast({ variant: "destructive", title: t.settingsSaveError, description: t.settingsSaveErrorDesc }); |
| }, |
| } |
| ); |
| }; |
|
|
| const handleRemove = () => { |
| removeToken(undefined, { |
| onSuccess: () => { |
| toast({ title: t.settingsRemovedTitle }); |
| queryClient.invalidateQueries({ queryKey: getGetConfigTokenQueryKey() }); |
| }, |
| }); |
| }; |
|
|
| return ( |
| <Dialog open={open} onOpenChange={setOpen}> |
| <DialogTrigger asChild> |
| <Button |
| variant="ghost" |
| size="icon" |
| className="relative" |
| title={t.navSettings} |
| > |
| <Settings className="w-4 h-4" /> |
| {tokenStatus && ( |
| <span className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full ${tokenStatus.configured ? "bg-emerald-400" : "bg-red-400"}`} /> |
| )} |
| </Button> |
| </DialogTrigger> |
| <DialogContent className="sm:max-w-lg bg-[#0e0a1a] border-violet-500/30"> |
| <DialogHeader> |
| <DialogTitle className="text-violet-200">{t.settingsTitle}</DialogTitle> |
| </DialogHeader> |
| |
| <div className="space-y-5 pt-2"> |
| {/* Status */} |
| <div className="flex items-center gap-3 p-3 rounded-lg bg-black/30 border border-violet-500/20"> |
| {isLoading ? ( |
| <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" /> |
| ) : tokenStatus?.configured ? ( |
| <> |
| <CheckCircle className="w-4 h-4 text-emerald-400 shrink-0" /> |
| <div className="flex-1 min-w-0"> |
| <p className="text-sm font-medium text-emerald-400">{t.settingsStatus}</p> |
| <p className="text-xs text-muted-foreground font-mono truncate">{tokenStatus.token}</p> |
| </div> |
| <Button |
| variant="ghost" |
| size="icon" |
| className="h-7 w-7 text-red-400 hover:text-red-300 hover:bg-red-400/10" |
| onClick={handleRemove} |
| disabled={isRemoving} |
| > |
| <Trash2 className="w-3.5 h-3.5" /> |
| </Button> |
| </> |
| ) : ( |
| <> |
| <XCircle className="w-4 h-4 text-red-400 shrink-0" /> |
| <p className="text-sm text-red-400">{t.settingsNoToken}</p> |
| </> |
| )} |
| </div> |
| |
| {/* Token Inputs */} |
| <div className="space-y-3"> |
| <div className="space-y-1.5"> |
| <Label className="text-violet-300 text-xs">{t.settingsAccessLabel}</Label> |
| <div className="relative"> |
| <Input |
| type={showToken ? "text" : "password"} |
| placeholder={t.settingsAccessPlaceholder} |
| value={accessToken} |
| onChange={(e) => setAccessToken(e.target.value)} |
| className="pr-10 font-mono text-xs bg-black/30 border-violet-500/30 focus-visible:ring-violet-400" |
| /> |
| <button |
| type="button" |
| className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" |
| onClick={() => setShowToken(!showToken)} |
| > |
| {showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} |
| </button> |
| </div> |
| </div> |
| |
| <div className="space-y-1.5"> |
| <Label className="text-violet-300 text-xs flex items-center gap-1.5"> |
| <RefreshCw className="w-3 h-3" /> |
| {t.settingsRefreshLabel} |
| </Label> |
| <Input |
| type={showToken ? "text" : "password"} |
| placeholder={t.settingsRefreshPlaceholder} |
| value={refreshToken} |
| onChange={(e) => setRefreshToken(e.target.value)} |
| className="font-mono text-xs bg-black/30 border-violet-500/30 focus-visible:ring-violet-400" |
| /> |
| </div> |
| |
| <Button |
| className="w-full" |
| onClick={handleSave} |
| disabled={!accessToken.trim() || isSaving} |
| > |
| {isSaving ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null} |
| {isSaving ? t.settingsSaving : t.settingsSave} |
| </Button> |
| </div> |
| |
| {/* How to get token */} |
| <div className="rounded-lg border border-violet-500/20 bg-violet-950/20 p-4 space-y-2"> |
| <p className="text-xs font-semibold text-violet-300">{t.settingsHowTitle}</p> |
| <ol className="text-xs text-muted-foreground space-y-1.5 list-decimal list-inside"> |
| <li> |
| {t.settingsStep1Pre}{" "} |
| <a |
| href="https://geminigen.ai" |
| target="_blank" |
| rel="noopener noreferrer" |
| className="text-violet-400 hover:underline inline-flex items-center gap-0.5" |
| > |
| geminigen.ai <ExternalLink className="w-3 h-3" /> |
| </a>{" "} |
| {t.settingsStep1Post} |
| </li> |
| <li>{t.settingsStep2}</li> |
| <li>{t.settingsStep3}</li> |
| <li> |
| {t.settingsStep4Pre}{" "} |
| <code className="text-violet-300 bg-black/30 px-1 rounded">access_token</code>{" "} |
| {t.settingsStep4Mid} |
| </li> |
| <li> |
| {t.settingsStep5Pre}{" "} |
| <code className="text-violet-300 bg-black/30 px-1 rounded">refresh_token</code>{" "} |
| {t.settingsStep5Mid} |
| </li> |
| </ol> |
| <p className="text-xs text-amber-400/80 mt-2"> |
| {t.settingsHint} |
| </p> |
| </div> |
| </div> |
| </DialogContent> |
| </Dialog> |
| ); |
| } |
|
|