Skip to content

Commit 4cb71e3

Browse files
committed
Admin page for adding data stores
1 parent a19f3f8 commit 4cb71e3

2 files changed

Lines changed: 372 additions & 0 deletions

File tree

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
import { useState } from "react";
2+
import { useFetcher } from "@remix-run/react";
3+
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
4+
import { redirect } from "@remix-run/server-runtime";
5+
import { typedjson, useTypedLoaderData } from "remix-typedjson";
6+
import { z } from "zod";
7+
import { Button } from "~/components/primitives/Buttons";
8+
import {
9+
Dialog,
10+
DialogContent,
11+
DialogFooter,
12+
DialogHeader,
13+
DialogTitle,
14+
} from "~/components/primitives/Dialog";
15+
import { Input } from "~/components/primitives/Input";
16+
import { Paragraph } from "~/components/primitives/Paragraph";
17+
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
18+
import {
19+
Table,
20+
TableBlankRow,
21+
TableBody,
22+
TableCell,
23+
TableHeader,
24+
TableHeaderCell,
25+
TableRow,
26+
} from "~/components/primitives/Table";
27+
import { prisma } from "~/db.server";
28+
import { requireUser } from "~/services/session.server";
29+
import { getSecretStore } from "~/services/secrets/secretStore.server";
30+
import { ClickhouseConnectionSchema } from "~/services/clickhouse/clickhouseSecretSchemas.server";
31+
32+
// ---------------------------------------------------------------------------
33+
// Loader
34+
// ---------------------------------------------------------------------------
35+
36+
export const loader = async ({ request }: LoaderFunctionArgs) => {
37+
const user = await requireUser(request);
38+
if (!user.admin) throw redirect("/");
39+
40+
const dataStores = await prisma.organizationDataStore.findMany({
41+
orderBy: { createdAt: "desc" },
42+
});
43+
44+
return typedjson({ dataStores });
45+
};
46+
47+
// ---------------------------------------------------------------------------
48+
// Action
49+
// ---------------------------------------------------------------------------
50+
51+
const AddSchema = z.object({
52+
_action: z.literal("add"),
53+
key: z.string().min(1),
54+
organizationIds: z.string().min(1),
55+
connectionUrl: z.string().url(),
56+
});
57+
58+
const DeleteSchema = z.object({
59+
_action: z.literal("delete"),
60+
id: z.string().min(1),
61+
});
62+
63+
export async function action({ request }: ActionFunctionArgs) {
64+
const user = await requireUser(request);
65+
if (!user.admin) throw redirect("/");
66+
67+
const formData = await request.formData();
68+
const _action = formData.get("_action");
69+
70+
if (_action === "add") {
71+
const result = AddSchema.safeParse(Object.fromEntries(formData));
72+
if (!result.success) {
73+
return typedjson(
74+
{ error: result.error.issues.map((i) => i.message).join(", ") },
75+
{ status: 400 }
76+
);
77+
}
78+
79+
const { key, organizationIds: rawOrgIds, connectionUrl } = result.data;
80+
const organizationIds = rawOrgIds
81+
.split(",")
82+
.map((s) => s.trim())
83+
.filter(Boolean);
84+
85+
const secretKey = `data-store:${key}:clickhouse`;
86+
87+
const secretStore = getSecretStore("DATABASE");
88+
await secretStore.setSecret(secretKey, ClickhouseConnectionSchema.parse({ url: connectionUrl }));
89+
90+
await prisma.organizationDataStore.create({
91+
data: {
92+
key,
93+
organizationIds,
94+
kind: "CLICKHOUSE",
95+
config: { version: 1, data: { secretKey } },
96+
},
97+
});
98+
99+
100+
return typedjson({ success: true });
101+
}
102+
103+
if (_action === "delete") {
104+
const result = DeleteSchema.safeParse(Object.fromEntries(formData));
105+
if (!result.success) {
106+
return typedjson({ error: "Invalid request" }, { status: 400 });
107+
}
108+
109+
const { id } = result.data;
110+
111+
const dataStore = await prisma.organizationDataStore.findFirst({ where: { id } });
112+
if (!dataStore) {
113+
return typedjson({ error: "Data store not found" }, { status: 404 });
114+
}
115+
116+
// Delete secret if config references one
117+
const config = dataStore.config as any;
118+
if (config?.data?.secretKey) {
119+
const secretStore = getSecretStore("DATABASE");
120+
await secretStore.deleteSecret(config.data.secretKey).catch(() => {
121+
// Secret may not exist — proceed with deletion
122+
});
123+
}
124+
125+
await prisma.organizationDataStore.delete({ where: { id } });
126+
127+
return typedjson({ success: true });
128+
}
129+
130+
return typedjson({ error: "Unknown action" }, { status: 400 });
131+
}
132+
133+
// ---------------------------------------------------------------------------
134+
// Component
135+
// ---------------------------------------------------------------------------
136+
137+
export default function AdminDataStoresRoute() {
138+
const { dataStores } = useTypedLoaderData<typeof loader>();
139+
const [addOpen, setAddOpen] = useState(false);
140+
141+
return (
142+
<main className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4">
143+
<div className="space-y-4">
144+
<div className="flex items-center justify-between">
145+
<Paragraph variant="small" className="text-text-dimmed">
146+
{dataStores.length} data store{dataStores.length !== 1 ? "s" : ""}
147+
</Paragraph>
148+
<Button variant="primary/small" onClick={() => setAddOpen(true)}>
149+
Add data store
150+
</Button>
151+
</div>
152+
153+
<Table>
154+
<TableHeader>
155+
<TableRow>
156+
<TableHeaderCell>Key</TableHeaderCell>
157+
<TableHeaderCell>Kind</TableHeaderCell>
158+
<TableHeaderCell>Organizations</TableHeaderCell>
159+
<TableHeaderCell>Created</TableHeaderCell>
160+
<TableHeaderCell>Updated</TableHeaderCell>
161+
<TableHeaderCell>
162+
<span className="sr-only">Actions</span>
163+
</TableHeaderCell>
164+
</TableRow>
165+
</TableHeader>
166+
<TableBody>
167+
{dataStores.length === 0 ? (
168+
<TableBlankRow colSpan={6}>
169+
<Paragraph>No data stores configured</Paragraph>
170+
</TableBlankRow>
171+
) : (
172+
dataStores.map((ds) => (
173+
<TableRow key={ds.id}>
174+
<TableCell>
175+
<span className="font-mono text-xs text-text-bright">{ds.key}</span>
176+
</TableCell>
177+
<TableCell>
178+
<span className="inline-flex rounded-sm bg-indigo-500/20 px-1.5 py-0.5 text-[11px] font-medium text-indigo-400">
179+
{ds.kind}
180+
</span>
181+
</TableCell>
182+
<TableCell>
183+
<span className="text-xs text-text-dimmed">
184+
{ds.organizationIds.length} org{ds.organizationIds.length !== 1 ? "s" : ""}
185+
</span>
186+
{ds.organizationIds.length > 0 && (
187+
<span
188+
className="ml-1 text-xs text-text-dimmed"
189+
title={ds.organizationIds.join(", ")}
190+
>
191+
({ds.organizationIds.slice(0, 2).join(", ")}
192+
{ds.organizationIds.length > 2
193+
? ` +${ds.organizationIds.length - 2} more`
194+
: ""}
195+
)
196+
</span>
197+
)}
198+
</TableCell>
199+
<TableCell>
200+
<span className="text-xs text-text-dimmed">
201+
{new Date(ds.createdAt).toLocaleString()}
202+
</span>
203+
</TableCell>
204+
<TableCell>
205+
<span className="text-xs text-text-dimmed">
206+
{new Date(ds.updatedAt).toLocaleString()}
207+
</span>
208+
</TableCell>
209+
<TableCell isSticky>
210+
<DeleteButton id={ds.id} name={ds.key} />
211+
</TableCell>
212+
</TableRow>
213+
))
214+
)}
215+
</TableBody>
216+
</Table>
217+
</div>
218+
219+
<AddDataStoreDialog open={addOpen} onOpenChange={setAddOpen} />
220+
</main>
221+
);
222+
}
223+
224+
// ---------------------------------------------------------------------------
225+
// Delete button with popover confirmation
226+
// ---------------------------------------------------------------------------
227+
228+
function DeleteButton({ id, name }: { id: string; name: string }) {
229+
const [open, setOpen] = useState(false);
230+
const fetcher = useFetcher<{ success?: boolean; error?: string }>();
231+
const isDeleting = fetcher.state !== "idle";
232+
233+
return (
234+
<Popover open={open} onOpenChange={setOpen}>
235+
<PopoverTrigger asChild>
236+
<Button variant="danger/small" disabled={isDeleting}>
237+
{isDeleting ? "Deleting…" : "Delete"}
238+
</Button>
239+
</PopoverTrigger>
240+
<PopoverContent align="end" className="w-72 space-y-3">
241+
<Paragraph variant="small" className="text-text-bright">
242+
Delete <span className="font-mono font-medium">{name}</span>?
243+
</Paragraph>
244+
<Paragraph variant="extra-small" className="text-text-dimmed">
245+
This will remove the data store and its secret. Organizations using it will fall back to
246+
the default ClickHouse instance.
247+
</Paragraph>
248+
<div className="flex items-center justify-end gap-2">
249+
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
250+
Cancel
251+
</Button>
252+
<fetcher.Form method="post" onSubmit={() => setOpen(false)}>
253+
<input type="hidden" name="_action" value="delete" />
254+
<input type="hidden" name="id" value={id} />
255+
<Button type="submit" variant="danger/small">
256+
Confirm delete
257+
</Button>
258+
</fetcher.Form>
259+
</div>
260+
</PopoverContent>
261+
</Popover>
262+
);
263+
}
264+
265+
// ---------------------------------------------------------------------------
266+
// Add data store dialog
267+
// ---------------------------------------------------------------------------
268+
269+
function AddDataStoreDialog({
270+
open,
271+
onOpenChange,
272+
}: {
273+
open: boolean;
274+
onOpenChange: (open: boolean) => void;
275+
}) {
276+
const fetcher = useFetcher<{ success?: boolean; error?: string }>();
277+
const isSubmitting = fetcher.state !== "idle";
278+
279+
// Close dialog on success
280+
if (fetcher.data?.success && open) {
281+
onOpenChange(false);
282+
}
283+
284+
return (
285+
<Dialog open={open} onOpenChange={onOpenChange}>
286+
<DialogContent className="sm:max-w-lg">
287+
<DialogHeader>
288+
<DialogTitle>Add data store</DialogTitle>
289+
</DialogHeader>
290+
291+
<fetcher.Form method="post" className="space-y-4 pt-2">
292+
<input type="hidden" name="_action" value="add" />
293+
294+
<div className="space-y-1.5">
295+
<label className="text-xs font-medium text-text-dimmed">
296+
Key <span className="text-rose-400">*</span>
297+
</label>
298+
<Input
299+
name="key"
300+
placeholder="e.g. hipaa-clickhouse-us-east"
301+
variant="medium"
302+
required
303+
className="font-mono"
304+
/>
305+
<p className="text-[11px] text-text-dimmed">
306+
Unique identifier for this data store. Used as the secret key prefix.
307+
</p>
308+
</div>
309+
310+
<div className="space-y-1.5">
311+
<label className="text-xs font-medium text-text-dimmed">
312+
Kind <span className="text-rose-400">*</span>
313+
</label>
314+
<Input name="kind" value="CLICKHOUSE" readOnly variant="medium" className="opacity-60" />
315+
</div>
316+
317+
<div className="space-y-1.5">
318+
<label className="text-xs font-medium text-text-dimmed">
319+
Organization IDs <span className="text-rose-400">*</span>
320+
</label>
321+
<Input
322+
name="organizationIds"
323+
placeholder="clxxxxx, clyyyyy, clzzzzz"
324+
variant="medium"
325+
required
326+
/>
327+
<p className="text-[11px] text-text-dimmed">Comma-separated organization IDs.</p>
328+
</div>
329+
330+
<div className="space-y-1.5">
331+
<label className="text-xs font-medium text-text-dimmed">
332+
ClickHouse connection URL <span className="text-rose-400">*</span>
333+
</label>
334+
<Input
335+
name="connectionUrl"
336+
type="password"
337+
placeholder="https://user:password@host:8443"
338+
variant="medium"
339+
required
340+
className="font-mono"
341+
/>
342+
<p className="text-[11px] text-text-dimmed">
343+
Stored encrypted in SecretStore. Never logged or displayed again.
344+
</p>
345+
</div>
346+
347+
{fetcher.data?.error && (
348+
<p className="text-xs text-rose-400">{fetcher.data.error}</p>
349+
)}
350+
351+
<DialogFooter>
352+
<Button
353+
variant="tertiary/small"
354+
type="button"
355+
onClick={() => onOpenChange(false)}
356+
disabled={isSubmitting}
357+
>
358+
Cancel
359+
</Button>
360+
<Button type="submit" variant="primary/small" disabled={isSubmitting}>
361+
{isSubmitting ? "Adding…" : "Add data store"}
362+
</Button>
363+
</DialogFooter>
364+
</fetcher.Form>
365+
</DialogContent>
366+
</Dialog>
367+
);
368+
}

apps/webapp/app/routes/admin.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export default function Page() {
4444
label: "Notifications",
4545
to: "/admin/notifications",
4646
},
47+
{
48+
label: "Data Stores",
49+
to: "/admin/data-stores",
50+
},
4751
]}
4852
layoutId={"admin"}
4953
/>

0 commit comments

Comments
 (0)