mirror of
https://github.com/devkitshq/notifkit.git
synced 2026-10-19 18:24:52 +02:00
feat(dashboard): add login screen, auth provider, and server-client example
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -127,4 +127,4 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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`!
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user