Skip to content

Commit 709abc8

Browse files
authored
Project Services Hub Page (#91)
Resolves #89 This PR implements a Services page that displays the configuration status of Email, Files, Analytics, and API keys. Features: - All service cards are clickable, linking directly to their respective pages - Email → /projects/[id]/services/email - Files → /projects/[id]/services/files - Analytics → /projects/[id]/analytics - API Keys → /projects/[id]/keys (admins only) - Configurations checked using React Query and existing server functions - File service details show whether a provider, bucket, and files exist - API key counting handled safely - only fetches for admins, shows 'Admins only' badge for non-admins - Status badges for configured/not-configured - Added Services, Analytics, and API Keys to top-bar page titles
1 parent e63f509 commit 709abc8

2 files changed

Lines changed: 239 additions & 1 deletion

File tree

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,240 @@
1+
"use client";
2+
3+
import { Badge } from "@/components/ui/badge";
4+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5+
import {
6+
getAnalyticsConfig,
7+
getEmailConfig,
8+
getFileConfig,
9+
} from "@/lib/settings";
10+
import { getAllFileProviders } from "@/lib/fileProvider";
11+
import { useQuery } from "@tanstack/react-query";
12+
import { ArrowRight } from "lucide-react";
13+
import Link from "next/link";
14+
import { useParams } from "next/navigation";
15+
import { getApiKeysAction } from "@/lib/actions";
16+
import {
17+
UserType,
18+
useUserSession,
19+
} from "@/components/providers/SessionProvider";
20+
121
const ServicesPage = () => {
2-
return <></>;
22+
const { projectId } = useParams<{ projectId: string }>();
23+
const { user } = useUserSession();
24+
const isAdmin = user && user.type !== UserType.USER;
25+
26+
const { data: emailConfig, isLoading: emailLoading } = useQuery({
27+
queryKey: ["emailConfig", projectId],
28+
queryFn: () => getEmailConfig(projectId),
29+
enabled: !!projectId,
30+
staleTime: 0,
31+
refetchOnMount: "always",
32+
});
33+
34+
const { data: fileConfig, isLoading: fileConfigLoading } = useQuery({
35+
queryKey: ["fileConfig", projectId],
36+
queryFn: () => getFileConfig(projectId),
37+
enabled: !!projectId,
38+
staleTime: 0,
39+
refetchOnMount: "always",
40+
});
41+
42+
const { data: analyticsConfig, isLoading: analyticsLoading } = useQuery({
43+
queryKey: ["analyticsConfig", projectId],
44+
queryFn: () => getAnalyticsConfig(projectId),
45+
enabled: !!projectId,
46+
staleTime: 0,
47+
refetchOnMount: "always",
48+
});
49+
50+
const { data: providers, isLoading: providersLoading } = useQuery({
51+
queryKey: ["fileProvider", projectId],
52+
queryFn: () => getAllFileProviders(projectId),
53+
enabled: !!projectId && !!fileConfig,
54+
staleTime: 0,
55+
refetchOnMount: "always",
56+
});
57+
58+
const { data: apiKeyData, isLoading: apiKeysLoading } = useQuery({
59+
queryKey: ["apiKeyCount", projectId],
60+
queryFn: async () => {
61+
const limit = 100;
62+
let offset = 0;
63+
let count = 0;
64+
65+
while (true) {
66+
const result = await getApiKeysAction({
67+
offset,
68+
limit,
69+
});
70+
71+
if (!result.success) {
72+
return { count: null, error: result.error };
73+
}
74+
75+
const keys = result.keys ?? [];
76+
77+
count += keys.filter(
78+
(key) => String(key.project) === String(projectId),
79+
).length;
80+
81+
if (keys.length < limit) {
82+
break;
83+
}
84+
85+
offset += limit;
86+
}
87+
88+
return { count, error: null };
89+
},
90+
enabled: !!projectId && isAdmin,
91+
staleTime: 0,
92+
refetchOnMount: "always",
93+
});
94+
95+
const apiKeyCount = apiKeyData?.count ?? null;
96+
const apiKeyAccessDenied = !isAdmin || apiKeyData?.error != null;
97+
98+
const isEmailConfigured = emailConfig !== null;
99+
const isFileConfigured = fileConfig !== null;
100+
const isAnalyticsConfigured = analyticsConfig !== null;
101+
102+
const hasProvider = (providers?.length ?? 0) > 0;
103+
const hasBucket = (fileConfig?.buckets?.length ?? 0) > 0;
104+
const hasFiles = (fileConfig?.files?.length ?? 0) > 0;
105+
106+
const services = [
107+
{
108+
name: "Email",
109+
configured: isEmailConfigured,
110+
loading: emailLoading,
111+
description:
112+
"Configure email to send messages and notifications from your project.",
113+
href: `/projects/${projectId}/services/email`,
114+
},
115+
{
116+
name: "Files",
117+
configured: isFileConfigured,
118+
loading: fileConfigLoading,
119+
description:
120+
"Configure file storage and manage buckets and uploaded files.",
121+
href: `/projects/${projectId}/services/files`,
122+
},
123+
{
124+
name: "Analytics",
125+
configured: isAnalyticsConfigured,
126+
loading: analyticsLoading,
127+
description:
128+
"Configure analytics to track user interactions, visits, clicks, and custom events.",
129+
href: `/projects/${projectId}/analytics`,
130+
},
131+
{
132+
name: "API Keys",
133+
configured: null,
134+
loading: false,
135+
description:
136+
"Create and manage API keys used to authenticate your project.",
137+
href: `/projects/${projectId}/keys`,
138+
adminOnly: true,
139+
},
140+
];
141+
142+
return (
143+
<div>
144+
<h1 className="mb-4 text-lg font-bold">Services</h1>
145+
<div className="grid gap-4 lg:grid-cols-2">
146+
{services.map((service) => (
147+
<Link key={service.name} href={service.href} className="block">
148+
<Card className="flex flex-col h-full hover:border-primary/50 transition-colors cursor-pointer">
149+
<CardHeader>
150+
<div className="flex items-center justify-between gap-3">
151+
<CardTitle>{service.name}</CardTitle>
152+
{service.adminOnly ? (
153+
isAdmin ? null : (
154+
<Badge variant="outline">Admins only</Badge>
155+
)
156+
) : (
157+
<Badge
158+
variant={
159+
service.loading
160+
? "secondary"
161+
: service.configured
162+
? "default"
163+
: "secondary"
164+
}
165+
>
166+
{service.loading
167+
? "Checking..."
168+
: service.configured
169+
? "Configured"
170+
: "Not configured"}
171+
</Badge>
172+
)}
173+
</div>
174+
</CardHeader>
175+
<CardContent className="flex flex-1 flex-col">
176+
<p className="mb-4 text-sm text-muted-foreground">
177+
{service.description}
178+
</p>
179+
{service.name === "API Keys" && isAdmin && (
180+
<div className="mb-4 text-sm text-muted-foreground">
181+
Number of API Keys:{" "}
182+
<span className="text-foreground">
183+
{apiKeysLoading
184+
? "Checking..."
185+
: apiKeyAccessDenied
186+
? "—"
187+
: `${apiKeyCount ?? 0}`}
188+
</span>
189+
</div>
190+
)}
191+
{service.name === "Files" &&
192+
isFileConfigured &&
193+
!fileConfigLoading ? (
194+
<div className="mb-4 space-y-1 text-sm text-muted-foreground">
195+
<div>
196+
Provider:{" "}
197+
<span className="text-foreground">
198+
{providersLoading
199+
? "Checking..."
200+
: hasProvider
201+
? "Configured"
202+
: "Not configured"}
203+
</span>
204+
</div>
205+
<div>
206+
Buckets:{" "}
207+
<span className="text-foreground">
208+
{fileConfigLoading
209+
? "Checking..."
210+
: hasBucket
211+
? "Available"
212+
: "None"}
213+
</span>
214+
</div>
215+
<div>
216+
Files:{" "}
217+
<span className="text-foreground">
218+
{fileConfigLoading
219+
? "Checking..."
220+
: hasFiles
221+
? "Available"
222+
: "None"}
223+
</span>
224+
</div>
225+
</div>
226+
) : null}
227+
<div className="mt-auto flex items-center text-sm text-primary">
228+
View {service.name.toLowerCase()}
229+
<ArrowRight className="ml-2 h-4 w-4" />
230+
</div>
231+
</CardContent>
232+
</Card>
233+
</Link>
234+
))}
235+
</div>
236+
</div>
237+
);
3238
};
4239

5240
export default ServicesPage;

‎src/components/top-bar.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ function getPageTitle(pathname: string): string {
1919
const sub = projectMatch[1];
2020
if (!sub || sub === "/") return "Dashboard";
2121
if (sub === "/users") return "Users";
22+
if (sub === "/services") return "Services";
2223
if (sub === "/services/email") return "Email";
2324
if (sub === "/services/files") return "Files";
25+
if (sub === "/analytics") return "Analytics";
26+
if (sub === "/keys") return "API Keys";
2427
if (sub === "/settings") return "Settings";
2528
if (sub === "/analytics") return "Analytics";
2629
}

0 commit comments

Comments
 (0)