diff --git a/.env.example b/.env.example index cce5707..6690ecf 100644 --- a/.env.example +++ b/.env.example @@ -10,11 +10,15 @@ AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= EMAIL_PROVIDER=mailpit -EMAIL_FROM=Album +EMAIL_FROM=Manyangles SMTP_HOST=127.0.0.1 SMTP_PORT=1027 RESEND_API_KEY= -RESEND_FROM=Album +# Signing secret for POST /api/webhooks/resend (not the API key). +RESEND_WEBHOOK_SECRET= +# Production: set EMAIL_PROVIDER=resend and EMAIL_FROM to a verified Resend domain. +# Run the worker with the same email configuration. Never use production for tests. +RESEND_FROM=Manyangles S3_ENDPOINT=http://127.0.0.1:3900 S3_PUBLIC_ENDPOINT=http://127.0.0.1:3900 diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index 3b84367..7a4c0e7 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -1,39 +1,16 @@ import { redirect } from "next/navigation"; -import { headers } from "next/headers"; -import Link from "next/link"; -import { auth } from "@/server/auth"; -import { getPlatformRole } from "@/server/roles"; -import { Button } from "@/components/ui/button"; +import { createServerCaller } from "@/trpc/server"; +import { BackendShell } from "@/components/backend-shell"; -import { DashboardTabBar } from "@/components/dashboard-tab-bar"; - -export default async function AdminLayout({ - children, -}: { - children: React.ReactNode; -}) { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session) redirect("/sign-in?callbackURL=/admin"); - const role = await getPlatformRole(session.user.id); - if (!role) redirect("/dashboard"); +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const caller = await createServerCaller(); + const viewer = await caller.viewer.me(); + if (!viewer.session) redirect("/sign-in?callbackURL=/admin"); + if (!viewer.platformRole) redirect("/dashboard"); return ( - <> -
-
- - - -
-
{children}
-
- - + +
{children}
+
); } diff --git a/apps/web/src/app/admin/platform-codes.tsx b/apps/web/src/app/admin/platform-codes.tsx index e8c69bd..1e843e1 100644 --- a/apps/web/src/app/admin/platform-codes.tsx +++ b/apps/web/src/app/admin/platform-codes.tsx @@ -1,5 +1,7 @@ "use client"; +import { NativeSelect } from "@/components/ui/native-select"; + import { useState } from "react"; import { toast } from "sonner"; import { api } from "@/trpc/react"; @@ -78,8 +80,8 @@ export function PlatformCodes({ ) : null} {groups.length > 0 ? (
- + : null} +
)} + ; +} diff --git a/apps/web/src/app/dashboard/events/[id]/event-audit.tsx b/apps/web/src/app/dashboard/events/[id]/event-audit.tsx index f00c163..925ed90 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-audit.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-audit.tsx @@ -1,6 +1,11 @@ "use client"; import { api } from "@/trpc/react"; +import { useState } from "react"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ImageIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Card, CardContent, @@ -10,7 +15,9 @@ import { } from "@/components/ui/card"; export function EventAudit({ eventId }: { eventId: string }) { - const audit = api.manager.audit.useQuery({ eventId }); + const [page, setPage] = useState(0); + const [category, setCategory] = useState<"all" | "event" | "photo" | "note" | "guest" | "gallery" | "submission">("all"); + const audit = api.manager.audit.useQuery({ eventId, page, category }); return ( @@ -18,15 +25,44 @@ export function EventAudit({ eventId }: { eventId: string }) { Recent changes for this event. +
+ + + +
+ {audit.isLoading ?

Loading activity…

: null} + {audit.isError ?

Could not load activity.

: null} + {audit.data?.length === 0 ?

No activity recorded yet.

: null}
    {(audit.data ?? []).map((row) => ( -
  • - - {row.action} · {row.subjectType} - - - {new Date(row.createdAt).toLocaleString()} - +
  • +
    +

    {row.actorName ?? String(row.metadata.actorName ?? (row.actorUserId ? "Deleted account" : "System"))} · {row.action.replaceAll(".", " ")}

    + +
    +

    {row.subjectLabel}

    +

    {row.subjectType}: {row.subjectId}

    + {row.assetUrl ? ( + + + + + + + Referenced photo + {row.subjectLabel} + + Photo referenced by this audit entry + + + ) : null} + {Object.keys(row.metadata).length ?
    + {Object.entries(row.metadata).filter(([key]) => key !== "actorName" && key !== "subjectLabel").map(([key, value]) =>
    {key}
    {String(value ?? "—")}
    )} +
    : null}
  • ))}
diff --git a/apps/web/src/app/dashboard/events/[id]/event-notes.tsx b/apps/web/src/app/dashboard/events/[id]/event-notes.tsx index cfbc687..699ece3 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-notes.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-notes.tsx @@ -1,6 +1,9 @@ "use client"; import { api } from "@/trpc/react"; +import { Button } from "@/components/ui/button"; +import { CheckIcon, EyeOffIcon } from "lucide-react"; +import { toast } from "sonner"; import { Card, CardContent, @@ -15,7 +18,12 @@ import { EmptyTitle, } from "@/components/ui/empty"; -export function EventNotes({ eventId }: { eventId: string }) { +export function EventNotes({ eventId, canManage }: { eventId: string; canManage: boolean }) { + const utils = api.useUtils(); + const moderate = api.manager.moderateNote.useMutation({ + onSuccess: () => { void utils.manager.notes.invalidate({ eventId }); }, + onError: (error) => toast.error(error.message), + }); const notes = api.manager.notes.useQuery({ eventId }); const withNotes = (notes.data ?? []).filter((guest) => guest.note); @@ -24,7 +32,7 @@ export function EventNotes({ eventId }: { eventId: string }) { Notes - Messages guests left for the event people. + Messages guests left for the event people. Approved notes appear publicly only when note publishing is enabled in Settings. @@ -33,7 +41,7 @@ export function EventNotes({ eventId }: { eventId: string }) { No notes yet - Guests can leave a note when they upload. + Guests can send notes with or without photos. @@ -45,6 +53,11 @@ export function EventNotes({ eventId }: { eventId: string }) { {guest.displayName ?? "Anonymous"}

{guest.note}

+ {canManage ? : null} ))} diff --git a/apps/web/src/app/dashboard/events/[id]/event-people.tsx b/apps/web/src/app/dashboard/events/[id]/event-people.tsx index ca9eb35..af7d5ab 100644 --- a/apps/web/src/app/dashboard/events/[id]/event-people.tsx +++ b/apps/web/src/app/dashboard/events/[id]/event-people.tsx @@ -1,5 +1,7 @@ "use client"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + import { useState } from "react"; import { toast } from "sonner"; import type { EventRole } from "@album/contracts"; @@ -7,6 +9,10 @@ import { api } from "@/trpc/react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { MailIcon } from "lucide-react"; +import { effectiveEvent } from "@/lib/event-lifecycle"; +import { galleryIsPublic } from "@/lib/publishing"; +import { EmailHistory } from "./email-history"; import { Card, CardContent, @@ -16,6 +22,7 @@ import { } from "@/components/ui/card"; const roles: EventRole[] = ["owner", "manager", "moderator", "viewer"]; +const roleLabels: Record = { owner: "Owner", manager: "Manager", moderator: "Moderator", viewer: "Viewer" }; export function EventPeople({ eventId, @@ -28,6 +35,16 @@ export function EventPeople({ }) { const utils = api.useUtils(); const members = api.manager.members.useQuery({ eventId }); + const guests = api.manager.guests.useQuery({ eventId }, { refetchInterval: 5000 }); + const [guestSearch, setGuestSearch] = useState(""); + const [preview, setPreview] = useState(false); + const emailPreview = api.manager.emailPreview.useQuery({ eventId }, { enabled: preview }); + const notify = api.manager.notifyGuests.useMutation({ + onSuccess: async (result) => { toast.success(`${result.queued} emails queued`); setPreview(false); await utils.manager.guests.invalidate({ eventId }); }, + onError: (error) => toast.error(error.message), + }); + const previouslyContacted = new Set((guests.data ?? []).filter((guest) => guest.email && (guest.notifiedAt || guest.notificationClaimedAt)).map((guest) => guest.email!.toLowerCase())); + const eligible = new Set((guests.data ?? []).filter((guest) => guest.email && guest.notifyWhenReady && !previouslyContacted.has(guest.email.toLowerCase())).map((guest) => guest.email!.toLowerCase())).size; const [email, setEmail] = useState(""); const [role, setRole] = useState("manager"); const setMember = api.manager.setMember.useMutation({ @@ -55,10 +72,33 @@ export function EventPeople({ People - The couple can both be owners. Managers run the day. Moderators review photos. + Guests attend or receive gallery notifications. Accounts have permission to manage this event; attending does not grant account access. +

Guests

+ setGuestSearch(e.target.value)} /> + {guests.isLoading ?

Loading guests…

: guests.isError ?

Could not load guests.

: !guests.data?.length ?

No guests yet. Guests appear when they register their details or contribute.

: null} +
    + {(guests.data ?? []).filter((guest) => `${guest.displayName ?? ""} ${guest.email ?? ""}`.toLowerCase().includes(guestSearch.toLowerCase())).map((guest) =>
  • +

    {guest.displayName ?? "Anonymous guest"}

    {guest.email ?? "No email provided"}

    + {guest.notifiedAt ? `Emailed ${new Date(guest.notifiedAt).toLocaleDateString()}` : guest.notificationClaimedAt ? "Delivery pending / needs review" : guest.email && guest.notifyWhenReady ? "Opted into gallery email" : "Not subscribed"} +
  • )} +
+ {event.data?.permissions.includes("gallery.release") ?
+ +

Complete the event and make the gallery public first. Only opted-in guests who have not already been emailed are eligible; repeated addresses are deduplicated.

+ {preview ?
+

The gallery for {event.data.title} is ready

+ {emailPreview.data ?