@sansavision/create-pulse 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -3
- package/dist/index.js +3 -1
- package/package.json +1 -1
- package/templates/nextjs-auth-demo/.env.example +6 -0
- package/templates/nextjs-auth-demo/README.md +125 -0
- package/templates/nextjs-auth-demo/_gitignore +33 -0
- package/templates/nextjs-auth-demo/drizzle.config.ts +10 -0
- package/templates/nextjs-auth-demo/eslint.config.mjs +18 -0
- package/templates/nextjs-auth-demo/next-env.d.ts +6 -0
- package/templates/nextjs-auth-demo/next.config.ts +7 -0
- package/templates/nextjs-auth-demo/package.json +36 -0
- package/templates/nextjs-auth-demo/postcss.config.mjs +7 -0
- package/templates/nextjs-auth-demo/public/file.svg +1 -0
- package/templates/nextjs-auth-demo/public/globe.svg +1 -0
- package/templates/nextjs-auth-demo/public/next.svg +1 -0
- package/templates/nextjs-auth-demo/public/vercel.svg +1 -0
- package/templates/nextjs-auth-demo/public/window.svg +1 -0
- package/templates/nextjs-auth-demo/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/nextjs-auth-demo/src/app/api/pulse/verify/route.ts +54 -0
- package/templates/nextjs-auth-demo/src/app/auth/sign-in/page.tsx +131 -0
- package/templates/nextjs-auth-demo/src/app/auth/sign-up/page.tsx +153 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/chat/page.tsx +248 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/encrypted-chat/page.tsx +198 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/game-sync/page.tsx +192 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/queues/page.tsx +297 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/demos/watch-together/page.tsx +258 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/layout.tsx +109 -0
- package/templates/nextjs-auth-demo/src/app/dashboard/page.tsx +147 -0
- package/templates/nextjs-auth-demo/src/app/favicon.ico +0 -0
- package/templates/nextjs-auth-demo/src/app/globals.css +96 -0
- package/templates/nextjs-auth-demo/src/app/layout.tsx +27 -0
- package/templates/nextjs-auth-demo/src/app/page.tsx +254 -0
- package/templates/nextjs-auth-demo/src/lib/auth-client.ts +15 -0
- package/templates/nextjs-auth-demo/src/lib/auth.ts +14 -0
- package/templates/nextjs-auth-demo/src/lib/db.ts +6 -0
- package/templates/nextjs-auth-demo/src/lib/pulse.ts +45 -0
- package/templates/nextjs-auth-demo/src/lib/schema.ts +107 -0
- package/templates/nextjs-auth-demo/tsconfig.json +34 -0
- package/templates/react-queue-demo/README.md +6 -7
- package/templates/react-queue-demo/src/App.tsx +34 -13
- package/src/index.ts +0 -115
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { signUp } from "@/lib/auth-client";
|
|
7
|
+
import { Zap, Mail, Lock, User, ArrowRight, Loader2 } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
export default function SignUpPage() {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const [name, setName] = useState("");
|
|
12
|
+
const [email, setEmail] = useState("");
|
|
13
|
+
const [password, setPassword] = useState("");
|
|
14
|
+
const [error, setError] = useState("");
|
|
15
|
+
const [loading, setLoading] = useState(false);
|
|
16
|
+
|
|
17
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
setError("");
|
|
20
|
+
setLoading(true);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await signUp.email({
|
|
24
|
+
name,
|
|
25
|
+
email,
|
|
26
|
+
password,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (result.error) {
|
|
30
|
+
setError(result.error.message || "Sign up failed");
|
|
31
|
+
} else {
|
|
32
|
+
router.push("/dashboard");
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
setError("An unexpected error occurred");
|
|
36
|
+
} finally {
|
|
37
|
+
setLoading(false);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="min-h-screen flex items-center justify-center px-4 relative overflow-hidden">
|
|
43
|
+
<div className="absolute top-1/4 left-1/3 w-80 h-80 bg-purple-500/15 rounded-full blur-[100px]" />
|
|
44
|
+
<div className="absolute bottom-1/4 right-1/3 w-80 h-80 bg-cyan-500/15 rounded-full blur-[100px]" />
|
|
45
|
+
|
|
46
|
+
<div className="w-full max-w-md relative z-10">
|
|
47
|
+
<div className="text-center mb-8">
|
|
48
|
+
<Link href="/" className="inline-flex items-center gap-2 mb-6">
|
|
49
|
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-cyan-500 flex items-center justify-center">
|
|
50
|
+
<Zap className="w-6 h-6 text-white" />
|
|
51
|
+
</div>
|
|
52
|
+
<span className="text-2xl font-bold">Pulse</span>
|
|
53
|
+
</Link>
|
|
54
|
+
<h1 className="text-2xl font-bold mb-2">Create your account</h1>
|
|
55
|
+
<p className="text-slate-400">
|
|
56
|
+
Start exploring the real-time protocol
|
|
57
|
+
</p>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<form
|
|
61
|
+
onSubmit={handleSubmit}
|
|
62
|
+
className="glass rounded-2xl p-8 space-y-5"
|
|
63
|
+
>
|
|
64
|
+
{error && (
|
|
65
|
+
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
|
66
|
+
{error}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
<div>
|
|
71
|
+
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
72
|
+
Name
|
|
73
|
+
</label>
|
|
74
|
+
<div className="relative">
|
|
75
|
+
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
76
|
+
<input
|
|
77
|
+
type="text"
|
|
78
|
+
value={name}
|
|
79
|
+
onChange={(e) => setName(e.target.value)}
|
|
80
|
+
placeholder="Your name"
|
|
81
|
+
required
|
|
82
|
+
className="w-full pl-10 pr-4 py-2.5 rounded-lg bg-slate-800/50 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none text-sm transition-colors placeholder:text-slate-600"
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div>
|
|
88
|
+
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
89
|
+
Email
|
|
90
|
+
</label>
|
|
91
|
+
<div className="relative">
|
|
92
|
+
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
93
|
+
<input
|
|
94
|
+
type="email"
|
|
95
|
+
value={email}
|
|
96
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
97
|
+
placeholder="you@example.com"
|
|
98
|
+
required
|
|
99
|
+
className="w-full pl-10 pr-4 py-2.5 rounded-lg bg-slate-800/50 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none text-sm transition-colors placeholder:text-slate-600"
|
|
100
|
+
/>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div>
|
|
105
|
+
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
|
106
|
+
Password
|
|
107
|
+
</label>
|
|
108
|
+
<div className="relative">
|
|
109
|
+
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
110
|
+
<input
|
|
111
|
+
type="password"
|
|
112
|
+
value={password}
|
|
113
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
114
|
+
placeholder="••••••••"
|
|
115
|
+
required
|
|
116
|
+
minLength={8}
|
|
117
|
+
className="w-full pl-10 pr-4 py-2.5 rounded-lg bg-slate-800/50 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none text-sm transition-colors placeholder:text-slate-600"
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
<p className="text-xs text-slate-500 mt-1">
|
|
121
|
+
Must be at least 8 characters
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<button
|
|
126
|
+
type="submit"
|
|
127
|
+
disabled={loading}
|
|
128
|
+
className="w-full py-2.5 bg-gradient-to-r from-purple-600 to-cyan-600 hover:from-purple-500 hover:to-cyan-500 rounded-lg font-semibold transition-all hover:shadow-lg hover:shadow-purple-500/25 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
129
|
+
>
|
|
130
|
+
{loading ? (
|
|
131
|
+
<Loader2 className="w-4 h-4 animate-spin" />
|
|
132
|
+
) : (
|
|
133
|
+
<>
|
|
134
|
+
Create Account
|
|
135
|
+
<ArrowRight className="w-4 h-4" />
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
<p className="text-center text-sm text-slate-400">
|
|
141
|
+
Already have an account?{" "}
|
|
142
|
+
<Link
|
|
143
|
+
href="/auth/sign-in"
|
|
144
|
+
className="text-purple-400 hover:text-purple-300 font-medium"
|
|
145
|
+
>
|
|
146
|
+
Sign in
|
|
147
|
+
</Link>
|
|
148
|
+
</p>
|
|
149
|
+
</form>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useSession } from "@/lib/auth-client";
|
|
5
|
+
import { connectWithAuth } from "@/lib/pulse";
|
|
6
|
+
import {
|
|
7
|
+
Send,
|
|
8
|
+
Users,
|
|
9
|
+
Wifi,
|
|
10
|
+
WifiOff,
|
|
11
|
+
Shield,
|
|
12
|
+
Loader2,
|
|
13
|
+
MessageSquare,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
16
|
+
|
|
17
|
+
interface ChatMessage {
|
|
18
|
+
id: string;
|
|
19
|
+
user: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
text: string;
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function ChatDemoPage() {
|
|
26
|
+
const { data: session } = useSession();
|
|
27
|
+
const [connected, setConnected] = useState(false);
|
|
28
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
29
|
+
const [input, setInput] = useState("");
|
|
30
|
+
const [authUser, setAuthUser] = useState<{
|
|
31
|
+
id: string;
|
|
32
|
+
claims: Record<string, string>;
|
|
33
|
+
} | null>(null);
|
|
34
|
+
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
|
|
35
|
+
const [connecting, setConnecting] = useState(true);
|
|
36
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
const connRef = useRef<PulseConnection | null>(null);
|
|
38
|
+
|
|
39
|
+
const scrollToBottom = useCallback(() => {
|
|
40
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!session) return;
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
|
|
47
|
+
async function init() {
|
|
48
|
+
try {
|
|
49
|
+
const connection = await connectWithAuth();
|
|
50
|
+
if (cancelled) return;
|
|
51
|
+
|
|
52
|
+
connRef.current = connection;
|
|
53
|
+
setConnected(true);
|
|
54
|
+
setConnecting(false);
|
|
55
|
+
|
|
56
|
+
// Check auth user from the ACCEPT payload
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
const user = (connection as any).user;
|
|
59
|
+
if (user) setAuthUser(user);
|
|
60
|
+
|
|
61
|
+
// Open a chat stream
|
|
62
|
+
const stream = connection.stream("chat-room");
|
|
63
|
+
|
|
64
|
+
// Announce join
|
|
65
|
+
stream.send(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
type: "join",
|
|
68
|
+
user: session!.user.name,
|
|
69
|
+
userId: session!.user.id,
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Listen for messages
|
|
74
|
+
stream.on("data", (data: Uint8Array) => {
|
|
75
|
+
try {
|
|
76
|
+
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
77
|
+
if (msg.type === "chat") {
|
|
78
|
+
setMessages((prev) => [
|
|
79
|
+
...prev,
|
|
80
|
+
{
|
|
81
|
+
id: crypto.randomUUID(),
|
|
82
|
+
user: msg.user,
|
|
83
|
+
userId: msg.userId,
|
|
84
|
+
text: msg.text,
|
|
85
|
+
timestamp: Date.now(),
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
} else if (msg.type === "join") {
|
|
89
|
+
setOnlineUsers((prev) =>
|
|
90
|
+
prev.includes(msg.user) ? prev : [...prev, msg.user]
|
|
91
|
+
);
|
|
92
|
+
setMessages((prev) => [
|
|
93
|
+
...prev,
|
|
94
|
+
{
|
|
95
|
+
id: crypto.randomUUID(),
|
|
96
|
+
user: "System",
|
|
97
|
+
userId: "system",
|
|
98
|
+
text: `${msg.user} joined the chat`,
|
|
99
|
+
timestamp: Date.now(),
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// ignore parse errors
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
connection.on("disconnect", () => setConnected(false));
|
|
109
|
+
connection.on("reconnected", () => setConnected(true));
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error("Failed to connect:", err);
|
|
112
|
+
setConnecting(false);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
init();
|
|
117
|
+
|
|
118
|
+
return () => {
|
|
119
|
+
cancelled = true;
|
|
120
|
+
connRef.current?.disconnect();
|
|
121
|
+
};
|
|
122
|
+
}, [session]);
|
|
123
|
+
|
|
124
|
+
useEffect(scrollToBottom, [messages, scrollToBottom]);
|
|
125
|
+
|
|
126
|
+
function sendMessage(e: React.FormEvent) {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
if (!input.trim() || !connRef.current) return;
|
|
129
|
+
|
|
130
|
+
const stream = connRef.current.stream("chat-room");
|
|
131
|
+
stream.send(
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
type: "chat",
|
|
134
|
+
user: session?.user?.name || "Anonymous",
|
|
135
|
+
userId: session?.user?.id || "unknown",
|
|
136
|
+
text: input.trim(),
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
setInput("");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="h-screen flex flex-col">
|
|
144
|
+
{/* Header */}
|
|
145
|
+
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
|
146
|
+
<div className="flex items-center gap-3">
|
|
147
|
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center">
|
|
148
|
+
<MessageSquare className="w-5 h-5 text-white" />
|
|
149
|
+
</div>
|
|
150
|
+
<div>
|
|
151
|
+
<h1 className="text-lg font-semibold">Real-time Chat</h1>
|
|
152
|
+
<p className="text-xs text-slate-500">
|
|
153
|
+
Multi-user authenticated messaging
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="flex items-center gap-4">
|
|
158
|
+
{authUser && (
|
|
159
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
160
|
+
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
161
|
+
<span className="text-xs text-green-400">
|
|
162
|
+
{authUser.claims.name || authUser.id}
|
|
163
|
+
</span>
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
<div className="flex items-center gap-2">
|
|
167
|
+
{connected ? (
|
|
168
|
+
<>
|
|
169
|
+
<Wifi className="w-4 h-4 text-green-400" />
|
|
170
|
+
<span className="text-xs text-green-400">Connected</span>
|
|
171
|
+
</>
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
<WifiOff className="w-4 h-4 text-red-400" />
|
|
175
|
+
<span className="text-xs text-red-400">Disconnected</span>
|
|
176
|
+
</>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-800">
|
|
180
|
+
<Users className="w-3.5 h-3.5 text-slate-400" />
|
|
181
|
+
<span className="text-xs text-slate-400">
|
|
182
|
+
{onlineUsers.length || 1} online
|
|
183
|
+
</span>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
{/* Messages */}
|
|
189
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
190
|
+
{connecting && (
|
|
191
|
+
<div className="flex items-center justify-center py-20">
|
|
192
|
+
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" />
|
|
193
|
+
<span className="text-slate-400">
|
|
194
|
+
Connecting to Pulse relay...
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{messages.map((msg) => (
|
|
200
|
+
<div
|
|
201
|
+
key={msg.id}
|
|
202
|
+
className={`flex ${msg.userId === session?.user?.id ? "justify-end" : "justify-start"
|
|
203
|
+
}`}
|
|
204
|
+
>
|
|
205
|
+
<div
|
|
206
|
+
className={`max-w-md px-4 py-2.5 rounded-2xl text-sm ${msg.userId === "system"
|
|
207
|
+
? "bg-slate-800/50 text-slate-500 text-center text-xs mx-auto italic"
|
|
208
|
+
: msg.userId === session?.user?.id
|
|
209
|
+
? "bg-purple-600 text-white rounded-br-md"
|
|
210
|
+
: "glass text-slate-200 rounded-bl-md"
|
|
211
|
+
}`}
|
|
212
|
+
>
|
|
213
|
+
{msg.userId !== "system" && msg.userId !== session?.user?.id && (
|
|
214
|
+
<div className="text-xs text-purple-400 font-medium mb-1">
|
|
215
|
+
{msg.user}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
{msg.text}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
))}
|
|
222
|
+
<div ref={messagesEndRef} />
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Input */}
|
|
226
|
+
<form
|
|
227
|
+
onSubmit={sendMessage}
|
|
228
|
+
className="p-4 border-t border-slate-800 flex gap-3"
|
|
229
|
+
>
|
|
230
|
+
<input
|
|
231
|
+
type="text"
|
|
232
|
+
value={input}
|
|
233
|
+
onChange={(e) => setInput(e.target.value)}
|
|
234
|
+
placeholder="Type a message..."
|
|
235
|
+
disabled={!connected}
|
|
236
|
+
className="flex-1 px-4 py-2.5 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none text-sm transition-colors placeholder:text-slate-600 disabled:opacity-50"
|
|
237
|
+
/>
|
|
238
|
+
<button
|
|
239
|
+
type="submit"
|
|
240
|
+
disabled={!connected || !input.trim()}
|
|
241
|
+
className="px-4 py-2.5 bg-purple-600 hover:bg-purple-500 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
242
|
+
>
|
|
243
|
+
<Send className="w-4 h-4" />
|
|
244
|
+
</button>
|
|
245
|
+
</form>
|
|
246
|
+
</div>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from "react";
|
|
4
|
+
import { useSession } from "@/lib/auth-client";
|
|
5
|
+
import { connectWithAuth } from "@/lib/pulse";
|
|
6
|
+
import { Lock, Send, Shield, Wifi, WifiOff, Loader2, Key, ShieldCheck, Eye, EyeOff } from "lucide-react";
|
|
7
|
+
import type { PulseConnection } from "@sansavision/pulse-sdk";
|
|
8
|
+
|
|
9
|
+
interface EncryptedMessage {
|
|
10
|
+
id: string;
|
|
11
|
+
sender: string;
|
|
12
|
+
plaintext: string;
|
|
13
|
+
ciphertext: string;
|
|
14
|
+
timestamp: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function EncryptedChatPage() {
|
|
18
|
+
const { data: session } = useSession();
|
|
19
|
+
const [connected, setConnected] = useState(false);
|
|
20
|
+
const [authUser, setAuthUser] = useState<{ id: string; claims: Record<string, string> } | null>(null);
|
|
21
|
+
const [connecting, setConnecting] = useState(true);
|
|
22
|
+
const [messages, setMessages] = useState<EncryptedMessage[]>([]);
|
|
23
|
+
const [input, setInput] = useState("");
|
|
24
|
+
const [showCiphertext, setShowCiphertext] = useState(false);
|
|
25
|
+
const [encryptionKey] = useState(() =>
|
|
26
|
+
Array.from(crypto.getRandomValues(new Uint8Array(16)))
|
|
27
|
+
.map((b) => b.toString(16).padStart(2, "0")).join("")
|
|
28
|
+
);
|
|
29
|
+
const connRef = useRef<PulseConnection | null>(null);
|
|
30
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
const scrollToBottom = useCallback(() => {
|
|
33
|
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
function simpleEncrypt(text: string, key: string): string {
|
|
37
|
+
return Array.from(text).map((c, i) =>
|
|
38
|
+
(c.charCodeAt(0) ^ key.charCodeAt(i % key.length)).toString(16).padStart(2, "0")
|
|
39
|
+
).join("");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function simpleDecrypt(hex: string, key: string): string {
|
|
43
|
+
const bytes = hex.match(/.{2}/g) || [];
|
|
44
|
+
return bytes.map((b, i) =>
|
|
45
|
+
String.fromCharCode(parseInt(b, 16) ^ key.charCodeAt(i % key.length))
|
|
46
|
+
).join("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!session) return;
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
|
|
53
|
+
async function init() {
|
|
54
|
+
try {
|
|
55
|
+
const connection = await connectWithAuth();
|
|
56
|
+
if (cancelled) return;
|
|
57
|
+
|
|
58
|
+
connRef.current = connection;
|
|
59
|
+
setConnected(true);
|
|
60
|
+
setConnecting(false);
|
|
61
|
+
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
63
|
+
const user = (connection as any).user;
|
|
64
|
+
if (user) setAuthUser(user);
|
|
65
|
+
|
|
66
|
+
const stream = connection.stream("encrypted-room");
|
|
67
|
+
|
|
68
|
+
stream.on("data", (data: Uint8Array) => {
|
|
69
|
+
try {
|
|
70
|
+
const msg = JSON.parse(new TextDecoder().decode(data));
|
|
71
|
+
if (msg.type === "encrypted-msg") {
|
|
72
|
+
const decrypted = simpleDecrypt(msg.ciphertext, encryptionKey);
|
|
73
|
+
setMessages((prev) => [
|
|
74
|
+
...prev,
|
|
75
|
+
{
|
|
76
|
+
id: crypto.randomUUID(),
|
|
77
|
+
sender: msg.sender,
|
|
78
|
+
plaintext: decrypted,
|
|
79
|
+
ciphertext: msg.ciphertext,
|
|
80
|
+
timestamp: Date.now(),
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
} catch { /* ignore */ }
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
connection.on("disconnect", () => setConnected(false));
|
|
88
|
+
connection.on("reconnected", () => setConnected(true));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
console.error("Failed to connect:", err);
|
|
91
|
+
setConnecting(false);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
init();
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
connRef.current?.disconnect();
|
|
99
|
+
};
|
|
100
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
+
}, [session]);
|
|
102
|
+
|
|
103
|
+
useEffect(scrollToBottom, [messages, scrollToBottom]);
|
|
104
|
+
|
|
105
|
+
function sendMessage(e: React.FormEvent) {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
if (!input.trim() || !connRef.current) return;
|
|
108
|
+
|
|
109
|
+
const plaintext = input.trim();
|
|
110
|
+
const ciphertext = simpleEncrypt(plaintext, encryptionKey);
|
|
111
|
+
|
|
112
|
+
const stream = connRef.current.stream("encrypted-room");
|
|
113
|
+
stream.send(JSON.stringify({ type: "encrypted-msg", sender: session?.user?.name || "Anonymous", ciphertext }));
|
|
114
|
+
setInput("");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="h-screen flex flex-col">
|
|
119
|
+
<div className="p-4 border-b border-slate-800">
|
|
120
|
+
<div className="flex items-center justify-between mb-3">
|
|
121
|
+
<div className="flex items-center gap-3">
|
|
122
|
+
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-violet-600 flex items-center justify-center">
|
|
123
|
+
<Lock className="w-5 h-5 text-white" />
|
|
124
|
+
</div>
|
|
125
|
+
<div>
|
|
126
|
+
<h1 className="text-lg font-semibold">E2E Encrypted Chat</h1>
|
|
127
|
+
<p className="text-xs text-slate-500">Messages encrypted before leaving your browser</p>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="flex items-center gap-3">
|
|
131
|
+
{authUser && (
|
|
132
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-green-500/10 border border-green-500/20">
|
|
133
|
+
<Shield className="w-3.5 h-3.5 text-green-400" />
|
|
134
|
+
<span className="text-xs text-green-400">{authUser.claims.name || authUser.id}</span>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
{connected ? (
|
|
138
|
+
<div className="flex items-center gap-1.5"><Wifi className="w-4 h-4 text-green-400" /><span className="text-xs text-green-400">Connected</span></div>
|
|
139
|
+
) : (
|
|
140
|
+
<div className="flex items-center gap-1.5"><WifiOff className="w-4 h-4 text-red-400" /><span className="text-xs text-red-400">Disconnected</span></div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="flex items-center gap-4">
|
|
146
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-purple-500/10 border border-purple-500/20">
|
|
147
|
+
<Key className="w-3.5 h-3.5 text-purple-400" />
|
|
148
|
+
<code className="text-xs text-purple-400 font-mono">{encryptionKey.slice(0, 16)}...</code>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-violet-500/10 border border-violet-500/20">
|
|
151
|
+
<ShieldCheck className="w-3.5 h-3.5 text-violet-400" />
|
|
152
|
+
<span className="text-xs text-violet-400">XOR cipher (demo)</span>
|
|
153
|
+
</div>
|
|
154
|
+
<button onClick={() => setShowCiphertext(!showCiphertext)}
|
|
155
|
+
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-800 border border-slate-700 hover:border-slate-600 transition-colors">
|
|
156
|
+
{showCiphertext ? <EyeOff className="w-3.5 h-3.5 text-slate-400" /> : <Eye className="w-3.5 h-3.5 text-slate-400" />}
|
|
157
|
+
<span className="text-xs text-slate-400">{showCiphertext ? "Hide" : "Show"} ciphertext</span>
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
163
|
+
{connecting && (
|
|
164
|
+
<div className="flex items-center justify-center py-20">
|
|
165
|
+
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mr-2" /><span className="text-slate-400">Establishing encrypted channel...</span>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{messages.map((msg) => (
|
|
170
|
+
<div key={msg.id}>
|
|
171
|
+
<div className={`flex ${msg.sender === session?.user?.name ? "justify-end" : "justify-start"}`}>
|
|
172
|
+
<div className={`max-w-md px-4 py-2.5 rounded-2xl text-sm ${msg.sender === session?.user?.name ? "bg-purple-600 text-white rounded-br-md" : "glass text-slate-200 rounded-bl-md"}`}>
|
|
173
|
+
{msg.sender !== session?.user?.name && <div className="text-xs text-purple-400 font-medium mb-1">{msg.sender}</div>}
|
|
174
|
+
<div className="flex items-center gap-1.5"><Lock className="w-3 h-3 text-green-400 shrink-0" />{msg.plaintext}</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
{showCiphertext && (
|
|
178
|
+
<div className={`mt-1 text-xs font-mono text-slate-600 ${msg.sender === session?.user?.name ? "text-right" : "text-left"} px-4`}>
|
|
179
|
+
🔒 {msg.ciphertext.slice(0, 40)}...
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
))}
|
|
184
|
+
<div ref={messagesEndRef} />
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<form onSubmit={sendMessage} className="p-4 border-t border-slate-800 flex gap-3">
|
|
188
|
+
<input type="text" value={input} onChange={(e) => setInput(e.target.value)} placeholder="Type an encrypted message..."
|
|
189
|
+
disabled={!connected}
|
|
190
|
+
className="flex-1 px-4 py-2.5 rounded-xl bg-slate-800/50 border border-slate-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none text-sm transition-colors placeholder:text-slate-600 disabled:opacity-50" />
|
|
191
|
+
<button type="submit" disabled={!connected || !input.trim()}
|
|
192
|
+
className="px-4 py-2.5 bg-purple-600 hover:bg-purple-500 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed">
|
|
193
|
+
<Send className="w-4 h-4" />
|
|
194
|
+
</button>
|
|
195
|
+
</form>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|