Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
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 { 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");
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="page-pad mx-auto w-full max-w-6xl py-6 pb-24 sm:py-10 sm:pb-10">
|
||||
<div className="mb-6 hidden items-center gap-1 sm:flex">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/admin">Overview</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/admin/settings">Settings</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/dashboard">Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="reveal">{children}</div>
|
||||
</main>
|
||||
<DashboardTabBar showAdmin area="admin" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createServerCaller } from "@/trpc/server";
|
||||
import { getPlatformRole } from "@/server/roles";
|
||||
import { auth } from "@/server/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { PlatformUsers } from "./platform-users";
|
||||
import { PlatformCodes } from "./platform-codes";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const role = session ? await getPlatformRole(session.user.id) : null;
|
||||
const caller = await createServerCaller();
|
||||
const [groups, events, audit] = await Promise.all([
|
||||
caller.platform.groups(),
|
||||
caller.platform.events(),
|
||||
caller.platform.audit(),
|
||||
]);
|
||||
const canManageUsers = role === "super_admin" || role === "admin";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Platform</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Full access for deployment operators.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Groups</CardTitle>
|
||||
<CardDescription>{groups.length} groups</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">
|
||||
{groups.map((group) => (
|
||||
<div key={group.id} className="flex justify-between gap-3">
|
||||
<span>{group.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{group.quota.unlimited
|
||||
? "unlimited"
|
||||
: `${group.quota.used}/${group.quota.eventLimit ?? 0}`}
|
||||
{group.quota.complimentary ? " · comp" : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Events</CardTitle>
|
||||
<CardDescription>{events.length} events</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2 text-sm">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="flex justify-between gap-3">
|
||||
<span>{event.title}</span>
|
||||
<div className="flex gap-1">
|
||||
<Badge variant="secondary">{event.status}</Badge>
|
||||
{event.listed ? <Badge variant="outline">listed</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{canManageUsers ? <PlatformUsers /> : null}
|
||||
{canManageUsers ? (
|
||||
<PlatformCodes groups={groups.map((group) => ({ id: group.id, name: group.name }))} />
|
||||
) : null}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col gap-2 text-sm">
|
||||
{audit.slice(0, 40).map((row) => (
|
||||
<li key={row.id} className="flex justify-between gap-3">
|
||||
<span>
|
||||
{row.action} · {row.subjectType}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(row.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export function PlatformCodes({
|
||||
groups,
|
||||
}: {
|
||||
groups: { id: string; name: string }[];
|
||||
}) {
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [groupId, setGroupId] = useState(groups[0]?.id ?? "");
|
||||
const createCode = api.platform.createCode.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setCode(result.code);
|
||||
toast.success("Code created");
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
const grant = api.platform.grantEntitlement.useMutation({
|
||||
onSuccess: () => toast.success("Complimentary unlimited granted"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invites and entitlements</CardTitle>
|
||||
<CardDescription>
|
||||
One-time or reusable codes, and complimentary event access.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
createCode.mutate({
|
||||
reusable: false,
|
||||
maxUses: 1,
|
||||
grantEventLimit: 1,
|
||||
grantComplimentary: true,
|
||||
groupRole: "owner",
|
||||
eventRole: "owner",
|
||||
})
|
||||
}
|
||||
>
|
||||
One-time create code
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
createCode.mutate({
|
||||
reusable: true,
|
||||
maxUses: 100,
|
||||
grantUnlimitedEvents: true,
|
||||
grantComplimentary: true,
|
||||
groupRole: "owner",
|
||||
eventRole: "owner",
|
||||
})
|
||||
}
|
||||
>
|
||||
Reusable unlimited code
|
||||
</Button>
|
||||
</div>
|
||||
{code ? (
|
||||
<p className="text-sm">
|
||||
Share once: <code>{code}</code>
|
||||
</p>
|
||||
) : null}
|
||||
{groups.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="rounded-md border bg-background px-2 py-1.5 text-sm"
|
||||
value={groupId}
|
||||
onChange={(event) => setGroupId(event.target.value)}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!groupId}
|
||||
onClick={() =>
|
||||
grant.mutate({
|
||||
groupId,
|
||||
eventLimit: null,
|
||||
complimentary: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Make selected group unlimited
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { PlatformRole } from "@album/contracts";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
const roles: Array<PlatformRole | "none"> = [
|
||||
"none",
|
||||
"viewer",
|
||||
"moderator",
|
||||
"admin",
|
||||
"super_admin",
|
||||
];
|
||||
|
||||
export function PlatformUsers() {
|
||||
const [query, setQuery] = useState("");
|
||||
const users = api.platform.users.useQuery({ query: query || undefined });
|
||||
const setRole = api.platform.setPlatformRole.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Role updated");
|
||||
await users.refetch();
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
<CardDescription>Grant platform roles.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Input
|
||||
placeholder="Search name or email"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{(users.data ?? []).map((row) => (
|
||||
<li key={row.id} className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">{row.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{row.platformRole ? (
|
||||
<Badge variant="secondary">{row.platformRole}</Badge>
|
||||
) : null}
|
||||
<select
|
||||
className="rounded-md border bg-background px-2 py-1.5 text-sm"
|
||||
value={row.platformRole ?? "none"}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value as PlatformRole | "none";
|
||||
setRole.mutate({
|
||||
userId: row.id,
|
||||
role: value === "none" ? null : value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { EventCreatePolicy } from "@album/contracts";
|
||||
import { api } from "@/trpc/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export function DeploymentSettingsForm({
|
||||
openSignup,
|
||||
eventCreatePolicy,
|
||||
defaultEventLimit,
|
||||
}: {
|
||||
openSignup: boolean;
|
||||
eventCreatePolicy: EventCreatePolicy;
|
||||
defaultEventLimit: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(openSignup);
|
||||
const [policy, setPolicy] = useState(eventCreatePolicy);
|
||||
const [limit, setLimit] = useState(String(defaultEventLimit));
|
||||
const update = api.platform.updateSettings.useMutation({
|
||||
onSuccess: () => toast.success("Settings saved"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Access</CardTitle>
|
||||
<CardDescription>
|
||||
Open signup is for accounts. Event creation can still require an invite
|
||||
or an administrator.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
className="flex flex-col gap-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
update.mutate({
|
||||
openSignup: open,
|
||||
eventCreatePolicy: policy,
|
||||
defaultEventLimit: Number(limit),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="signup">Open signups</FieldLabel>
|
||||
<Switch id="signup" checked={open} onCheckedChange={setOpen} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="policy">Event creation</FieldLabel>
|
||||
<select
|
||||
id="policy"
|
||||
className="rounded-md border bg-background px-2 py-2"
|
||||
value={policy}
|
||||
onChange={(event) =>
|
||||
setPolicy(event.target.value as EventCreatePolicy)
|
||||
}
|
||||
>
|
||||
<option value="open">Open (quota still applies)</option>
|
||||
<option value="invite">Invite code required</option>
|
||||
<option value="admin_only">Administrators only</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="limit">Default event limit for new groups</FieldLabel>
|
||||
<Input
|
||||
id="limit"
|
||||
type="number"
|
||||
min={0}
|
||||
value={limit}
|
||||
onChange={(event) => setLimit(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Button type="submit" disabled={update.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/server/auth";
|
||||
import { getPlatformRole } from "@/server/roles";
|
||||
import { hasPlatformPermission } from "@/server/roles";
|
||||
import { PLATFORM_PERMISSIONS } from "@/server/permissions";
|
||||
import { createServerCaller } from "@/trpc/server";
|
||||
import { DeploymentSettingsForm } from "./deployment-settings-form";
|
||||
|
||||
export default async function AdminSettingsPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const role = session ? await getPlatformRole(session.user.id) : null;
|
||||
if (!hasPlatformPermission(role, PLATFORM_PERMISSIONS.SETTINGS_MANAGE)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
const caller = await createServerCaller();
|
||||
const settings = await caller.platform.settings();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">Deployment settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Signup policy and event creation rules for this install.
|
||||
</p>
|
||||
</div>
|
||||
<DeploymentSettingsForm
|
||||
openSignup={settings.openSignup}
|
||||
eventCreatePolicy={settings.eventCreatePolicy}
|
||||
defaultEventLimit={settings.defaultEventLimit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user