diff --git a/apps/admin/src/app/[locale]/users/_user-actions-menu.tsx b/apps/admin/src/app/[locale]/users/_user-actions-menu.tsx index b5d7b9c..76a51b8 100644 --- a/apps/admin/src/app/[locale]/users/_user-actions-menu.tsx +++ b/apps/admin/src/app/[locale]/users/_user-actions-menu.tsx @@ -121,10 +121,7 @@ export function UserActionsMenu({ row, currentUserId }: Props) { {isAdmin ? ( - + Demote to user diff --git a/apps/web/src/app/(app)/dashboard/api-keys/page.tsx b/apps/web/src/app/(app)/dashboard/api-keys/page.tsx index fa7219b..242389c 100644 --- a/apps/web/src/app/(app)/dashboard/api-keys/page.tsx +++ b/apps/web/src/app/(app)/dashboard/api-keys/page.tsx @@ -1,278 +1,6 @@ -"use client"; - -import { Badge } from "@starter-saas/ui/components/badge"; -import { Button } from "@starter-saas/ui/components/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@starter-saas/ui/components/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@starter-saas/ui/components/dialog"; -import { EmptyState } from "@starter-saas/ui/components/empty-state"; -import { Input } from "@starter-saas/ui/components/input"; -import { Label } from "@starter-saas/ui/components/label"; -import { Skeleton } from "@starter-saas/ui/components/skeleton"; -import { Copy, Key, Plus, Trash2 } from "lucide-react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { PageHeader } from "@/components/app/page-header"; - -type Row = { - id: string; - name: string; - prefix: string; - scopes: string[]; - lastUsedAt: string | null; - expiresAt: string | null; - revokedAt: string | null; - createdAt: string; -}; - -type IssuedKey = { - id: string; - prefix: string; - plaintext: string; -}; +import { redirect } from "next/navigation"; +// Consolidated into /dashboard/settings (API keys tab). export default function ApiKeysPage() { - const [rows, setRows] = useState(null); - const [newName, setNewName] = useState(""); - const [busy, setBusy] = useState(false); - const [issued, setIssued] = useState(null); - - const load = async () => { - try { - const res = await fetch("/api/api-keys", { cache: "no-store" }); - if (!res.ok) { - throw new Error(`status ${res.status}`); - } - const data = (await res.json()) as { rows: Row[] }; - setRows(data.rows); - } catch (err) { - toast.error("Couldn't load API keys", { - description: err instanceof Error ? err.message : "?", - }); - setRows([]); - } - }; - - useEffect(() => { - void load(); - }, []); - - const create = async () => { - if (newName.trim().length < 1) { - toast.error("Give the key a name"); - return; - } - setBusy(true); - const toastId = toast.loading("Generating key…"); - try { - const res = await fetch("/api/api-keys", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: newName.trim() }), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(text || `status ${res.status}`); - } - const data = (await res.json()) as IssuedKey; - setIssued(data); - setNewName(""); - toast.success("Key created", { id: toastId }); - await load(); - } catch (err) { - toast.error("Couldn't create key", { - id: toastId, - description: err instanceof Error ? err.message : "?", - }); - } finally { - setBusy(false); - } - }; - - const revoke = async (row: Row) => { - if ( - !confirm(`Revoke ${row.name}? Apps using this key will stop working.`) - ) { - return; - } - const toastId = toast.loading("Revoking…"); - try { - const res = await fetch(`/api/api-keys/${row.id}`, { method: "DELETE" }); - if (!res.ok) { - throw new Error(`status ${res.status}`); - } - toast.success("Revoked", { id: toastId }); - await load(); - } catch (err) { - toast.error("Couldn't revoke", { - id: toastId, - description: err instanceof Error ? err.message : "?", - }); - } - }; - - return ( - <> - - - - - Generate a key - - -
{ - e.preventDefault(); - void create(); - }} - > -
- - setNewName(e.target.value)} - placeholder="laptop-cli, ci-bot, etc." - disabled={busy} - /> -
- -
-
-
- -
- {rows === null ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) : rows.length === 0 ? ( - - ) : ( -
    - {rows.map((row) => ( -
  • -
    - -
    -
    -

    {row.name}

    -

    - {row.prefix}… -

    -

    - {row.lastUsedAt - ? `Last used ${new Date(row.lastUsedAt).toLocaleString()}` - : "Never used"} - {row.expiresAt - ? ` · expires ${new Date(row.expiresAt).toLocaleDateString()}` - : ""} -

    -
    - {row.revokedAt ? ( - - Revoked - - ) : ( - - )} -
  • - ))} -
- )} -
- - { - if (!open) { - setIssued(null); - } - }} - /> - - ); -} - -function IssuedKeyDialog({ - keyData, - onOpenChange, -}: { - keyData: IssuedKey | null; - onOpenChange: (open: boolean) => void; -}) { - const open = keyData !== null; - const copy = async () => { - if (!keyData) { - return; - } - try { - await navigator.clipboard.writeText(keyData.plaintext); - toast.success("Copied"); - } catch { - toast.error("Clipboard blocked — copy manually"); - } - }; - return ( - - - - Copy this key now - - We won't show it again. Store it somewhere safe — anyone with this - token can act as you on the API. - - - {keyData ? ( -
-
- {keyData.plaintext} -
- -
- ) : null} - - - -
-
- ); + redirect("/dashboard/settings#api-keys" as never); } diff --git a/apps/web/src/app/(app)/dashboard/appearance/page.tsx b/apps/web/src/app/(app)/dashboard/appearance/page.tsx index 215bf89..68a8512 100644 --- a/apps/web/src/app/(app)/dashboard/appearance/page.tsx +++ b/apps/web/src/app/(app)/dashboard/appearance/page.tsx @@ -1,223 +1,6 @@ -"use client"; - -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@starter-saas/ui/components/card"; -import { Label } from "@starter-saas/ui/components/label"; -import { - RadioGroup, - RadioGroupItem, -} from "@starter-saas/ui/components/radio-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@starter-saas/ui/components/select"; -import { Switch } from "@starter-saas/ui/components/switch"; -import { cn } from "@starter-saas/ui/lib/utils"; -import { Monitor, Moon, Sun } from "lucide-react"; -import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { PageHeader } from "@/components/app/page-header"; - -const themes = [ - { id: "light", label: "Light", icon: Sun }, - { id: "dark", label: "Dark", icon: Moon }, - { id: "system", label: "System", icon: Monitor }, -]; - -type Prefs = { - theme: "light" | "dark" | "system"; - density: "comfortable" | "compact" | "spacious"; - locale: string; -}; - -async function savePrefs(patch: Partial): Promise { - const res = await fetch("/api/preferences", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify(patch), - }); - if (!res.ok) { - throw new Error(`status ${res.status}`); - } -} +import { redirect } from "next/navigation"; +// Consolidated into /dashboard/settings (Appearance tab). export default function AppearancePage() { - const { theme, setTheme } = useTheme(); - const [density, setDensity] = useState("comfortable"); - const [locale, setLocale] = useState("en"); - const [reduceMotion, setReduceMotion] = useState(false); - - useEffect(() => { - void (async () => { - try { - const res = await fetch("/api/preferences", { cache: "no-store" }); - if (!res.ok) { - return; - } - const prefs = (await res.json()) as Prefs; - setDensity(prefs.density); - setLocale(prefs.locale); - if (prefs.theme && prefs.theme !== theme) { - setTheme(prefs.theme); - } - } catch { - /* offline → keep defaults */ - } - })(); - // `setTheme` is stable; depending on `theme` would loop on remote sync. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const persist = async (patch: Partial) => { - try { - await savePrefs(patch); - } catch { - toast.error("Couldn't save preference — change kept locally"); - } - }; - - const onTheme = (next: string | null | undefined) => { - if (!next) { - return; - } - setTheme(next); - void persist({ theme: next as Prefs["theme"] }); - }; - - const onDensity = (next: string | null | undefined) => { - if (!next) { - return; - } - setDensity(next as Prefs["density"]); - void persist({ density: next as Prefs["density"] }); - }; - - const onLocale = (next: string | null | undefined) => { - if (!next) { - return; - } - setLocale(next); - void persist({ locale: next }); - }; - - return ( - <> - - -
- - - Theme - - Pick a color scheme. System follows your OS. - - - - - {themes.map((t) => { - const Icon = t.icon; - const active = theme === t.id; - return ( - - ); - })} - - - - - - - Density & language - - Tighter spacing and your preferred locale. - - - -
- - -
-
- - -
-
-
- - - - Motion - - Disable animations if you prefer a calmer interface. - - - - - - - -
- - ); + redirect("/dashboard/settings#appearance" as never); } diff --git a/apps/web/src/app/(app)/dashboard/billing/page.tsx b/apps/web/src/app/(app)/dashboard/billing/page.tsx index 9e1d9de..0e3e3dd 100644 --- a/apps/web/src/app/(app)/dashboard/billing/page.tsx +++ b/apps/web/src/app/(app)/dashboard/billing/page.tsx @@ -1,276 +1,6 @@ -"use client"; - -import { - findPlan, - formatPrice, - PLANS, - type PlanId, -} from "@starter-saas/billing/plans"; -import { - Alert, - AlertDescription, - AlertTitle, -} from "@starter-saas/ui/components/alert"; -import { Badge } from "@starter-saas/ui/components/badge"; -import { Button } from "@starter-saas/ui/components/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@starter-saas/ui/components/card"; -import { Skeleton } from "@starter-saas/ui/components/skeleton"; -import { cn } from "@starter-saas/ui/lib/utils"; -import { AlertCircle, Loader2, Sparkles } from "lucide-react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { PageHeader } from "@/components/app/page-header"; -import { authClient } from "@/lib/auth-client"; -import { formatError } from "@/lib/format-error"; - -type CustomerState = { - activeSubscriptions?: Array<{ - id: string; - productId: string; - currentPeriodEnd?: string | Date | null; - }>; -}; +import { redirect } from "next/navigation"; +// Consolidated into /dashboard/settings (Billing tab). export default function BillingPage() { - const [state, setState] = useState(null); - const [loading, setLoading] = useState(true); - const [pendingPlan, setPendingPlan] = useState(null); - const [opening, setOpening] = useState(false); - - useEffect(() => { - (async () => { - try { - const res = await authClient.customer.state(); - setState((res?.data as unknown as CustomerState) ?? {}); - } catch (err) { - toast.error(formatError(err as Error, "Couldn't load billing state")); - setState({}); - } finally { - setLoading(false); - } - })(); - }, []); - - const active = state?.activeSubscriptions?.[0]; - const currentPlanId: PlanId = active ? "pro" : "free"; - const current = findPlan(currentPlanId) ?? findPlan("free"); - - const onUpgrade = async (slug: string, id: PlanId) => { - setPendingPlan(id); - const toastId = toast.loading(`Opening ${slug} checkout…`); - try { - await authClient.checkout({ slug }); - toast.dismiss(toastId); - } catch (err) { - toast.error(formatError(err as Error, "Couldn't start checkout"), { - id: toastId, - description: - "Make sure POLAR_PRODUCT_ID_PRO is set in your .env and matches a product in your Polar dashboard.", - }); - setPendingPlan(null); - } - }; - - const onManage = async () => { - setOpening(true); - const toastId = toast.loading("Opening customer portal…"); - try { - await authClient.customer.portal(); - toast.dismiss(toastId); - } catch (err) { - toast.error(formatError(err as Error, "Couldn't open portal"), { - id: toastId, - }); - } finally { - setOpening(false); - } - }; - - const hasActiveSub = Boolean(active); - - return ( - <> - - - - - Current plan - What you're paying for today. - - -
- {loading ? ( - - ) : ( -
- - {current?.name ?? "Free"} - - - {hasActiveSub ? "Active" : "Free tier"} - -
- )} - {active?.currentPeriodEnd && ( -

- Renews{" "} - {new Date(active.currentPeriodEnd).toLocaleDateString( - undefined, - { dateStyle: "long" }, - )} -

- )} -
- {hasActiveSub && ( - - )} -
-
- -
-

Plans

-

- Defined in{" "} - packages/billing/src/plans.ts. - Polar product IDs come from .env. -

- -
- {PLANS.map((p) => { - const isCurrent = p.id === currentPlanId; - const isPaid = p.priceCents > 0; - const isPending = pendingPlan === p.id; - return ( - - -
- - {p.name} - {p.highlight && ( - - )} - - {isCurrent && ( - - Current - - )} -
- {p.description} -
- - {formatPrice(p)} - - {p.interval && ( - - /{p.interval === "month" ? "mo" : "yr"} - - )} -
-
- -
    - {p.features.map((f) => ( -
  • - - {f} -
  • - ))} -
-
- - {!isPaid ? ( - - ) : isCurrent ? ( - - ) : ( - - )} - -
- ); - })} -
- - {process.env.NODE_ENV === "development" && ( - - - Set Polar product IDs to enable checkout - - Add POLAR_PRODUCT_ID_PRO (and - optionally{" "} - POLAR_PRODUCT_ID_TEAM) to your{" "} - .env with the product IDs from{" "} - - sandbox.polar.sh - - , then restart pnpm dev. - - - )} -
- - ); + redirect("/dashboard/settings#billing" as never); } diff --git a/apps/web/src/app/(app)/dashboard/security/page.tsx b/apps/web/src/app/(app)/dashboard/security/page.tsx index 8bab6c4..58bb460 100644 --- a/apps/web/src/app/(app)/dashboard/security/page.tsx +++ b/apps/web/src/app/(app)/dashboard/security/page.tsx @@ -1,197 +1,6 @@ -"use client"; - -import { Badge } from "@starter-saas/ui/components/badge"; -import { Button } from "@starter-saas/ui/components/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@starter-saas/ui/components/card"; -import { EmptyState } from "@starter-saas/ui/components/empty-state"; -import { Label } from "@starter-saas/ui/components/label"; -import { Skeleton } from "@starter-saas/ui/components/skeleton"; -import { Switch } from "@starter-saas/ui/components/switch"; -import { - KeyRound, - Laptop, - LogOut, - ShieldCheck, - Smartphone, -} from "lucide-react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { GdprSection } from "@/components/app/gdpr-section"; -import { PageHeader } from "@/components/app/page-header"; -import { PasskeySection } from "@/components/app/passkey-section"; -import { authClient } from "@/lib/auth-client"; - -type SessionRow = { - id: string; - userAgent?: string | null; - ipAddress?: string | null; - createdAt: string | Date; - current?: boolean; -}; +import { redirect } from "next/navigation"; +// Consolidated into /dashboard/settings (Security tab). export default function SecurityPage() { - const { data } = authClient.useSession(); - // Derive during render — Better Auth's useSession is the source of - // truth. The wizard at /security/2fa flips this server-side and a - // session refresh propagates the new value. - const twoFA = Boolean( - (data?.user as { twoFactorEnabled?: boolean })?.twoFactorEnabled, - ); - const [sessions, setSessions] = useState(null); - - useEffect(() => { - (async () => { - try { - const res = await authClient.listSessions(); - setSessions(((res?.data as unknown as SessionRow[]) ?? []).slice(0, 6)); - } catch { - setSessions([]); - } - })(); - }, []); - - const revoke = async (id: string) => { - try { - await authClient.revokeSession({ token: id }); - toast.success("Session revoked"); - setSessions((s) => s?.filter((x) => x.id !== id) ?? null); - } catch (err) { - toast.error("Couldn't revoke", { - description: err instanceof Error ? err.message : "?", - }); - } - }; - - return ( - <> - - -
- - - - - Two-factor authentication - - - Require a one-time code in addition to your password. - - - - - { - toast.info( - next - ? "2FA setup flow — wire to /security/2fa wizard" - : "Disabling 2FA — wire confirmation modal", - ); - }} - /> - - - - - - - - - - Password - - - Change your password — you'll be signed out everywhere else. - - - - - - - - - - - - Active sessions - - - Devices currently signed in to your account. - - - - {sessions !== null && sessions.length === 0 ? ( - - ) : null} -
    - {sessions === null - ? Array.from({ length: 3 }).map((_, i) => ( -
  • - -
    - - -
    -
  • - )) - : sessions.map((s) => { - const ua = s.userAgent ?? ""; - const isMobile = /Mobile|Android|iPhone/i.test(ua); - const Icon = isMobile ? Smartphone : Laptop; - return ( -
  • -
    - -
    -
    -

    - {isMobile ? "Mobile" : "Desktop"} - {s.current && ( - - This device - - )} -

    -

    - {s.ipAddress ?? "—"} ·{" "} - {new Date(s.createdAt).toLocaleString()} -

    -
    - {!s.current && ( - - )} -
  • - ); - })} -
-
-
- - -
- - ); + redirect("/dashboard/settings#security" as never); } diff --git a/apps/web/src/app/(app)/dashboard/settings/page.tsx b/apps/web/src/app/(app)/dashboard/settings/page.tsx index f3fa377..d8a26c4 100644 --- a/apps/web/src/app/(app)/dashboard/settings/page.tsx +++ b/apps/web/src/app/(app)/dashboard/settings/page.tsx @@ -1,71 +1,115 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button } from "@starter-saas/ui/components/button"; import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@starter-saas/ui/components/card"; + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@starter-saas/ui/components/tabs"; import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@starter-saas/ui/components/form"; -import { Input } from "@starter-saas/ui/components/input"; -import { Loader2 } from "lucide-react"; + CreditCard, + KeyRound, + Palette, + Shield, + User, + Webhook, +} from "lucide-react"; +import type { ComponentType } from "react"; import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; import { PageHeader } from "@/components/app/page-header"; -import { authClient } from "@/lib/auth-client"; -import { formatError } from "@/lib/format-error"; +import { ApiKeysSection } from "@/components/settings/api-keys-section"; +import { AppearanceSection } from "@/components/settings/appearance-section"; +import { BillingSection } from "@/components/settings/billing-section"; +import { ProfileSection } from "@/components/settings/profile-section"; +import { SecuritySection } from "@/components/settings/security-section"; +import { WebhooksSection } from "@/components/settings/webhooks-section"; +import { type FeatureKey, isFeatureEnabled } from "@/config/features"; -const profile = z.object({ - name: z.string().min(2, "Required"), - email: z.email(), -}); +type Tab = { + id: string; + /** Feature gate. `null` = always shown (e.g. profile). */ + feature: FeatureKey | null; + label: string; + icon: ComponentType<{ className?: string }>; + render: () => React.ReactNode; +}; -type ProfileValues = z.infer; +const ALL_TABS: Tab[] = [ + { + id: "profile", + feature: null, + label: "Profile", + icon: User, + render: () => , + }, + { + id: "appearance", + feature: "appearance", + label: "Appearance", + icon: Palette, + render: () => , + }, + { + id: "billing", + feature: "billing", + label: "Billing", + icon: CreditCard, + render: () => , + }, + { + id: "security", + feature: "security", + label: "Security", + icon: Shield, + render: () => , + }, + { + id: "api-keys", + feature: "apiKeys", + label: "API keys", + icon: KeyRound, + render: () => , + }, + { + id: "webhooks", + feature: "webhooks", + label: "Webhooks", + icon: Webhook, + render: () => , + }, +]; -export default function SettingsPage() { - const { data } = authClient.useSession(); - const [saving, setSaving] = useState(false); +const tabs = ALL_TABS.filter( + (t) => t.feature === null || isFeatureEnabled(t.feature), +); + +function initialTab(): string { + if (typeof window === "undefined") { + return tabs[0]?.id ?? "profile"; + } + const hash = window.location.hash.replace(/^#/, ""); + if (tabs.some((t) => t.id === hash)) { + return hash; + } + return tabs[0]?.id ?? "profile"; +} - const form = useForm({ - resolver: zodResolver(profile), - values: { - name: data?.user?.name ?? "", - email: data?.user?.email ?? "", - }, - }); +export default function SettingsPage() { + const [active, setActive] = useState(() => initialTab()); useEffect(() => { - if (data?.user) { - form.reset({ name: data.user.name ?? "", email: data.user.email ?? "" }); - } - }, [data, form]); + const onHash = () => setActive(initialTab()); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); - const onSubmit = async (values: ProfileValues) => { - setSaving(true); - const id = toast.loading("Saving…"); - try { - const { error } = await authClient.updateUser({ name: values.name }); - if (error) { - toast.error(formatError(error, "Couldn't save"), { id }); - return; - } - toast.success("Profile updated", { id }); - } finally { - setSaving(false); + const onValueChange = (value: string | null | undefined) => { + if (!value) { + return; + } + setActive(value); + if (typeof window !== "undefined") { + window.history.replaceState(null, "", `#${value}`); } }; @@ -73,85 +117,44 @@ export default function SettingsPage() { <> - - - Profile - - Your name and email — what shows up on receipts and invites. - - -
- - - ( - - Full name - - - - - - )} - /> - ( - - Email - - - - - Changing your email triggers a re-verification. - - - - )} - /> - - - - -
- -
+ + - - - Danger zone - - Permanent actions. There is no undo. - - - -
-
-

Delete account

-

- Wipes your user, organizations you own, and all data. -

-
- -
-
-
+
+ {tabs.map((tab) => ( + + {tab.render()} + + ))} +
+
); } diff --git a/apps/web/src/app/(app)/dashboard/webhooks/page.tsx b/apps/web/src/app/(app)/dashboard/webhooks/page.tsx index bda9299..6df39aa 100644 --- a/apps/web/src/app/(app)/dashboard/webhooks/page.tsx +++ b/apps/web/src/app/(app)/dashboard/webhooks/page.tsx @@ -1,310 +1,6 @@ -"use client"; - -import { Badge } from "@starter-saas/ui/components/badge"; -import { Button } from "@starter-saas/ui/components/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@starter-saas/ui/components/card"; -import { EmptyState } from "@starter-saas/ui/components/empty-state"; -import { Input } from "@starter-saas/ui/components/input"; -import { Label } from "@starter-saas/ui/components/label"; -import { Skeleton } from "@starter-saas/ui/components/skeleton"; -import { Plus, Repeat, Trash2 } from "lucide-react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { PageHeader } from "@/components/app/page-header"; - -type Subscription = { - id: string; - url: string; - events: string[]; - status: string; - lastDeliveryAt: string | null; - failureCount: number; - createdAt: string; -}; - -type Delivery = { - id: string; - webhookId: string; - event: string; - status: string; - attempts: number; - responseCode: number | null; - deliveredAt: string | null; - createdAt: string; -}; - -function statusBadge(status: string) { - const variant = - status === "delivered" - ? "default" - : status === "failed" - ? "destructive" - : "secondary"; - return ( - - {status} - - ); -} +import { redirect } from "next/navigation"; +// Consolidated into /dashboard/settings (Webhooks tab). export default function WebhooksPage() { - const [subs, setSubs] = useState(null); - const [deliveries, setDeliveries] = useState(null); - const [url, setUrl] = useState(""); - const [eventsStr, setEventsStr] = useState(""); - const [busy, setBusy] = useState(false); - - const load = async () => { - try { - const [s, d] = await Promise.all([ - fetch("/api/webhooks/subscriptions", { cache: "no-store" }).then((r) => - r.json(), - ), - fetch("/api/webhooks/deliveries", { cache: "no-store" }).then((r) => - r.json(), - ), - ]); - setSubs(s.rows ?? []); - setDeliveries(d.rows ?? []); - } catch (err) { - toast.error("Couldn't load webhooks"); - setSubs([]); - setDeliveries([]); - } - }; - - useEffect(() => { - void load(); - }, []); - - const create = async () => { - if (!url.trim()) { - toast.error("Enter a URL"); - return; - } - setBusy(true); - const toastId = toast.loading("Creating subscription…"); - try { - const events = eventsStr - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - const res = await fetch("/api/webhooks/subscriptions", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ url: url.trim(), events }), - }); - if (!res.ok) { - throw new Error(`status ${res.status}`); - } - toast.success("Subscription created", { id: toastId }); - setUrl(""); - setEventsStr(""); - await load(); - } catch (err) { - toast.error("Couldn't create", { - id: toastId, - description: err instanceof Error ? err.message : "?", - }); - } finally { - setBusy(false); - } - }; - - const remove = async (id: string) => { - if (!confirm("Delete this webhook? Deliveries to it will stop.")) { - return; - } - const toastId = toast.loading("Deleting…"); - try { - await fetch(`/api/webhooks/subscriptions/${id}`, { method: "DELETE" }); - toast.success("Deleted", { id: toastId }); - await load(); - } catch { - toast.error("Couldn't delete", { id: toastId }); - } - }; - - const replay = async (id: string) => { - const toastId = toast.loading("Replaying…"); - try { - const res = await fetch(`/api/webhooks/deliveries/${id}/replay`, { - method: "POST", - }); - if (!res.ok) { - throw new Error(`status ${res.status}`); - } - toast.success("Re-queued — will fire on next worker tick", { - id: toastId, - }); - await load(); - } catch (err) { - toast.error("Couldn't replay", { - id: toastId, - description: err instanceof Error ? err.message : "?", - }); - } - }; - - return ( - <> - - -
- - - Add a subscription - - -
{ - e.preventDefault(); - void create(); - }} - > -
- - setUrl(e.target.value)} - placeholder="https://your-server.example.com/webhooks/stack" - disabled={busy} - /> -
-
- - setEventsStr(e.target.value)} - placeholder="user.created,subscription.updated · blank = all" - disabled={busy} - /> -
- -
-
-
- - - - Subscriptions - - - {subs === null ? ( -
- {Array.from({ length: 2 }).map((_, i) => ( - - ))} -
- ) : subs.length === 0 ? ( - - ) : ( -
    - {subs.map((s) => ( -
  • -
    -

    {s.url}

    -

    - {s.events.length === 0 - ? "all events" - : s.events.join(", ")} - {s.lastDeliveryAt - ? ` · last delivery ${new Date(s.lastDeliveryAt).toLocaleString()}` - : " · never delivered"} - {s.failureCount > 0 - ? ` · ${s.failureCount} failure${s.failureCount === 1 ? "" : "s"}` - : ""} -

    -
    - -
  • - ))} -
- )} -
-
- - - - Recent deliveries - - - {deliveries === null ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) : deliveries.length === 0 ? ( - - ) : ( -
    - {deliveries.map((d) => ( -
  • -
    -

    {d.event}

    -

    - {statusBadge(d.status)} · attempts {d.attempts} - {d.responseCode ? ` · HTTP ${d.responseCode}` : ""} - {d.deliveredAt - ? ` · delivered ${new Date(d.deliveredAt).toLocaleString()}` - : ""} -

    -
    - {d.status === "failed" || d.status === "retry" ? ( - - ) : null} -
  • - ))} -
- )} -
-
-
- - ); + redirect("/dashboard/settings#webhooks" as never); } diff --git a/apps/web/src/components/app/app-sidebar.tsx b/apps/web/src/components/app/app-sidebar.tsx index 9d1614f..df0a0f9 100644 --- a/apps/web/src/components/app/app-sidebar.tsx +++ b/apps/web/src/components/app/app-sidebar.tsx @@ -25,40 +25,82 @@ import { import { CreditCard, FolderOpen, - KeyRound, + Gift, + HandCoins, LayoutDashboard, LogOut, - Palette, Settings, - Shield, Users, - Webhook, } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import type { ComponentType } from "react"; +import { type FeatureKey, isFeatureEnabled } from "@/config/features"; import { authClient } from "@/lib/auth-client"; -const groups = [ +type Item = { + href: string; + label: string; + icon: ComponentType<{ className?: string }>; + feature?: FeatureKey; +}; + +type Group = { label: string; items: Item[] }; + +// One-source-of-truth nav. Items with a `feature` are filtered out at +// render time when that feature is disabled in `config/features.ts`. +// "Settings" lives at /dashboard/settings and contains profile + +// appearance + billing + security + api-keys + webhooks tabs in a hub. +const groups: readonly Group[] = [ { label: "Workspace", items: [ { href: "/dashboard", label: "Overview", icon: LayoutDashboard }, - { href: "/dashboard/organizations", label: "Organizations", icon: Users }, - { href: "/dashboard/files", label: "Files", icon: FolderOpen }, + { + href: "/dashboard/organizations", + label: "Organizations", + icon: Users, + feature: "organizations", + }, + { + href: "/dashboard/files", + label: "Files", + icon: FolderOpen, + feature: "files", + }, ], }, { - label: "Account", + label: "Growth", items: [ - { href: "/dashboard/settings", label: "Settings", icon: Settings }, - { href: "/dashboard/billing", label: "Billing", icon: CreditCard }, - { href: "/dashboard/appearance", label: "Appearance", icon: Palette }, - { href: "/dashboard/security", label: "Security", icon: Shield }, - { href: "/dashboard/api-keys", label: "API keys", icon: KeyRound }, - { href: "/dashboard/webhooks", label: "Webhooks", icon: Webhook }, + { + href: "/dashboard/affiliate", + label: "Affiliate", + icon: HandCoins, + feature: "affiliate", + }, + { + href: "/dashboard/referrals", + label: "Referrals", + icon: Gift, + feature: "referrals", + }, ], }, -] as const; + { + label: "Account", + items: [{ href: "/dashboard/settings", label: "Settings", icon: Settings }], + }, +]; + +function visibleGroups(): Group[] { + return groups + .map((g) => ({ + label: g.label, + items: g.items.filter((i) => !i.feature || isFeatureEnabled(i.feature)), + })) + .filter((g) => g.items.length > 0); +} export function AppSidebar() { const router = useRouter(); @@ -90,7 +132,7 @@ export function AppSidebar() { - {groups.map((g) => ( + {visibleGroups().map((g) => ( {g.label} @@ -156,12 +198,16 @@ export function AppSidebar() { Settings
- router.push("/dashboard/billing")} - > - - Billing - + {isFeatureEnabled("billing") ? ( + + router.push("/dashboard/settings#billing" as never) + } + > + + Billing + + ) : null} { diff --git a/apps/web/src/components/settings/api-keys-section.tsx b/apps/web/src/components/settings/api-keys-section.tsx new file mode 100644 index 0000000..611adcd --- /dev/null +++ b/apps/web/src/components/settings/api-keys-section.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { Badge } from "@starter-saas/ui/components/badge"; +import { Button } from "@starter-saas/ui/components/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@starter-saas/ui/components/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@starter-saas/ui/components/dialog"; +import { EmptyState } from "@starter-saas/ui/components/empty-state"; +import { Input } from "@starter-saas/ui/components/input"; +import { Label } from "@starter-saas/ui/components/label"; +import { Skeleton } from "@starter-saas/ui/components/skeleton"; +import { Copy, Key, Plus, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +type Row = { + id: string; + name: string; + prefix: string; + scopes: string[]; + lastUsedAt: string | null; + expiresAt: string | null; + revokedAt: string | null; + createdAt: string; +}; + +type IssuedKey = { + id: string; + prefix: string; + plaintext: string; +}; + +export function ApiKeysSection() { + const [rows, setRows] = useState(null); + const [newName, setNewName] = useState(""); + const [busy, setBusy] = useState(false); + const [issued, setIssued] = useState(null); + + const load = async () => { + try { + const res = await fetch("/api/api-keys", { cache: "no-store" }); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } + const data = (await res.json()) as { rows: Row[] }; + setRows(data.rows); + } catch (err) { + toast.error("Couldn't load API keys", { + description: err instanceof Error ? err.message : "?", + }); + setRows([]); + } + }; + + useEffect(() => { + void load(); + }, []); + + const create = async () => { + if (newName.trim().length < 1) { + toast.error("Give the key a name"); + return; + } + setBusy(true); + const toastId = toast.loading("Generating key…"); + try { + const res = await fetch("/api/api-keys", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: newName.trim() }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `status ${res.status}`); + } + const data = (await res.json()) as IssuedKey; + setIssued(data); + setNewName(""); + toast.success("Key created", { id: toastId }); + await load(); + } catch (err) { + toast.error("Couldn't create key", { + id: toastId, + description: err instanceof Error ? err.message : "?", + }); + } finally { + setBusy(false); + } + }; + + const revoke = async (row: Row) => { + if ( + !confirm(`Revoke ${row.name}? Apps using this key will stop working.`) + ) { + return; + } + const toastId = toast.loading("Revoking…"); + try { + const res = await fetch(`/api/api-keys/${row.id}`, { method: "DELETE" }); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } + toast.success("Revoked", { id: toastId }); + await load(); + } catch (err) { + toast.error("Couldn't revoke", { + id: toastId, + description: err instanceof Error ? err.message : "?", + }); + } + }; + + return ( + <> + + + Generate a key + + +
{ + e.preventDefault(); + void create(); + }} + > +
+ + setNewName(e.target.value)} + placeholder="laptop-cli, ci-bot, etc." + disabled={busy} + /> +
+ +
+
+
+ +
+ {rows === null ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : rows.length === 0 ? ( + + ) : ( +
    + {rows.map((row) => ( +
  • +
    + +
    +
    +

    {row.name}

    +

    + {row.prefix}… +

    +

    + {row.lastUsedAt + ? `Last used ${new Date(row.lastUsedAt).toLocaleString()}` + : "Never used"} + {row.expiresAt + ? ` · expires ${new Date(row.expiresAt).toLocaleDateString()}` + : ""} +

    +
    + {row.revokedAt ? ( + + Revoked + + ) : ( + + )} +
  • + ))} +
+ )} +
+ + { + if (!open) { + setIssued(null); + } + }} + /> + + ); +} + +function IssuedKeyDialog({ + keyData, + onOpenChange, +}: { + keyData: IssuedKey | null; + onOpenChange: (open: boolean) => void; +}) { + const open = keyData !== null; + const copy = async () => { + if (!keyData) { + return; + } + try { + await navigator.clipboard.writeText(keyData.plaintext); + toast.success("Copied"); + } catch { + toast.error("Clipboard blocked — copy manually"); + } + }; + return ( + + + + Copy this key now + + We won't show it again. Store it somewhere safe — anyone with this + token can act as you on the API. + + + {keyData ? ( +
+
+ {keyData.plaintext} +
+ +
+ ) : null} + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/appearance-section.tsx b/apps/web/src/components/settings/appearance-section.tsx new file mode 100644 index 0000000..56f1efb --- /dev/null +++ b/apps/web/src/components/settings/appearance-section.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@starter-saas/ui/components/card"; +import { Label } from "@starter-saas/ui/components/label"; +import { + RadioGroup, + RadioGroupItem, +} from "@starter-saas/ui/components/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@starter-saas/ui/components/select"; +import { Switch } from "@starter-saas/ui/components/switch"; +import { cn } from "@starter-saas/ui/lib/utils"; +import { Monitor, Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +const themes = [ + { id: "light", label: "Light", icon: Sun }, + { id: "dark", label: "Dark", icon: Moon }, + { id: "system", label: "System", icon: Monitor }, +]; + +type Prefs = { + theme: "light" | "dark" | "system"; + density: "comfortable" | "compact" | "spacious"; + locale: string; +}; + +async function savePrefs(patch: Partial): Promise { + const res = await fetch("/api/preferences", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } +} + +export function AppearanceSection() { + const { theme, setTheme } = useTheme(); + const [density, setDensity] = useState("comfortable"); + const [locale, setLocale] = useState("en"); + const [reduceMotion, setReduceMotion] = useState(false); + + useEffect(() => { + void (async () => { + try { + const res = await fetch("/api/preferences", { cache: "no-store" }); + if (!res.ok) { + return; + } + const prefs = (await res.json()) as Prefs; + setDensity(prefs.density); + setLocale(prefs.locale); + if (prefs.theme && prefs.theme !== theme) { + setTheme(prefs.theme); + } + } catch { + /* offline → keep defaults */ + } + })(); + // `setTheme` is stable; depending on `theme` would loop on remote sync. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const persist = async (patch: Partial) => { + try { + await savePrefs(patch); + } catch { + toast.error("Couldn't save preference — change kept locally"); + } + }; + + const onTheme = (next: string | null | undefined) => { + if (!next) { + return; + } + setTheme(next); + void persist({ theme: next as Prefs["theme"] }); + }; + + const onDensity = (next: string | null | undefined) => { + if (!next) { + return; + } + setDensity(next as Prefs["density"]); + void persist({ density: next as Prefs["density"] }); + }; + + const onLocale = (next: string | null | undefined) => { + if (!next) { + return; + } + setLocale(next); + void persist({ locale: next }); + }; + + return ( +
+ + + Theme + + Pick a color scheme. System follows your OS. + + + + + {themes.map((t) => { + const Icon = t.icon; + const active = theme === t.id; + return ( + + ); + })} + + + + + + + Density & language + + Tighter spacing and your preferred locale. + + + +
+ + +
+
+ + +
+
+
+ + + + Motion + + Disable animations if you prefer a calmer interface. + + + + + + + +
+ ); +} diff --git a/apps/web/src/components/settings/billing-section.tsx b/apps/web/src/components/settings/billing-section.tsx new file mode 100644 index 0000000..c2362ae --- /dev/null +++ b/apps/web/src/components/settings/billing-section.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { + findPlan, + formatPrice, + PLANS, + type PlanId, +} from "@starter-saas/billing/plans"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@starter-saas/ui/components/alert"; +import { Badge } from "@starter-saas/ui/components/badge"; +import { Button } from "@starter-saas/ui/components/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@starter-saas/ui/components/card"; +import { Skeleton } from "@starter-saas/ui/components/skeleton"; +import { cn } from "@starter-saas/ui/lib/utils"; +import { AlertCircle, Loader2, Sparkles } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { authClient } from "@/lib/auth-client"; +import { formatError } from "@/lib/format-error"; + +type CustomerState = { + activeSubscriptions?: Array<{ + id: string; + productId: string; + currentPeriodEnd?: string | Date | null; + }>; +}; + +export function BillingSection() { + const [state, setState] = useState(null); + const [loading, setLoading] = useState(true); + const [pendingPlan, setPendingPlan] = useState(null); + const [opening, setOpening] = useState(false); + + useEffect(() => { + (async () => { + try { + const res = await authClient.customer.state(); + setState((res?.data as unknown as CustomerState) ?? {}); + } catch (err) { + toast.error(formatError(err as Error, "Couldn't load billing state")); + setState({}); + } finally { + setLoading(false); + } + })(); + }, []); + + const active = state?.activeSubscriptions?.[0]; + const currentPlanId: PlanId = active ? "pro" : "free"; + const current = findPlan(currentPlanId) ?? findPlan("free"); + + const onUpgrade = async (slug: string, id: PlanId) => { + setPendingPlan(id); + const toastId = toast.loading(`Opening ${slug} checkout…`); + try { + await authClient.checkout({ slug }); + toast.dismiss(toastId); + } catch (err) { + toast.error(formatError(err as Error, "Couldn't start checkout"), { + id: toastId, + description: + "Make sure POLAR_PRODUCT_ID_PRO is set in your .env and matches a product in your Polar dashboard.", + }); + setPendingPlan(null); + } + }; + + const onManage = async () => { + setOpening(true); + const toastId = toast.loading("Opening customer portal…"); + try { + await authClient.customer.portal(); + toast.dismiss(toastId); + } catch (err) { + toast.error(formatError(err as Error, "Couldn't open portal"), { + id: toastId, + }); + } finally { + setOpening(false); + } + }; + + const hasActiveSub = Boolean(active); + + return ( +
+ + + Current plan + What you're paying for today. + + +
+ {loading ? ( + + ) : ( +
+ + {current?.name ?? "Free"} + + + {hasActiveSub ? "Active" : "Free tier"} + +
+ )} + {active?.currentPeriodEnd && ( +

+ Renews{" "} + {new Date(active.currentPeriodEnd).toLocaleDateString( + undefined, + { dateStyle: "long" }, + )} +

+ )} +
+ {hasActiveSub && ( + + )} +
+
+ +
+

Plans

+

+ Defined in{" "} + packages/billing/src/plans.ts. + Polar product IDs come from .env. +

+ +
+ {PLANS.map((p) => { + const isCurrent = p.id === currentPlanId; + const isPaid = p.priceCents > 0; + const isPending = pendingPlan === p.id; + return ( + + +
+ + {p.name} + {p.highlight && ( + + )} + + {isCurrent && ( + + Current + + )} +
+ {p.description} +
+ + {formatPrice(p)} + + {p.interval && ( + + /{p.interval === "month" ? "mo" : "yr"} + + )} +
+
+ +
    + {p.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ + {!isPaid ? ( + + ) : isCurrent ? ( + + ) : ( + + )} + +
+ ); + })} +
+ + {process.env.NODE_ENV === "development" && ( + + + Set Polar product IDs to enable checkout + + Add POLAR_PRODUCT_ID_PRO (and + optionally{" "} + POLAR_PRODUCT_ID_TEAM) to your{" "} + .env with the product IDs from{" "} + + sandbox.polar.sh + + , then restart pnpm dev. + + + )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/profile-section.tsx b/apps/web/src/components/settings/profile-section.tsx new file mode 100644 index 0000000..e6a5140 --- /dev/null +++ b/apps/web/src/components/settings/profile-section.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@starter-saas/ui/components/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@starter-saas/ui/components/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@starter-saas/ui/components/form"; +import { Input } from "@starter-saas/ui/components/input"; +import { Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { authClient } from "@/lib/auth-client"; +import { formatError } from "@/lib/format-error"; + +const profile = z.object({ + name: z.string().min(2, "Required"), + email: z.email(), +}); + +type ProfileValues = z.infer; + +export function ProfileSection() { + const { data } = authClient.useSession(); + const [saving, setSaving] = useState(false); + + const form = useForm({ + resolver: zodResolver(profile), + values: { + name: data?.user?.name ?? "", + email: data?.user?.email ?? "", + }, + }); + + useEffect(() => { + if (data?.user) { + form.reset({ name: data.user.name ?? "", email: data.user.email ?? "" }); + } + }, [data, form]); + + const onSubmit = async (values: ProfileValues) => { + setSaving(true); + const id = toast.loading("Saving…"); + try { + const { error } = await authClient.updateUser({ name: values.name }); + if (error) { + toast.error(formatError(error, "Couldn't save"), { id }); + return; + } + toast.success("Profile updated", { id }); + } finally { + setSaving(false); + } + }; + + return ( +
+ + + Profile + + Your name and email — what shows up on receipts and invites. + + +
+ + + ( + + Full name + + + + + + )} + /> + ( + + Email + + + + + Changing your email triggers a re-verification. + + + + )} + /> + + + + +
+ +
+ + + + Danger zone + + Permanent actions. There is no undo. + + + +
+
+

Delete account

+

+ Wipes your user, organizations you own, and all data. +

+
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/security-section.tsx b/apps/web/src/components/settings/security-section.tsx new file mode 100644 index 0000000..92d5b7f --- /dev/null +++ b/apps/web/src/components/settings/security-section.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { Badge } from "@starter-saas/ui/components/badge"; +import { Button } from "@starter-saas/ui/components/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@starter-saas/ui/components/card"; +import { EmptyState } from "@starter-saas/ui/components/empty-state"; +import { Label } from "@starter-saas/ui/components/label"; +import { Skeleton } from "@starter-saas/ui/components/skeleton"; +import { Switch } from "@starter-saas/ui/components/switch"; +import { + KeyRound, + Laptop, + LogOut, + ShieldCheck, + Smartphone, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { GdprSection } from "@/components/app/gdpr-section"; +import { PasskeySection } from "@/components/app/passkey-section"; +import { isFeatureEnabled } from "@/config/features"; +import { authClient } from "@/lib/auth-client"; + +type SessionRow = { + id: string; + userAgent?: string | null; + ipAddress?: string | null; + createdAt: string | Date; + current?: boolean; +}; + +export function SecuritySection() { + const { data } = authClient.useSession(); + const twoFA = Boolean( + (data?.user as { twoFactorEnabled?: boolean })?.twoFactorEnabled, + ); + const [sessions, setSessions] = useState(null); + + useEffect(() => { + (async () => { + try { + const res = await authClient.listSessions(); + setSessions(((res?.data as unknown as SessionRow[]) ?? []).slice(0, 6)); + } catch { + setSessions([]); + } + })(); + }, []); + + const revoke = async (id: string) => { + try { + await authClient.revokeSession({ token: id }); + toast.success("Session revoked"); + setSessions((s) => s?.filter((x) => x.id !== id) ?? null); + } catch (err) { + toast.error("Couldn't revoke", { + description: err instanceof Error ? err.message : "?", + }); + } + }; + + return ( +
+ {isFeatureEnabled("twoFactor") ? ( + + + + + Two-factor authentication + + + Require a one-time code in addition to your password. + + + + + { + toast.info( + next + ? "2FA setup flow — wire to /security/2fa wizard" + : "Disabling 2FA — wire confirmation modal", + ); + }} + /> + + + ) : null} + + {isFeatureEnabled("passkeys") ? : null} + + + + + + Password + + + Change your password — you'll be signed out everywhere else. + + + + + + + + + + + + Active sessions + + + Devices currently signed in to your account. + + + + {sessions !== null && sessions.length === 0 ? ( + + ) : null} +
    + {sessions === null + ? Array.from({ length: 3 }).map((_, i) => ( +
  • + +
    + + +
    +
  • + )) + : sessions.map((s) => { + const ua = s.userAgent ?? ""; + const isMobile = /Mobile|Android|iPhone/i.test(ua); + const Icon = isMobile ? Smartphone : Laptop; + return ( +
  • +
    + +
    +
    +

    + {isMobile ? "Mobile" : "Desktop"} + {s.current && ( + + This device + + )} +

    +

    + {s.ipAddress ?? "—"} ·{" "} + {new Date(s.createdAt).toLocaleString()} +

    +
    + {!s.current && ( + + )} +
  • + ); + })} +
+
+
+ + {isFeatureEnabled("gdpr") ? : null} +
+ ); +} diff --git a/apps/web/src/components/settings/webhooks-section.tsx b/apps/web/src/components/settings/webhooks-section.tsx new file mode 100644 index 0000000..bc6f00c --- /dev/null +++ b/apps/web/src/components/settings/webhooks-section.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { Badge } from "@starter-saas/ui/components/badge"; +import { Button } from "@starter-saas/ui/components/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@starter-saas/ui/components/card"; +import { EmptyState } from "@starter-saas/ui/components/empty-state"; +import { Input } from "@starter-saas/ui/components/input"; +import { Label } from "@starter-saas/ui/components/label"; +import { Skeleton } from "@starter-saas/ui/components/skeleton"; +import { Plus, Repeat, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +type Subscription = { + id: string; + url: string; + events: string[]; + status: string; + lastDeliveryAt: string | null; + failureCount: number; + createdAt: string; +}; + +type Delivery = { + id: string; + webhookId: string; + event: string; + status: string; + attempts: number; + responseCode: number | null; + deliveredAt: string | null; + createdAt: string; +}; + +function statusBadge(status: string) { + const variant = + status === "delivered" + ? "default" + : status === "failed" + ? "destructive" + : "secondary"; + return ( + + {status} + + ); +} + +export function WebhooksSection() { + const [subs, setSubs] = useState(null); + const [deliveries, setDeliveries] = useState(null); + const [url, setUrl] = useState(""); + const [eventsStr, setEventsStr] = useState(""); + const [busy, setBusy] = useState(false); + + const load = async () => { + try { + const [s, d] = await Promise.all([ + fetch("/api/webhooks/subscriptions", { cache: "no-store" }).then((r) => + r.json(), + ), + fetch("/api/webhooks/deliveries", { cache: "no-store" }).then((r) => + r.json(), + ), + ]); + setSubs(s.rows ?? []); + setDeliveries(d.rows ?? []); + } catch { + toast.error("Couldn't load webhooks"); + setSubs([]); + setDeliveries([]); + } + }; + + useEffect(() => { + void load(); + }, []); + + const create = async () => { + if (!url.trim()) { + toast.error("Enter a URL"); + return; + } + setBusy(true); + const toastId = toast.loading("Creating subscription…"); + try { + const events = eventsStr + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const res = await fetch("/api/webhooks/subscriptions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: url.trim(), events }), + }); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } + toast.success("Subscription created", { id: toastId }); + setUrl(""); + setEventsStr(""); + await load(); + } catch (err) { + toast.error("Couldn't create", { + id: toastId, + description: err instanceof Error ? err.message : "?", + }); + } finally { + setBusy(false); + } + }; + + const remove = async (id: string) => { + if (!confirm("Delete this webhook? Deliveries to it will stop.")) { + return; + } + const toastId = toast.loading("Deleting…"); + try { + await fetch(`/api/webhooks/subscriptions/${id}`, { method: "DELETE" }); + toast.success("Deleted", { id: toastId }); + await load(); + } catch { + toast.error("Couldn't delete", { id: toastId }); + } + }; + + const replay = async (id: string) => { + const toastId = toast.loading("Replaying…"); + try { + const res = await fetch(`/api/webhooks/deliveries/${id}/replay`, { + method: "POST", + }); + if (!res.ok) { + throw new Error(`status ${res.status}`); + } + toast.success("Re-queued — will fire on next worker tick", { + id: toastId, + }); + await load(); + } catch (err) { + toast.error("Couldn't replay", { + id: toastId, + description: err instanceof Error ? err.message : "?", + }); + } + }; + + return ( +
+ + + Add a subscription + + +
{ + e.preventDefault(); + void create(); + }} + > +
+ + setUrl(e.target.value)} + placeholder="https://your-server.example.com/webhooks/stack" + disabled={busy} + /> +
+
+ + setEventsStr(e.target.value)} + placeholder="user.created,subscription.updated · blank = all" + disabled={busy} + /> +
+ +
+
+
+ + + + Subscriptions + + + {subs === null ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ) : subs.length === 0 ? ( + + ) : ( +
    + {subs.map((s) => ( +
  • +
    +

    {s.url}

    +

    + {s.events.length === 0 + ? "all events" + : s.events.join(", ")} + {s.lastDeliveryAt + ? ` · last delivery ${new Date(s.lastDeliveryAt).toLocaleString()}` + : " · never delivered"} + {s.failureCount > 0 + ? ` · ${s.failureCount} failure${s.failureCount === 1 ? "" : "s"}` + : ""} +

    +
    + +
  • + ))} +
+ )} +
+
+ + + + Recent deliveries + + + {deliveries === null ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : deliveries.length === 0 ? ( + + ) : ( +
    + {deliveries.map((d) => ( +
  • +
    +

    {d.event}

    +

    + {statusBadge(d.status)} · attempts {d.attempts} + {d.responseCode ? ` · HTTP ${d.responseCode}` : ""} + {d.deliveredAt + ? ` · delivered ${new Date(d.deliveredAt).toLocaleString()}` + : ""} +

    +
    + {d.status === "failed" || d.status === "retry" ? ( + + ) : null} +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/config/features.ts b/apps/web/src/config/features.ts new file mode 100644 index 0000000..e1ff924 --- /dev/null +++ b/apps/web/src/config/features.ts @@ -0,0 +1,109 @@ +// Feature toggle registry — single source of truth for which features +// are exposed in this build of the starter. Flipping `enabled: false` +// hides the corresponding settings tab, sidebar link, and (for most +// features) prevents the API routes from being reachable. +// +// See docs/features/README.md for the full toggle guide. + +export type FeatureKey = + | "appearance" + | "billing" + | "security" + | "passkeys" + | "twoFactor" + | "apiKeys" + | "webhooks" + | "notifications" + | "gdpr" + | "files" + | "organizations" + | "affiliate" + | "referrals" + | "search"; + +export type FeatureConfig = { + enabled: boolean; + /** Human label used by the settings hub tabs and sidebar. */ + label: string; + /** Short description for docs / inline help. */ + description?: string; +}; + +// Edit this object to turn features on or off. Restart `pnpm dev` after +// changes (config is imported at module load). +export const features = { + appearance: { + enabled: true, + label: "Appearance", + description: "Theme, density, locale, motion preferences.", + }, + billing: { + enabled: true, + label: "Billing", + description: "Polar-backed subscriptions and plan management.", + }, + security: { + enabled: true, + label: "Security", + description: "Sessions, password, 2FA toggle, GDPR controls.", + }, + passkeys: { + enabled: true, + label: "Passkeys", + description: "WebAuthn sign-in via @better-auth/passkey.", + }, + twoFactor: { + enabled: true, + label: "Two-factor", + description: "TOTP second factor (Better Auth twoFactor plugin).", + }, + apiKeys: { + enabled: true, + label: "API keys", + description: "User-issued bearer tokens for /api/v1.", + }, + webhooks: { + enabled: true, + label: "Webhooks", + description: "Outbox-pattern outbound webhooks with retry + replay.", + }, + notifications: { + enabled: true, + label: "Notifications", + description: "In-app bell + cron-driven digest emails.", + }, + gdpr: { + enabled: true, + label: "GDPR", + description: "Data export + soft-delete account with 7d grace.", + }, + files: { + enabled: true, + label: "Files", + description: "R2-backed user file uploads with presigned PUT/GET.", + }, + organizations: { + enabled: true, + label: "Organizations", + description: "Multi-tenant workspaces with member invitations.", + }, + affiliate: { + enabled: false, + label: "Affiliate program", + description: "Code-based referrals + click attribution + payouts.", + }, + referrals: { + enabled: false, + label: "Refer a friend", + description: "Email-based invites with one-time reward credit.", + }, + search: { + enabled: true, + label: "Global search", + description: "Postgres ILIKE search + ⌘K command palette.", + }, +} as const satisfies Record; + +export function isFeatureEnabled(key: FeatureKey): boolean { + return features[key]?.enabled === true; +} diff --git a/docs/features/README.md b/docs/features/README.md new file mode 100644 index 0000000..07ea7cc --- /dev/null +++ b/docs/features/README.md @@ -0,0 +1,68 @@ +# Features — toggle + configure + +Every feature in this starter is **opt-in**. You can disable any of them by flipping a single boolean, and customize their behaviour by editing a single config file per feature. **No code changes required** — just edit, restart, ship. + +--- + +## 1. Toggle a feature on / off + +Open `apps/web/src/config/features.ts`. Each feature has an `enabled` flag: + +```ts +export const features = { + billing: { enabled: true, label: "Billing", ... }, + affiliate: { enabled: false, label: "Affiliate program", ... }, + // ... +}; +``` + +Flip `enabled: false` and restart `pnpm dev` (or rebuild). The result: + +- the matching **tab in `/dashboard/settings`** disappears +- the **sidebar link** disappears (where applicable) +- the **API routes** for that feature return 404 / 410 (where wired) + +The default ships with the boring-but-essential features on (auth, billing, security, files, organizations, search, notifications, GDPR) and the growth features off (affiliate, referrals) — flip them on when you're ready. + +--- + +## 2. Configure a feature's values + +Each feature keeps **one config file** with all editable values (plans, limits, retry budgets, cookie names, etc.). Edit values, never logic. + +| Feature | Config file | +|---|---| +| Billing — plans, pricing, features | `packages/billing/src/plans.ts` | +| Auth — rate-limit, RP name, plugins | `packages/auth/src/index.ts` (top of `createAuth()`) | +| Webhooks — timeout, max attempts, backoff | `packages/api/src/webhooks.ts` (top constants) | +| API keys — prefix, default scopes | `packages/api/src/api-keys.ts` (top constants) | +| Affiliate — payout min, default rate | `apps/web/.env` (`AFFILIATE_*`) — these can change per-deploy | +| Referrals — credit amount, max pending | `apps/web/.env` (`REFERRAL_*`) — same | +| GDPR — grace period, export TTL | `packages/api/src/gdpr.ts` (`DELETION_GRACE_DAYS`, `EXPORT_LINK_TTL_SECONDS`) | +| Notifications — poll interval | `apps/web/src/components/app/notification-bell.tsx` (`POLL_MS`) | +| Files — max bytes, MIME allowlist | `apps/web/src/app/(app)/dashboard/files/page.tsx` (`DEFAULT_MAX_BYTES`, `ACCEPT`) | +| Storage prefix — per-user isolation | `packages/storage/src/upload.ts` (`userPrefix()`) | + +The rule of thumb: anything you'd expect to change **per project** lives in a single file marked with a top comment. Anything you'd change **per deployment** lives in `.env`. Anything that's actually code (handlers, types, queries) lives where you'd expect. + +--- + +## 3. Add a new feature + +If you're adding a new feature that should be toggleable: + +1. Add an entry to `apps/web/src/config/features.ts` +2. Gate the settings tab + sidebar link with `isFeatureEnabled("yourFeature")` +3. Gate API routes (server-side): + ```ts + import { isFeatureEnabled } from "@/config/features"; + if (!isFeatureEnabled("yourFeature")) return new Response(null, { status: 404 }); + ``` +4. Put per-feature config in `packages//src/.ts` near the implementation, with a top comment marking it as the user-editable surface +5. Add a row to the table above + +--- + +## 4. Why some configs live in `packages/` instead of `apps/web/src/config/` + +Multiple apps (`apps/web`, `apps/marketing`, `apps/admin`) share the same plans / auth / webhook code via the workspace packages. Putting the config inside the package means **all three apps stay in sync automatically** — change billing plans once, the pricing page, the dashboard, and the admin all see the new values on next reload. The feature *toggle* registry stays in `apps/web/src/config/` because only the web app's settings hub renders it.