feat(dashboard): add login screen, auth provider, and server-client example

This commit is contained in:
coo1estguy
2026-09-14 23:14:11 +05:30
parent 231fb19349
commit 8d1ff5d816
28 changed files with 837 additions and 186 deletions
+8 -1
View File
@@ -1,5 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {/* config options here */};
const nextConfig: NextConfig = {
basePath: "/admin",
output: "export",
trailingSlash: true,
images: {
unoptimized: true,
},
};
export default nextConfig;
+1 -1
View File
@@ -26,7 +26,7 @@ export default function AnalyticsPage() {
const fetchMetrics = useCallback(async () => {
setIsLoading(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/system/metrics`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
+3 -3
View File
@@ -34,7 +34,7 @@ export default function DeadLetterQueuePage() {
const fetchDLQ = useCallback(async () => {
setIsLoading(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/dlq`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
@@ -60,7 +60,7 @@ export default function DeadLetterQueuePage() {
const handleReplay = async (id: string) => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/dlq/replay`, {
method: "POST",
headers: {
@@ -80,7 +80,7 @@ export default function DeadLetterQueuePage() {
const handleDelete = async (id: string) => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/dlq/${id}`, {
method: "DELETE",
headers: {
+1 -1
View File
@@ -127,4 +127,4 @@
html {
@apply font-sans;
}
}
}
+10 -7
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import Sidebar from "@/components/Sidebar";
import { AuthProvider } from "@/hooks/useAuth";
import { ProjectProvider } from "@/hooks/useProjectKey";
import "./globals.css";
@@ -16,8 +17,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Notifkit Observability",
description: "Real-time delivery observability dashboard for Notifkit",
title: "Notifkit Observability & Admin",
description: "Real-time delivery observability dashboard and admin console for Notifkit",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -27,11 +28,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased dark`}
>
<body className="h-screen flex bg-background text-foreground bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-zinc-900 via-background to-background overflow-hidden">
<ProjectProvider>
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
<Toaster theme="dark" />
</ProjectProvider>
<AuthProvider>
<ProjectProvider>
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
<Toaster theme="dark" />
</ProjectProvider>
</AuthProvider>
</body>
</html>
);
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Bell, Lock, User, Loader2, AlertCircle, ArrowRight, ShieldCheck } from "lucide-react";
import { toast } from "sonner";
export default function LoginPage() {
const { login } = useAuth();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!identifier.trim() || !password) {
setErrorMessage("Please enter both your email/user ID and password.");
return;
}
setLoading(true);
setErrorMessage(null);
const res = await login(identifier.trim(), password);
setLoading(false);
if (res.success) {
toast.success("Welcome back!", { description: "Logged in successfully." });
} else {
setErrorMessage(res.error || "Invalid email/user ID or password.");
toast.error("Authentication failed", { description: res.error });
}
};
return (
<div className="min-h-screen w-full flex items-center justify-center p-4 relative overflow-hidden bg-background">
{/* Background Decorative Gradients */}
<div className="absolute -top-40 -left-40 w-96 h-96 bg-primary/20 rounded-full blur-[128px] pointer-events-none" />
<div className="absolute -bottom-40 -right-40 w-96 h-96 bg-primary/10 rounded-full blur-[128px] pointer-events-none" />
<div className="absolute inset-0 bg-[radial-gradient(#27272a_1px,transparent_1px)] [background-size:24px_24px] opacity-25 pointer-events-none" />
<div className="w-full max-w-md relative z-10">
<div className="flex flex-col items-center mb-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-tr from-primary/30 to-primary/10 border border-primary/20 shadow-lg shadow-primary/10 mb-4">
<Bell className="h-7 w-7 text-primary animate-pulse" />
</div>
<h1 className="text-2xl font-extrabold tracking-tight text-foreground">
Notifkit <span className="text-primary font-normal">Admin</span>
</h1>
<p className="text-sm text-muted-foreground mt-1">
Sign in to access your notification engine and observability suite.
</p>
</div>
<Card className="border-border/60 bg-card/60 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1 pb-4">
<CardTitle className="text-lg font-semibold tracking-tight">Admin Login</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Enter your admin credentials below
</CardDescription>
</CardHeader>
<CardContent>
{errorMessage && (
<div className="mb-4 flex items-center gap-2 p-3 text-xs rounded-lg bg-destructive/15 text-destructive border border-destructive/30 animate-shake">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{errorMessage}</span>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit(e);
}}
className="space-y-4"
>
<div className="space-y-1.5">
<label className="text-xs font-medium text-foreground/80 flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-muted-foreground" />
Email or Username
</label>
<div className="relative">
<input
type="text"
required
autoFocus
placeholder="admin@example.com"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
className="w-full rounded-lg border border-border/60 bg-muted/30 px-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-foreground/80 flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
Password
</label>
<div className="relative">
<input
type="password"
required
placeholder="••••••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-border/60 bg-muted/30 px-3.5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
/>
</div>
</div>
<Button
type="submit"
disabled={loading}
className="w-full mt-2 h-10 font-medium text-sm flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-primary/25"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Signing in...</span>
</>
) : (
<>
<span>Sign In</span>
<ArrowRight className="h-4 w-4" />
</>
)}
</Button>
</form>
<div className="mt-6 pt-4 border-t border-border/40 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
<span>Protected by salted Scrypt & Redis session auth</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+2 -3
View File
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { FolderKey, Plus, Copy, Trash2, AlertCircle, Eye, EyeOff } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
interface Project {
id: string;
@@ -21,6 +22,7 @@ interface ApiKey {
}
export default function ProjectsPage() {
const { apiUrl, token: adminKey } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [newProjectName, setNewProjectName] = useState("");
@@ -30,9 +32,6 @@ export default function ProjectsPage() {
const [newlyGeneratedKey, setNewlyGeneratedKey] = useState<string | null>(null);
const [showKey, setShowKey] = useState(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const adminKey = process.env.NEXT_PUBLIC_ADMIN_KEY || "nk_live_1234567890abcdef1234567890abcdef";
const fetchProjects = async () => {
try {
const res = await fetch(`${apiUrl}/v1/projects`, {
+2 -2
View File
@@ -31,7 +31,7 @@ export default function ScheduledMessagesPage() {
const fetchScheduled = useCallback(async () => {
setIsLoading(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/notifications/scheduled`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
@@ -57,7 +57,7 @@ export default function ScheduledMessagesPage() {
const handleCancel = async (taskId: string) => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/notifications/${taskId}`, {
method: "DELETE",
headers: {
+1 -1
View File
@@ -42,7 +42,7 @@ export default function SystemHealthPage() {
const fetchHealth = useCallback(async () => {
setIsLoading(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/system/health`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
+2 -2
View File
@@ -36,7 +36,7 @@ export default function TemplatesPage() {
const fetchTemplates = useCallback(async () => {
setIsLoading(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/templates`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
@@ -62,7 +62,7 @@ export default function TemplatesPage() {
const handleDelete = async (id: string) => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/templates/${id}`, {
method: "DELETE",
headers: {
+2 -2
View File
@@ -41,7 +41,7 @@ export default function UsersPage() {
const fetchUsers = useCallback(async (apiKey: string, projectId: string) => {
setLoading(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/users`, {
headers: {
Authorization: `Bearer ${apiKey}`,
@@ -75,7 +75,7 @@ export default function UsersPage() {
if (!id) return;
setLoadingDetail(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/users/${encodeURIComponent(id)}/details`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
+3 -3
View File
@@ -50,7 +50,7 @@ export default function WorkflowsPage() {
const fetchWorkflows = useCallback(async () => {
setIsLoading(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/workflows`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
@@ -76,7 +76,7 @@ export default function WorkflowsPage() {
const handleTrigger = async (name: string) => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/workflows/trigger`, {
method: "POST",
headers: {
@@ -103,7 +103,7 @@ export default function WorkflowsPage() {
if (!targetId) return;
setSearchingInstance(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const res = await fetch(`${apiUrl}/v1/workflows/instances/${encodeURIComponent(targetId)}`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
+27 -16
View File
@@ -4,16 +4,17 @@ import { useEffect, useState } from "react";
import { Activity, Bell, FolderKey, AlertTriangle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useProject } from "@/hooks/useProjectKey";
import { useAuth } from "@/hooks/useAuth";
export default function DashboardHeader({ isConnected }: { isConnected: boolean }) {
const { projects, selectedProjectId, setSelectedProjectId, projectApiKey } = useProject();
const { apiUrl } = useAuth();
const [highBackpressure, setHighBackpressure] = useState(false);
useEffect(() => {
if (!projectApiKey || !selectedProjectId) return;
const checkMetrics = async () => {
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const res = await fetch(`${apiUrl}/v1/system/metrics`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
@@ -34,7 +35,7 @@ export default function DashboardHeader({ isConnected }: { isConnected: boolean
void checkMetrics();
const interval = setInterval(() => void checkMetrics(), 10000);
return () => clearInterval(interval);
}, [projectApiKey, selectedProjectId]);
}, [apiUrl, projectApiKey, selectedProjectId]);
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -74,20 +75,30 @@ export default function DashboardHeader({ isConnected }: { isConnected: boolean
</div>
)}
<Badge
variant={isConnected ? "default" : "destructive"}
className="flex items-center gap-1.5 px-3 py-1 shadow-sm transition-all"
>
{isConnected ? (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
) : (
<Activity className="h-3.5 w-3.5" />
)}
{isConnected ? "Live Stream Active" : "Disconnected"}
</Badge>
{projects.length === 0 ? (
<Badge
variant="outline"
className="flex items-center gap-1.5 px-3 py-1 shadow-sm text-muted-foreground border-dashed border-border"
>
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
No Project Created
</Badge>
) : (
<Badge
variant={isConnected ? "default" : "destructive"}
className="flex items-center gap-1.5 px-3 py-1 shadow-sm transition-all"
>
{isConnected ? (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
) : (
<Activity className="h-3.5 w-3.5" />
)}
{isConnected ? "Live Stream Active" : "Disconnected"}
</Badge>
)}
</div>
</div>
</header>
+2 -2
View File
@@ -60,7 +60,7 @@ export default function LogsTable() {
async (apiKey: string, projectId: string, cursor?: string | null) => {
setIsLoading(true);
try {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const query = new URLSearchParams({ limit: "25" });
if (cursor) query.set("cursor", cursor);
if (search) query.set("search", search);
@@ -104,7 +104,7 @@ export default function LogsTable() {
useEffect(() => {
if (selectedLog && projectApiKey && selectedProjectId) {
setLoadingLifecycle(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
fetch(`${apiUrl}/v1/notifications/${selectedLog.taskId}`, {
headers: {
Authorization: `Bearer ${projectApiKey}`,
+2 -2
View File
@@ -30,9 +30,9 @@ export default function RealTimeFeed({
if (!projectApiKey || !selectedProjectId) return;
setIsConnected(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const es = new EventSource(
`${apiUrl}/v1/events/stream?token=${projectApiKey}&projectId=${selectedProjectId}`,
`${apiUrl}/v1/events/stream?token=${encodeURIComponent(projectApiKey)}&projectId=${encodeURIComponent(selectedProjectId)}`,
);
es.onopen = () => {
+44 -6
View File
@@ -14,8 +14,11 @@ import {
Clock,
GitFork,
FileCode2,
LogOut,
UserCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/useAuth";
const navigation = [
{ name: "Live Feed", href: "/", icon: Activity },
@@ -32,6 +35,16 @@ const navigation = [
export default function Sidebar() {
const pathname = usePathname();
const { user, logout, isAuthenticated } = useAuth();
const isLoginPage =
pathname === "/login" ||
pathname === "/login/" ||
pathname?.endsWith("/login") ||
pathname?.endsWith("/login/");
if (isLoginPage || !isAuthenticated) {
return null;
}
return (
<div className="flex flex-col w-64 border-r border-border bg-card/30 backdrop-blur-md h-full shrink-0">
@@ -39,12 +52,17 @@ export default function Sidebar() {
<div className="bg-primary/20 p-2 rounded-lg">
<LayoutDashboard className="w-6 h-6 text-primary" />
</div>
<h1 className="font-bold text-lg tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent">
Notifkit
</h1>
<div>
<h1 className="font-bold text-lg tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent">
Notifkit
</h1>
<span className="text-[10px] uppercase font-semibold tracking-wider text-muted-foreground">
Admin Console
</span>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
<nav className="flex-1 overflow-y-auto p-4 space-y-1.5">
{navigation.map((item) => {
const isActive = pathname === item.href;
return (
@@ -73,8 +91,28 @@ export default function Sidebar() {
})}
</nav>
<div className="p-4 border-t border-border/50">
<div className="text-xs text-muted-foreground text-center">Notifkit Observability v1.0</div>
{/* Admin User info and logout */}
<div className="p-4 border-t border-border/50 bg-muted/20 space-y-3">
{user && (
<div className="flex items-center gap-2.5 px-2 py-1.5">
<div className="h-7 w-7 rounded-full bg-primary/20 flex items-center justify-center shrink-0 border border-primary/30">
<UserCheck className="h-3.5 w-3.5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-foreground truncate">
{user.username || user.email}
</p>
<p className="text-[10px] text-muted-foreground capitalize">{user.role}</p>
</div>
</div>
)}
<button
onClick={() => void logout()}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-muted-foreground hover:text-destructive hover:bg-destructive/10 border border-transparent hover:border-destructive/20 transition-all cursor-pointer"
>
<LogOut className="h-3.5 w-3.5" />
<span>Sign Out</span>
</button>
</div>
</div>
);
+190
View File
@@ -0,0 +1,190 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext, useState, useEffect, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
export interface AdminUser {
id: string;
email: string;
username: string | null;
role: string;
}
interface AuthContextType {
user: AdminUser | null;
token: string | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (identifier: string, password: string) => Promise<{ success: boolean; error?: string }>;
logout: () => Promise<void>;
apiUrl: string;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AdminUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
const pathname = usePathname();
// If NEXT_PUBLIC_API_URL is configured, use it. Otherwise, use empty string for same-origin calls.
const apiUrl =
process.env.NEXT_PUBLIC_API_URL !== undefined && process.env.NEXT_PUBLIC_API_URL !== ""
? process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, "")
: "";
const logout = useCallback(async () => {
const activeToken =
token ||
(typeof window !== "undefined" ? localStorage.getItem("notifkit_admin_token") : null);
if (activeToken) {
try {
await fetch(`${apiUrl}/v1/auth/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${activeToken}` },
});
} catch {
// Ignore network errors during logout
}
}
if (typeof window !== "undefined") {
localStorage.removeItem("notifkit_admin_token");
localStorage.removeItem("notifkit_admin_user");
}
setUser(null);
setToken(null);
router.push("/login/");
}, [apiUrl, token, router]);
const verifySession = useCallback(
async (existingToken: string) => {
try {
const res = await fetch(`${apiUrl}/v1/auth/me`, {
headers: { Authorization: `Bearer ${existingToken}` },
});
if (res.ok) {
const data = await res.json();
setUser(data.user);
setToken(existingToken);
if (typeof window !== "undefined") {
localStorage.setItem("notifkit_admin_user", JSON.stringify(data.user));
}
return true;
} else {
if (typeof window !== "undefined") {
localStorage.removeItem("notifkit_admin_token");
localStorage.removeItem("notifkit_admin_user");
}
setUser(null);
setToken(null);
return false;
}
} catch {
// In offline/network error, fallback to cached user if available
const cached =
typeof window !== "undefined" ? localStorage.getItem("notifkit_admin_user") : null;
if (cached) {
try {
setUser(JSON.parse(cached));
setToken(existingToken);
return true;
} catch {
// ignore parse error
}
}
return false;
}
},
[apiUrl],
);
useEffect(() => {
const initAuth = async () => {
const storedToken =
typeof window !== "undefined" ? localStorage.getItem("notifkit_admin_token") : null;
if (storedToken) {
await verifySession(storedToken);
}
setIsLoading(false);
};
void initAuth();
}, [verifySession]);
const login = async (
identifier: string,
password: string,
): Promise<{ success: boolean; error?: string }> => {
try {
const res = await fetch(`${apiUrl}/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier, password }),
});
const data = await res.json();
if (!res.ok) {
return { success: false, error: data.message || "Invalid credentials" };
}
setToken(data.token);
setUser(data.user);
if (typeof window !== "undefined") {
localStorage.setItem("notifkit_admin_token", data.token);
localStorage.setItem("notifkit_admin_user", JSON.stringify(data.user));
}
router.push("/");
return { success: true };
} catch (err: unknown) {
return {
success: false,
error: err instanceof Error ? err.message : "Failed to connect to server",
};
}
};
const isLoginPage =
pathname === "/login" ||
pathname === "/login/" ||
pathname?.endsWith("/login") ||
pathname?.endsWith("/login/");
// Route protection
useEffect(() => {
if (!isLoading) {
if (!token && !isLoginPage) {
router.push("/login/");
} else if (token && isLoginPage) {
router.push("/");
}
}
}, [isLoading, token, isLoginPage, router]);
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
isAuthenticated: !!token,
login,
logout,
apiUrl,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
+40 -21
View File
@@ -1,7 +1,8 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext, useState, useEffect } from "react";
import { createContext, useContext, useState, useEffect, useCallback } from "react";
import { useAuth } from "./useAuth";
interface ProjectContextType {
projects: any[];
@@ -9,41 +10,58 @@ interface ProjectContextType {
setSelectedProjectId: (id: string) => void;
projectApiKey: string | null;
isLoadingProjects: boolean;
refreshProjects: () => Promise<void>;
}
const ProjectContext = createContext<ProjectContextType | undefined>(undefined);
export function ProjectProvider({ children }: { children: ReactNode }) {
const { token, apiUrl, isAuthenticated } = useAuth();
const [projects, setProjects] = useState<any[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
const [projectApiKey, setProjectApiKey] = useState<string | null>(null);
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";
const adminKey = process.env.NEXT_PUBLIC_ADMIN_KEY || "test_admin_key";
const fetchProjects = useCallback(async () => {
if (!token) {
setProjects([]);
setSelectedProjectId("");
setIsLoadingProjects(false);
return;
}
useEffect(() => {
fetch(`${apiUrl}/v1/projects`, { headers: { Authorization: `Bearer ${adminKey}` } })
.then((res) => res.json())
.then((data) => {
try {
setIsLoadingProjects(true);
const res = await fetch(`${apiUrl}/v1/projects`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
const projs = data.projects || [];
setProjects(projs);
if (projs.length > 0) {
setSelectedProjectId(projs[0].id);
setSelectedProjectId((prev) =>
projs.some((p: any) => p.id === prev) ? prev : projs[0].id,
);
} else {
setSelectedProjectId("");
}
setIsLoadingProjects(false);
})
.catch((err) => {
console.error(err);
setIsLoadingProjects(false);
});
}, [apiUrl, adminKey]);
}
} catch (err) {
console.error("Failed to load projects:", err);
} finally {
setIsLoadingProjects(false);
}
}, [apiUrl, token]);
useEffect(() => {
// With Admin token, we no longer need the project's API key.
// We just use the Admin token and pass x-project-id.
setProjectApiKey(adminKey);
}, [adminKey]);
if (isAuthenticated && token) {
void fetchProjects();
} else {
setProjects([]);
setSelectedProjectId("");
setIsLoadingProjects(false);
}
}, [isAuthenticated, token, fetchProjects]);
return (
<ProjectContext.Provider
@@ -51,8 +69,9 @@ export function ProjectProvider({ children }: { children: ReactNode }) {
projects,
selectedProjectId,
setSelectedProjectId,
projectApiKey,
projectApiKey: token,
isLoadingProjects,
refreshProjects: fetchProjects,
}}
>
{children}
+39 -38
View File
@@ -10,77 +10,78 @@
* $ ADMIN_API_KEY=my_super_secret_admin_key npm start
*/
const API_BASE = process.env.NOTIFKIT_API_URL || 'http://localhost:3000/v1';
const API_KEY = process.env.ADMIN_API_KEY || 'my_super_secret_admin_key';
const API_BASE = process.env.NOTIFKIT_API_URL || "http://localhost:3000/v1";
const API_KEY = process.env.ADMIN_API_KEY || "my_super_secret_admin_key";
// Every /v1 route is authenticated. Only /health, /live, /ready and /metrics are not.
const headers = {
'Content-Type': 'application/json',
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
};
async function main() {
console.log('🚀 Starting Notifkit example...\n');
console.log("🚀 Starting Notifkit example...\n");
try {
// 1. Sync Templates (Configure how notifications should look)
console.log('1️⃣ Syncing email template...');
console.log("1️⃣ Syncing email template...");
const templateRes = await fetch(`${API_BASE}/templates`, {
method: 'PUT',
method: "PUT",
headers,
body: JSON.stringify({
templates: [{
id: 'welcome_email',
channel: 'email',
// The per-channel fields live under `content`.
content: {
subject: 'Welcome to Notifkit, {{name}}!',
html: '<h1>Hello {{name}}</h1><p>Thanks for signing up!</p>',
text: 'Hello {{name}}! Thanks for signing up!'
}
}]
})
templates: [
{
id: "welcome_email",
channel: "email",
// The per-channel fields live under `content`.
content: {
subject: "Welcome to Notifkit, {{name}}!",
html: "<h1>Hello {{name}}</h1><p>Thanks for signing up!</p>",
text: "Hello {{name}}! Thanks for signing up!",
},
},
],
}),
});
console.log('Template Response:', await templateRes.json());
console.log("Template Response:", await templateRes.json());
console.log();
// 2. Create a User (Register a recipient)
console.log('2️⃣ Registering a user...');
console.log("2️⃣ Registering a user...");
const userRes = await fetch(`${API_BASE}/users`, {
method: 'POST',
method: "POST",
headers,
body: JSON.stringify({
id: 'user_123',
email: 'test@example.com',
id: "user_123",
email: "test@example.com",
preferences: {
channels: { email: true }
}
})
channels: { email: true },
},
}),
});
console.log('User Response:', await userRes.json());
console.log("User Response:", await userRes.json());
console.log();
// 3. Send a Notification
console.log('3️⃣ Sending notification...');
console.log("3️⃣ Sending notification...");
const notifyRes = await fetch(`${API_BASE}/notify`, {
method: 'POST',
method: "POST",
headers,
body: JSON.stringify({
user: 'user_123',
template: 'welcome_email',
channels: ['email'],
user: "user_123",
template: "welcome_email",
channels: ["email"],
data: {
name: 'Alice'
}
})
name: "Alice",
},
}),
});
// 202 Accepted — queued, not delivered. Track it with
// GET /v1/notifications/{taskId}.
console.log('Notify Response:', await notifyRes.json());
console.log('\n✅ Example completed successfully!');
console.log("Notify Response:", await notifyRes.json());
console.log("\n✅ Example completed successfully!");
} catch (err) {
console.error('❌ Error executing example:', err.message);
console.error("❌ Error executing example:", err.message);
}
}
+12 -14
View File
@@ -1,16 +1,16 @@
import { NotifkitServer } from 'notifkit';
import { NotifkitServer } from "notifkit";
/**
* Example Custom Email Transport Provider
*/
class CustomEmailTransport {
constructor() {
this.channel = 'email';
this.channel = "email";
}
async send(task) {
console.log(`[CustomEmailTransport] Sending notification to ${task.recipient.email}...`);
// Simulate async network request to email gateway
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -22,36 +22,34 @@ class CustomEmailTransport {
}
async function main() {
console.log('🚀 Starting custom Notifkit Server example...');
console.log("🚀 Starting custom Notifkit Server example...");
const server = new NotifkitServer({
services: ['all'],
services: ["all"],
port: Number(process.env.PORT || 4000),
logLevel: process.env.LOG_LEVEL || 'info',
logLevel: process.env.LOG_LEVEL || "info",
redisUrl: process.env.REDIS_URL,
databaseUrl: process.env.DATABASE_URL,
workerConcurrency: 50,
providers: [
new CustomEmailTransport(),
],
providers: [new CustomEmailTransport()],
});
try {
await server.start();
console.log('✅ Notifkit Server started successfully on port 4000.');
console.log("✅ Notifkit Server started successfully on port 4000.");
// Graceful shutdown handling
const shutdown = async (signal) => {
console.log(`\n🛑 Received ${signal}, stopping Notifkit Server...`);
await server.stop();
console.log('👋 Server stopped cleanly.');
console.log("👋 Server stopped cleanly.");
process.exit(0);
};
process.once('SIGINT', () => shutdown('SIGINT'));
process.once('SIGTERM', () => shutdown('SIGTERM'));
process.once("SIGINT", () => shutdown("SIGINT"));
process.once("SIGTERM", () => shutdown("SIGTERM"));
} catch (error) {
console.error('❌ Failed to start Notifkit Server:', error);
console.error("❌ Failed to start Notifkit Server:", error);
process.exit(1);
}
}
+9 -7
View File
@@ -1,25 +1,27 @@
import { NotifkitClient } from 'notifkit';
import { NotifkitClient } from "notifkit";
/**
* Load testing example for measuring Notifkit pipeline throughput.
*/
async function runLoadTest() {
const baseUrl = process.env.NOTIFKIT_API_URL || 'http://localhost:4000';
const apiKey = process.env.ADMIN_API_KEY || 'your_admin_api_key';
const baseUrl = process.env.NOTIFKIT_API_URL || "http://localhost:4000";
const apiKey = process.env.ADMIN_API_KEY || "your_admin_api_key";
const totalNotifications = Number(process.env.COUNT || 500);
const concurrency = Number(process.env.CONCURRENCY || 50);
const client = new NotifkitClient({ baseUrl, apiKey });
console.log(`⚡ Starting load test: ${totalNotifications} notifications (Concurrency: ${concurrency})...`);
console.log(
`⚡ Starting load test: ${totalNotifications} notifications (Concurrency: ${concurrency})...`,
);
const startTime = Date.now();
let completed = 0;
let failed = 0;
const queue = Array.from({ length: totalNotifications }, (_, i) => ({
template: 'benchmark-template',
channels: ['email'],
template: "benchmark-template",
channels: ["email"],
user: {
id: `bench-user-${i % 100}`,
email: `bench-user-${i % 100}@example.com`,
@@ -56,6 +58,6 @@ async function runLoadTest() {
}
runLoadTest().catch((err) => {
console.error('❌ Error executing load test:', err);
console.error("❌ Error executing load test:", err);
process.exit(1);
});
+18 -18
View File
@@ -1,24 +1,24 @@
import { NotifkitClient } from 'notifkit';
import { NotifkitClient } from "notifkit";
/**
* Example demonstrating scheduled and quiet-hours aware notification delivery.
*/
async function runScheduledNotificationsExample() {
const client = new NotifkitClient({
baseUrl: process.env.NOTIFKIT_API_URL || 'http://localhost:4000',
apiKey: process.env.ADMIN_API_KEY || 'your_admin_api_key',
baseUrl: process.env.NOTIFKIT_API_URL || "http://localhost:4000",
apiKey: process.env.ADMIN_API_KEY || "your_admin_api_key",
});
console.log('🔄 Syncing template...');
console.log("🔄 Syncing template...");
await client.syncTemplates({
templates: [
{
id: 'scheduled-reminder',
channel: 'email',
id: "scheduled-reminder",
channel: "email",
content: {
subject: 'Scheduled Meeting Reminder',
html: '<h3>Meeting Reminder</h3><p>Hi {{name}}, your meeting starts in 15 minutes.</p>',
text: 'Hi {{name}}, your meeting starts in 15 minutes.',
subject: "Scheduled Meeting Reminder",
html: "<h3>Meeting Reminder</h3><p>Hi {{name}}, your meeting starts in 15 minutes.</p>",
text: "Hi {{name}}, your meeting starts in 15 minutes.",
},
},
],
@@ -29,25 +29,25 @@ async function runScheduledNotificationsExample() {
console.log(`⏰ Dispatching scheduled notification for ${sendAt}...`);
const response = await client.notify({
template: 'scheduled-reminder',
channels: ['email'],
template: "scheduled-reminder",
channels: ["email"],
user: {
id: 'user-2002',
email: 'alex@example.com',
timezone: 'America/New_York',
id: "user-2002",
email: "alex@example.com",
timezone: "America/New_York",
// Quiet hours live under `preferences`, and are a list of UTC windows.
preferences: {
quietHours: [{ start: '22:00', end: '08:00' }],
quietHours: [{ start: "22:00", end: "08:00" }],
},
},
data: { name: 'Alex' },
data: { name: "Alex" },
sendAt,
});
console.log('✅ Scheduled notification response:', response);
console.log("✅ Scheduled notification response:", response);
}
runScheduledNotificationsExample().catch((err) => {
console.error('❌ Error executing scheduled notification example:', err);
console.error("❌ Error executing scheduled notification example:", err);
process.exit(1);
});
+50
View File
@@ -0,0 +1,50 @@
# Notifkit Server & Client Example
This example demonstrates how to:
1. Boot the full Notifkit Server with API and Admin Dashboard (`server.js`).
2. Interact with the running server using the typed `NotifkitClient` SDK (`client.js`).
3. Log into the built Admin Dashboard at `http://localhost:3000/admin`.
---
## 1. Start the Server
Before running the server, ensure your project bundles are built:
```bash
# From repository root:
npm run build
npm run build:dashboard
```
Run the server script:
```bash
node examples/server-client/server.js
```
The server will start at `http://localhost:3000` with the following default credentials:
- **Admin Dashboard**: [http://localhost:3000/admin](http://localhost:3000/admin)
- **Email**: `admin@example.com`
- **Password**: `admin123`
---
## 2. Run the Client SDK
In another terminal, execute the client script:
```bash
node examples/server-client/client.js
```
The client script will:
- Sync notification templates (`welcome_notification` and `security_alert`).
- Upsert recipient user `usr_alex_99` with email and SMS contact channels.
- Dispatch a notification.
- Check the task delivery status.
You can view the real-time events and history logs immediately in your dashboard at `http://localhost:3000/admin`!
+108
View File
@@ -0,0 +1,108 @@
/**
* Notifkit Client SDK Example
*
* Demonstrates using the typed `NotifkitClient` to:
* 1. Sync templates
* 2. Register users & recipient contacts
* 3. Dispatch multi-channel notifications
*
* To run:
* node client.js
*/
import { NotifkitClient } from "../../dist/index.mjs";
const BASE_URL = process.env.NOTIFKIT_URL || "http://localhost:3000";
// Project API Key or Admin API Key
const API_KEY =
process.env.NOTIFKIT_API_KEY || process.env.ADMIN_API_KEY || "nk_live_admin_secret_key";
const PROJECT_ID = process.env.NOTIFKIT_PROJECT_ID;
async function main() {
console.log("🚀 Initializing Notifkit Client...\n");
const client = new NotifkitClient({
baseUrl: BASE_URL,
apiKey: API_KEY,
headers: PROJECT_ID ? { "x-project-id": PROJECT_ID } : undefined,
});
try {
// 1. Sync Notification Templates
console.log("1️⃣ Syncing notification templates...");
const syncRes = await client.syncTemplates({
templates: [
{
id: "welcome_notification",
channel: "email",
content: {
subject: "Welcome to our platform, {{name}}!",
html: "<h1>Welcome {{name}} 🎉</h1><p>We are thrilled to have you on board!</p>",
text: "Welcome {{name}}! We are thrilled to have you on board!",
},
},
{
id: "security_alert",
channel: "sms",
content: {
text: "Security Alert: New login detected from {{location}} at {{time}}.",
},
},
],
});
console.log(`✅ Synced ${syncRes.synced} templates.\n`);
// 2. Register / Upsert a User and their contact points
console.log("2️⃣ Upserting user profile and contacts...");
const userRes = await client.upsertUser({
id: "usr_alex_99",
email: "alex@example.com",
timezone: "America/New_York",
contacts: [
{
channel: "email",
target: "alex@example.com",
isPrimary: true,
enabled: true,
},
{
channel: "sms",
target: "+15551234567",
enabled: true,
},
],
preferences: {
channels: {
email: true,
sms: true,
},
},
});
console.log(`✅ User registered: ID "${userRes.id}"\n`);
// 3. Send a Notification
console.log("3️⃣ Dispatching notification...");
const notifyRes = await client.send({
user: "usr_alex_99",
template: "welcome_notification",
channels: ["email"],
data: {
name: "Alex",
},
priority: "normal",
});
console.log(`✅ Notification queued successfully! Task ID: ${notifyRes.taskId}\n`);
// 4. Poll/Check Notification Status
console.log("4️⃣ Fetching notification status...");
const status = await client.getNotificationStatus(notifyRes.taskId);
console.log("Status details:", status);
console.log("\n🎉 All client actions completed successfully!");
} catch (error) {
console.error("❌ Client error:", error.message);
}
}
void main();
+10
View File
@@ -0,0 +1,10 @@
{
"name": "notifkit-server-client-example",
"version": "1.0.0",
"type": "module",
"description": "Full server and client SDK example for Notifkit",
"scripts": {
"start:server": "node server.js",
"start:client": "node client.js"
}
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Notifkit Server Example
*
* Starts the Notifkit orchestrator server including the API service,
* workers (delivery, engine, enricher, scheduler), and the Admin Dashboard.
*
* To run:
* node server.js
*/
import { NotifkitServer } from "../../dist/index.mjs";
// Optional: Set default admin credentials for first-time dashboard login
process.env.ADMIN_EMAIL = process.env.ADMIN_EMAIL || "admin@example.com";
process.env.ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "admin123";
process.env.ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin";
process.env.ADMIN_API_KEY = process.env.ADMIN_API_KEY || "nk_live_admin_secret_key";
async function main() {
console.log("🚀 Initializing Notifkit Server...\n");
const server = new NotifkitServer({
services: ["all"],
port: Number(process.env.PORT || 3000),
logLevel: process.env.LOG_LEVEL || "info",
redisUrl: process.env.REDIS_URL,
databaseUrl: process.env.DATABASE_URL,
workerConcurrency: 10,
autoMigrate: true,
});
// Listen to server events
server.on("delivery:delivered", (event) => {
console.log(`[Event] Notification delivered: Task ${event.taskId} -> ${event.channel}`);
});
server.on("delivery:failed", (event) => {
console.error(`[Event] Notification delivery failed: Task ${event.taskId}`, event.error);
});
try {
await server.start();
const port = process.env.PORT || 3000;
console.log("\n=======================================================");
console.log(`✅ Notifkit Server is running at http://localhost:${port}`);
console.log(`📊 Admin Dashboard available at http://localhost:${port}/admin`);
console.log(`🔑 Default Admin Credentials:`);
console.log(` Email: ${process.env.ADMIN_EMAIL}`);
console.log(` Password: ${process.env.ADMIN_PASSWORD}`);
console.log("=======================================================\n");
} catch (error) {
console.error("❌ Failed to start Notifkit Server:", error);
process.exit(1);
}
}
void main();
+34 -34
View File
@@ -1,63 +1,63 @@
import { NotifkitClient } from 'notifkit';
import { NotifkitClient } from "notifkit";
/**
* Example demonstrating multi-step notification workflows (Notify -> Wait -> Notify)
*/
async function runWorkflowExample() {
const client = new NotifkitClient({
baseUrl: process.env.NOTIFKIT_API_URL || 'http://localhost:4000',
apiKey: process.env.ADMIN_API_KEY || 'your_admin_api_key',
baseUrl: process.env.NOTIFKIT_API_URL || "http://localhost:4000",
apiKey: process.env.ADMIN_API_KEY || "your_admin_api_key",
});
console.log('🔄 Syncing onboarding template...');
console.log("🔄 Syncing onboarding template...");
await client.syncTemplates({
templates: [
{
id: 'onboarding-welcome',
channel: 'email',
id: "onboarding-welcome",
channel: "email",
content: {
subject: 'Welcome to our Platform!',
html: '<h3>Hello {{name}}!</h3><p>Thank you for signing up.</p>',
text: 'Hello {{name}}! Thank you for signing up.',
subject: "Welcome to our Platform!",
html: "<h3>Hello {{name}}!</h3><p>Thank you for signing up.</p>",
text: "Hello {{name}}! Thank you for signing up.",
},
},
{
id: 'onboarding-followup',
channel: 'email',
id: "onboarding-followup",
channel: "email",
content: {
subject: 'Getting Started Tips',
html: '<h3>Tips for {{name}}</h3><p>Check out our documentation to get started.</p>',
text: 'Tips for {{name}}. Check out our documentation to get started.',
subject: "Getting Started Tips",
html: "<h3>Tips for {{name}}</h3><p>Check out our documentation to get started.</p>",
text: "Tips for {{name}}. Check out our documentation to get started.",
},
},
],
});
console.log('✅ Templates synced successfully.');
console.log("✅ Templates synced successfully.");
console.log('🔄 Registering multi-step workflow definition...');
console.log("🔄 Registering multi-step workflow definition...");
// No step names a recipient: each one inherits the user the instance was
// triggered with, so the same definition serves every subscriber.
const workflow = await client.createWorkflow({
name: 'user-onboarding-drip',
name: "user-onboarding-drip",
steps: [
{
action: 'notify',
action: "notify",
payload: {
template: 'onboarding-welcome',
channels: ['email'],
data: { name: 'Jane Doe' },
template: "onboarding-welcome",
channels: ["email"],
data: { name: "Jane Doe" },
},
},
{
action: 'wait',
duration: '1h', // Wait 1 hour before next step
action: "wait",
duration: "1h", // Wait 1 hour before next step
},
{
action: 'notify',
action: "notify",
payload: {
template: 'onboarding-followup',
channels: ['email'],
data: { name: 'Jane Doe' },
template: "onboarding-followup",
channels: ["email"],
data: { name: "Jane Doe" },
},
},
],
@@ -65,20 +65,20 @@ async function runWorkflowExample() {
console.log(`✅ Workflow registered: ${workflow.name}`);
console.log('🔄 Registering the recipient...');
await client.addUser({ id: 'user-1001', email: 'user@example.com' });
console.log("🔄 Registering the recipient...");
await client.addUser({ id: "user-1001", email: "user@example.com" });
console.log('🚀 Triggering workflow execution for recipient...');
console.log("🚀 Triggering workflow execution for recipient...");
// input.user.id is who every notify step in this instance sends to.
const execution = await client.triggerWorkflow({
name: 'user-onboarding-drip',
input: { user: { id: 'user-1001' } },
name: "user-onboarding-drip",
input: { user: { id: "user-1001" } },
});
console.log('🎉 Workflow triggered. Instance ID:', execution.instanceId);
console.log("🎉 Workflow triggered. Instance ID:", execution.instanceId);
}
runWorkflowExample().catch((err) => {
console.error('❌ Error executing workflow example:', err);
console.error("❌ Error executing workflow example:", err);
process.exit(1);
});
+16 -2
View File
@@ -15,7 +15,17 @@ const SUMMARY = "coverage/coverage-summary.json";
/** Shields renders whatever colour we hand it, so the thresholds live here. */
const colorFor = (pct) =>
pct >= 90 ? "brightgreen" : pct >= 80 ? "green" : pct >= 70 ? "yellowgreen" : pct >= 60 ? "yellow" : pct >= 50 ? "orange" : "red";
pct >= 90
? "brightgreen"
: pct >= 80
? "green"
: pct >= 70
? "yellowgreen"
: pct >= 60
? "yellow"
: pct >= 50
? "orange"
: "red";
const arg = (flag, fallback) => {
const i = process.argv.indexOf(flag);
@@ -50,7 +60,11 @@ const pct = Math.round(total * 10) / 10;
await mkdir(dirname(out), { recursive: true });
await writeFile(
out,
JSON.stringify({ schemaVersion: 1, label: "coverage", message: `${pct}%`, color: colorFor(pct) }, null, 2) + "\n",
JSON.stringify(
{ schemaVersion: 1, label: "coverage", message: `${pct}%`, color: colorFor(pct) },
null,
2,
) + "\n",
);
// The workflow reads this line to label the badge commit.