feat: household api token auth foundation

This commit is contained in:
ginnoir
2026-07-04 18:57:31 -05:00
parent 2c0c31540e
commit ea5d1d050c
11 changed files with 465 additions and 32 deletions
+118
View File
@@ -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 &lt;token&gt;</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>
</>
);
}