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
+20
View File
@@ -24,6 +24,7 @@ import type {
} from "@/modules/_core/themes";
import { getCurrentSession } from "@/lib/session";
import { revokeShareLink } from "@/modules/_core/share";
import { createHouseholdApiToken, revokeHouseholdApiToken } from "@/modules/_core/api-token";
export interface ThemePatch {
palette?: Palette;
@@ -88,3 +89,22 @@ export async function revokeShareLinkAction(formData: FormData): Promise<void> {
await revokeShareLink(id);
revalidatePath("/settings");
}
function requireOwner(role: "owner" | "member") {
if (role !== "owner") throw new Error("Only household owners can manage API tokens");
}
export async function createApiTokenAction(): Promise<{ token: string }> {
const { household, role, user } = await getCurrentSession();
requireOwner(role);
const result = await createHouseholdApiToken(household.id, user.id);
revalidatePath("/settings");
return result;
}
export async function revokeApiTokenAction(): Promise<void> {
const { household, role } = await getCurrentSession();
requireOwner(role);
await revokeHouseholdApiToken(household.id);
revalidatePath("/settings");
}
+55 -31
View File
@@ -11,11 +11,13 @@ import { ResponsiveSidebar } from "@/components/settings-section";
import type { SectionId } from "@/components/settings-section";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions";
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
import { ApiTokenSettings } from "@/components/api-token-settings";
import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries";
import Link from "next/link";
import { NavIcon } from "@/components/nav-icon";
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus } from "lucide-react";
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus, KeyRound } from "lucide-react";
const VALID_SECTIONS = new Set<SectionId>([
"household",
@@ -36,7 +38,7 @@ export default async function SettingsPage({
? (sp.s as SectionId)
: "household";
const { user, household } = await getCurrentSession();
const { user, household, role } = await getCurrentSession();
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
@@ -51,7 +53,7 @@ export default async function SettingsPage({
)}
{section === "calendars" && <CalendarsAndListsSection />}
{section === "appearance" && <AppearanceSection user={user} />}
{section === "data" && <DataSection />}
{section === "data" && <DataSection householdId={household.id} role={role} />}
</div>
</div>
);
@@ -315,36 +317,58 @@ function AppearanceSection({
);
}
function DataSection() {
async function DataSection({
householdId,
role,
}: {
householdId: string;
role: "owner" | "member";
}) {
const tokenStatus = role === "owner" ? await getHouseholdApiTokenStatus(householdId) : null;
return (
<Card>
<CardHeader>
<CardTitle>Data & backups</CardTitle>
</CardHeader>
<div>
<div className="set-row">
<History className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Auto-backup</div>
<div className="d">Daily 03:00 /var/backups/famapp/. Configured via host cron.</div>
<>
{role === "owner" && tokenStatus && (
<Card>
<CardHeader>
<CardTitle>API access</CardTitle>
<KeyRound className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent className="p-0">
<ApiTokenSettings status={tokenStatus} />
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Data & backups</CardTitle>
</CardHeader>
<div>
<div className="set-row">
<History className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Auto-backup</div>
<div className="d">Daily 03:00 /var/backups/famapp/. Configured via host cron.</div>
</div>
</div>
<div className="set-row">
<Globe className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Server</div>
<div className="d">Self-hosted via Docker Compose</div>
</div>
</div>
<div className="set-row" style={{ borderBottom: "0" }}>
<Mail className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Export</div>
<div className="d">Not yet implemented coming in v0.5</div>
</div>
<Bell className="size-4 text-[var(--ink-faint)]" />
</div>
</div>
<div className="set-row">
<Globe className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Server</div>
<div className="d">Self-hosted via Docker Compose</div>
</div>
</div>
<div className="set-row" style={{ borderBottom: "0" }}>
<Mail className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Export</div>
<div className="d">Not yet implemented coming in v0.5</div>
</div>
<Bell className="size-4 text-[var(--ink-faint)]" />
</div>
</div>
</Card>
</Card>
</>
);
}