feat: household api token auth foundation
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Check, Copy, KeyRound } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { createApiTokenAction, revokeApiTokenAction } from "@/app/settings/actions";
|
||||
import type { HouseholdApiTokenStatus } from "@/modules/_core/api-token";
|
||||
|
||||
export function ApiTokenSettings({ status }: { status: HouseholdApiTokenStatus }) {
|
||||
const [tokenStatus, setTokenStatus] = useState(status);
|
||||
const [rawToken, setRawToken] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function generateToken() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await createApiTokenAction();
|
||||
setRawToken(result.token);
|
||||
setDialogOpen(true);
|
||||
setTokenStatus({
|
||||
hasActiveToken: true,
|
||||
lastUsedAt: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to generate token");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function revokeToken() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await revokeApiTokenAction();
|
||||
setTokenStatus({ hasActiveToken: false, lastUsedAt: null, createdAt: null });
|
||||
toast.success("API token revoked");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to revoke token");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
if (!rawToken) return;
|
||||
navigator.clipboard.writeText(rawToken).then(() => {
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="set-row" style={{ borderBottom: "0" }}>
|
||||
<KeyRound className="size-4 text-[var(--ink-soft)]" />
|
||||
<div className="label flex-1">
|
||||
<div className="t">API token</div>
|
||||
<div className="d">
|
||||
{tokenStatus.hasActiveToken ? (
|
||||
<>
|
||||
Active
|
||||
{tokenStatus.createdAt && (
|
||||
<> · created {tokenStatus.createdAt.toLocaleDateString()}</>
|
||||
)}
|
||||
{tokenStatus.lastUsedAt && (
|
||||
<> · last used {tokenStatus.lastUsedAt.toLocaleString()}</>
|
||||
)}
|
||||
{!tokenStatus.lastUsedAt && <> · never used</>}
|
||||
</>
|
||||
) : (
|
||||
<>No active token — generate one for scripts and automations</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{tokenStatus.hasActiveToken ? (
|
||||
<Button variant="destructive" size="sm" onClick={revokeToken} disabled={isPending}>
|
||||
Revoke token
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={generateToken} disabled={isPending}>
|
||||
{tokenStatus.hasActiveToken ? "Regenerate" : "Generate token"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API token created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy this token now — it will not be shown again. Use it as{" "}
|
||||
<code className="text-xs">Authorization: Bearer <token></code> on{" "}
|
||||
<code className="text-xs">/api/v1/</code> requests.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input readOnly value={rawToken ?? ""} className="font-mono text-xs" />
|
||||
<Button variant="outline" size="icon" onClick={copyToken} aria-label="Copy token">
|
||||
{copied ? <Check className="text-[var(--c-success)]" /> : <Copy />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user