@jhits/dashboard 0.0.1
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 +36 -0
- package/next.config.ts +32 -0
- package/package.json +79 -0
- package/postcss.config.mjs +7 -0
- package/src/api/README.md +72 -0
- package/src/api/masterRouter.ts +150 -0
- package/src/api/pluginRouter.ts +135 -0
- package/src/app/[locale]/(auth)/layout.tsx +30 -0
- package/src/app/[locale]/(auth)/login/page.tsx +201 -0
- package/src/app/[locale]/catch-all/page.tsx +10 -0
- package/src/app/[locale]/dashboard/[...pluginRoute]/page.tsx +98 -0
- package/src/app/[locale]/dashboard/layout.tsx +42 -0
- package/src/app/[locale]/dashboard/page.tsx +121 -0
- package/src/app/[locale]/dashboard/preferences/page.tsx +295 -0
- package/src/app/[locale]/dashboard/profile/page.tsx +491 -0
- package/src/app/[locale]/layout.tsx +28 -0
- package/src/app/actions/preferences.ts +40 -0
- package/src/app/actions/user.ts +191 -0
- package/src/app/api/auth/[...nextauth]/route.ts +6 -0
- package/src/app/api/plugin-images/list/route.ts +96 -0
- package/src/app/api/plugin-images/upload/route.ts +88 -0
- package/src/app/api/telemetry/log/route.ts +10 -0
- package/src/app/api/telemetry/route.ts +12 -0
- package/src/app/api/uploads/[filename]/route.ts +33 -0
- package/src/app/globals.css +181 -0
- package/src/app/layout.tsx +4 -0
- package/src/assets/locales/en/common.json +47 -0
- package/src/assets/locales/nl/common.json +48 -0
- package/src/assets/locales/sv/common.json +48 -0
- package/src/assets/plugins.json +42 -0
- package/src/assets/public/Logo_JH_black.jpg +0 -0
- package/src/assets/public/Logo_JH_black.png +0 -0
- package/src/assets/public/Logo_JH_white.png +0 -0
- package/src/assets/public/animated-logo-white.svg +5 -0
- package/src/assets/public/logo_black.svg +5 -0
- package/src/assets/public/logo_white.svg +5 -0
- package/src/assets/public/noimagefound.jpg +0 -0
- package/src/components/DashboardCatchAll.tsx +95 -0
- package/src/components/DashboardRootLayout.tsx +37 -0
- package/src/components/PluginNotFound.tsx +24 -0
- package/src/components/Providers.tsx +59 -0
- package/src/components/dashboard/Sidebar.tsx +263 -0
- package/src/components/dashboard/Topbar.tsx +363 -0
- package/src/components/page.tsx +130 -0
- package/src/config.ts +230 -0
- package/src/i18n/navigation.ts +7 -0
- package/src/i18n/request.ts +41 -0
- package/src/i18n/routing.ts +35 -0
- package/src/i18n/translations.ts +20 -0
- package/src/index.tsx +69 -0
- package/src/lib/auth.ts +159 -0
- package/src/lib/db.ts +11 -0
- package/src/lib/get-website-info.ts +78 -0
- package/src/lib/modules-config.ts +68 -0
- package/src/lib/mongodb.ts +32 -0
- package/src/lib/plugin-registry.tsx +77 -0
- package/src/lib/website-context.tsx +39 -0
- package/src/proxy.ts +55 -0
- package/src/router.tsx +45 -0
- package/src/routes.tsx +3 -0
- package/src/server.ts +8 -0
- package/src/types/plugin.ts +24 -0
- package/src/types/preferences.ts +13 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useRef } from 'react';
|
|
4
|
+
import { useTranslations } from 'next-intl';
|
|
5
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
6
|
+
import { useSession } from 'next-auth/react';
|
|
7
|
+
import {
|
|
8
|
+
User, Shield, Bell, Camera, Check,
|
|
9
|
+
ChevronRight, Key, CreditCard,
|
|
10
|
+
Activity, Trash2, X, Laptop, Smartphone, Globe
|
|
11
|
+
} from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
changePassword,
|
|
15
|
+
getUserProfile,
|
|
16
|
+
getUserSessions,
|
|
17
|
+
revokeSession,
|
|
18
|
+
updateProfileFields,
|
|
19
|
+
updateProfileImage
|
|
20
|
+
} from '../../../actions/user';
|
|
21
|
+
import { createPortal } from 'react-dom';
|
|
22
|
+
import Image from 'next/image';
|
|
23
|
+
|
|
24
|
+
// --- TYPES ---
|
|
25
|
+
interface UserData {
|
|
26
|
+
name: string;
|
|
27
|
+
email: string;
|
|
28
|
+
image?: string;
|
|
29
|
+
role?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SessionLog {
|
|
33
|
+
id: string;
|
|
34
|
+
device: string;
|
|
35
|
+
browser: string;
|
|
36
|
+
location: string;
|
|
37
|
+
isCurrent: boolean;
|
|
38
|
+
lastActive: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function ProfilePage() {
|
|
42
|
+
const t = useTranslations('common.profile');
|
|
43
|
+
const { data: session, update } = useSession();
|
|
44
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
45
|
+
|
|
46
|
+
// Global States
|
|
47
|
+
const [user, setUser] = useState<UserData | null>(null);
|
|
48
|
+
const [originalUser, setOriginalUser] = useState<UserData | null>(null);
|
|
49
|
+
const [loading, setLoading] = useState(true);
|
|
50
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
51
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
52
|
+
const [sessions, setSessions] = useState<SessionLog[]>([]);
|
|
53
|
+
|
|
54
|
+
// Feature States
|
|
55
|
+
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
|
56
|
+
const [isChangingPwd, setIsChangingPwd] = useState(false);
|
|
57
|
+
const [passwords, setPasswords] = useState({ current: '', new: '', confirm: '' });
|
|
58
|
+
|
|
59
|
+
useEffect(() => { loadData(); }, []);
|
|
60
|
+
|
|
61
|
+
async function loadData() {
|
|
62
|
+
setLoading(true);
|
|
63
|
+
try {
|
|
64
|
+
const [profile, activeSessions] = await Promise.all([
|
|
65
|
+
getUserProfile(),
|
|
66
|
+
getUserSessions()
|
|
67
|
+
]);
|
|
68
|
+
setUser(profile);
|
|
69
|
+
setOriginalUser(profile);
|
|
70
|
+
setSessions(activeSessions);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error(e);
|
|
73
|
+
} finally {
|
|
74
|
+
setLoading(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const handleRevokeSession = async (id: string) => {
|
|
79
|
+
try {
|
|
80
|
+
const result = await revokeSession(id);
|
|
81
|
+
if (result.success) {
|
|
82
|
+
setSessions(prev => prev.filter(s => s.id !== id));
|
|
83
|
+
}
|
|
84
|
+
} catch (error: unknown) {
|
|
85
|
+
const errorMessage = error instanceof Error ? error.message : "Failed to revoke session";
|
|
86
|
+
alert(errorMessage);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handleUpdateProfile = async () => {
|
|
91
|
+
if (!user) return;
|
|
92
|
+
setIsSaving(true);
|
|
93
|
+
try {
|
|
94
|
+
const result = await updateProfileFields(user.name, user.email);
|
|
95
|
+
if (result.success) {
|
|
96
|
+
await update({ user: { ...session?.user, name: result.name, email: result.email } });
|
|
97
|
+
setOriginalUser({ ...user });
|
|
98
|
+
alert("Profile updated!");
|
|
99
|
+
}
|
|
100
|
+
} catch (error: unknown) {
|
|
101
|
+
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
|
|
102
|
+
alert(errorMessage);
|
|
103
|
+
} finally {
|
|
104
|
+
setIsSaving(false);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleImageChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
109
|
+
const file = e.target.files?.[0];
|
|
110
|
+
if (!file) return;
|
|
111
|
+
setIsUploading(true);
|
|
112
|
+
const reader = new FileReader();
|
|
113
|
+
reader.onloadend = async () => {
|
|
114
|
+
try {
|
|
115
|
+
const result = await updateProfileImage(reader.result as string);
|
|
116
|
+
if (result.success) {
|
|
117
|
+
await update({ user: { ...session?.user, image: result.image } });
|
|
118
|
+
await loadData();
|
|
119
|
+
}
|
|
120
|
+
} catch (error) { alert("Upload failed"); } finally { setIsUploading(false); }
|
|
121
|
+
};
|
|
122
|
+
reader.readAsDataURL(file);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const handleRemoveImage = async (e: React.MouseEvent) => {
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
if (!confirm("Remove photo?")) return;
|
|
128
|
+
setIsUploading(true);
|
|
129
|
+
try {
|
|
130
|
+
const result = await updateProfileImage(null);
|
|
131
|
+
if (result.success) {
|
|
132
|
+
await update({ user: { ...session?.user, image: null } });
|
|
133
|
+
await loadData();
|
|
134
|
+
}
|
|
135
|
+
} catch (error) { console.error(error); } finally { setIsUploading(false); }
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const handlePasswordChange = async (e: React.FormEvent) => {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
if (passwords.new !== passwords.confirm) return alert("Passwords mismatch");
|
|
141
|
+
setIsChangingPwd(true);
|
|
142
|
+
try {
|
|
143
|
+
const result = await changePassword(passwords.current, passwords.new);
|
|
144
|
+
if (result.success) {
|
|
145
|
+
setShowPasswordModal(false);
|
|
146
|
+
setPasswords({ current: '', new: '', confirm: '' });
|
|
147
|
+
alert("Password updated");
|
|
148
|
+
}
|
|
149
|
+
} catch (error: unknown) {
|
|
150
|
+
const errorMessage = error instanceof Error ? error.message : "Failed to change password";
|
|
151
|
+
alert(errorMessage);
|
|
152
|
+
} finally { setIsChangingPwd(false); }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (loading) return <LoadingSpinner />;
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="space-y-8 pb-12">
|
|
159
|
+
|
|
160
|
+
<ProfileHero
|
|
161
|
+
user={user}
|
|
162
|
+
isUploading={isUploading}
|
|
163
|
+
fileInputRef={fileInputRef as React.RefObject<HTMLInputElement>}
|
|
164
|
+
onImageChange={handleImageChange}
|
|
165
|
+
onRemoveImage={handleRemoveImage}
|
|
166
|
+
/>
|
|
167
|
+
|
|
168
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
169
|
+
<div className="lg:col-span-2 space-y-8">
|
|
170
|
+
<PersonalInfoSection
|
|
171
|
+
user={user}
|
|
172
|
+
setUser={setUser}
|
|
173
|
+
originalUser={originalUser}
|
|
174
|
+
isSaving={isSaving}
|
|
175
|
+
onSave={handleUpdateProfile}
|
|
176
|
+
/>
|
|
177
|
+
<SessionHistorySection
|
|
178
|
+
sessions={sessions}
|
|
179
|
+
onRevoke={handleRevokeSession}
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div className="space-y-6">
|
|
184
|
+
<SecurityActions onPasswordClick={() => setShowPasswordModal(true)} />
|
|
185
|
+
<StatusCard />
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<AnimatePresence>
|
|
190
|
+
{showPasswordModal && (
|
|
191
|
+
<PasswordModal
|
|
192
|
+
passwords={passwords}
|
|
193
|
+
setPasswords={setPasswords}
|
|
194
|
+
isChanging={isChangingPwd}
|
|
195
|
+
onClose={() => setShowPasswordModal(false)}
|
|
196
|
+
onSubmit={handlePasswordChange}
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
</AnimatePresence>
|
|
200
|
+
</motion.div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- MODULAR SUB-COMPONENTS ---
|
|
205
|
+
|
|
206
|
+
function PersonalInfoSection({ user, setUser, originalUser, isSaving, onSave }: { user: UserData | null, setUser: (user: UserData) => void, originalUser: UserData | null, isSaving: boolean, onSave: () => void }) {
|
|
207
|
+
const hasChanges = user?.name !== originalUser?.name || user?.email !== originalUser?.email;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<section className="p-8 rounded-4xl bg-neutral-100 dark:bg-neutral-700/30 border border-neutral-300 dark:border-neutral-700 shadow-sm transition-all overflow-hidden">
|
|
211
|
+
<motion.div layout className="flex items-center justify-between mb-8">
|
|
212
|
+
<h2 className="text-lg font-black text-neutral-950 dark:text-neutral-50 uppercase tracking-tight flex items-center gap-2">
|
|
213
|
+
<User className="size-5 text-primary" />
|
|
214
|
+
Personal Information
|
|
215
|
+
</h2>
|
|
216
|
+
|
|
217
|
+
<AnimatePresence mode="wait">
|
|
218
|
+
{(hasChanges || isSaving) && (
|
|
219
|
+
<motion.button
|
|
220
|
+
key="save-button"
|
|
221
|
+
layout
|
|
222
|
+
initial={{ opacity: 0, scale: 0.9, x: 20 }}
|
|
223
|
+
animate={{ opacity: 1, scale: 1, x: 0 }}
|
|
224
|
+
exit={{ opacity: 0, scale: 0.9, x: 10 }}
|
|
225
|
+
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
|
226
|
+
onClick={onSave}
|
|
227
|
+
disabled={isSaving}
|
|
228
|
+
className="relative px-6 py-2.5 bg-primary text-white text-[10px] font-black uppercase tracking-widest rounded-xl hover:scale-[1.05] active:scale-95 shadow-lg shadow-primary/20 flex items-center justify-center min-w-[120px]"
|
|
229
|
+
>
|
|
230
|
+
<AnimatePresence mode="wait">
|
|
231
|
+
{isSaving ? (
|
|
232
|
+
<motion.div key="updating" initial={{ y: 15, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -15, opacity: 0 }} className="flex items-center gap-2">
|
|
233
|
+
<div className="size-3 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
234
|
+
<span>Updating</span>
|
|
235
|
+
</motion.div>
|
|
236
|
+
) : (
|
|
237
|
+
<motion.span key="save" initial={{ y: 15, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -15, opacity: 0 }}>
|
|
238
|
+
Save Changes
|
|
239
|
+
</motion.span>
|
|
240
|
+
)}
|
|
241
|
+
</AnimatePresence>
|
|
242
|
+
</motion.button>
|
|
243
|
+
)}
|
|
244
|
+
</AnimatePresence>
|
|
245
|
+
</motion.div>
|
|
246
|
+
|
|
247
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
248
|
+
<InputGroup
|
|
249
|
+
label="Full Name"
|
|
250
|
+
value={user?.name}
|
|
251
|
+
onChange={(v: string) => setUser({ ...user, name: v } as UserData)}
|
|
252
|
+
/>
|
|
253
|
+
<InputGroup
|
|
254
|
+
label="Email Address"
|
|
255
|
+
value={user?.email}
|
|
256
|
+
onChange={(v: string) => setUser({ ...user, email: v } as UserData)}
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
</section>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function ProfileHero({ user, isUploading, fileInputRef, onImageChange, onRemoveImage }: { user: UserData | null, isUploading: boolean, fileInputRef: React.RefObject<HTMLInputElement>, onImageChange: (e: React.ChangeEvent<HTMLInputElement>) => void, onRemoveImage: (e: React.MouseEvent) => void }) {
|
|
264
|
+
const initials = user?.name?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
|
|
265
|
+
return (
|
|
266
|
+
<header className="relative p-8 lg:p-10 rounded-[2.5rem] bg-neutral-100 dark:bg-neutral-700/50 border border-neutral-300 dark:border-neutral-700 overflow-hidden shadow-xl">
|
|
267
|
+
<div className="relative z-10 flex flex-col md:flex-row items-center gap-8">
|
|
268
|
+
<div className="relative group">
|
|
269
|
+
<input type="file" ref={fileInputRef} onChange={onImageChange} className="hidden" accept="image/*" />
|
|
270
|
+
<div onClick={() => fileInputRef.current?.click()} className={`size-32 lg:size-40 rounded-[2.5rem] bg-primary flex items-center justify-center text-white text-4xl font-black shadow-2xl overflow-hidden cursor-pointer transition-all ${isUploading ? 'opacity-50 grayscale' : ''}`}>
|
|
271
|
+
{user?.image ? <Image src={user.image} className="w-full h-full object-cover" alt="Profile" width={128} height={128} /> : initials}
|
|
272
|
+
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"><Camera className="size-8" /></div>
|
|
273
|
+
</div>
|
|
274
|
+
{user?.image && (
|
|
275
|
+
<button onClick={onRemoveImage} className="absolute -top-2 -right-2 size-10 rounded-2xl bg-red-500 border-4 border-neutral-100 dark:border-neutral-800 flex items-center justify-center shadow-lg"><Trash2 className="size-4 text-white" /></button>
|
|
276
|
+
)}
|
|
277
|
+
<div className="absolute -bottom-2 -right-2 size-10 rounded-2xl bg-emerald-500 border-4 border-neutral-100 dark:border-neutral-800 flex items-center justify-center shadow-lg"><Check className="size-5 text-white" strokeWidth={3} /></div>
|
|
278
|
+
</div>
|
|
279
|
+
<div className="text-center md:text-left">
|
|
280
|
+
<h1 className="text-3xl lg:text-4xl font-black text-neutral-950 dark:text-neutral-50 uppercase tracking-tight">{user?.name || 'User'}<span className="text-primary">.</span></h1>
|
|
281
|
+
<p className="text-neutral-600 dark:text-neutral-400 font-medium mb-3">{user?.email}</p>
|
|
282
|
+
<div className="flex gap-2 justify-center md:justify-start">
|
|
283
|
+
<span className="px-3 py-1 rounded-full bg-neutral-200 dark:bg-neutral-900 text-[10px] font-black uppercase text-neutral-500 dark:text-neutral-400">{user?.role || 'User'}</span>
|
|
284
|
+
<span className="px-3 py-1 rounded-full bg-primary/10 text-primary text-[10px] font-black uppercase tracking-widest">Verified Account</span>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/10 rounded-full blur-3xl -mr-20 -mt-20" />
|
|
289
|
+
</header>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function SessionHistorySection({ sessions, onRevoke }: { sessions: SessionLog[], onRevoke: (id: string) => void }) {
|
|
294
|
+
const [revokingId, setRevokingId] = useState<string | null>(null);
|
|
295
|
+
|
|
296
|
+
const handleRevoke = async (id: string) => {
|
|
297
|
+
if (!confirm("Are you sure you want to terminate this session?")) return;
|
|
298
|
+
setRevokingId(id);
|
|
299
|
+
try { await onRevoke(id); } finally { setRevokingId(null); }
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<section className="p-8 rounded-4xl bg-neutral-100 dark:bg-neutral-700/30 border border-neutral-300 dark:border-neutral-700 shadow-sm">
|
|
304
|
+
<h2 className="text-lg font-black text-neutral-950 dark:text-neutral-50 uppercase tracking-tight mb-6 flex items-center gap-2">
|
|
305
|
+
<Activity className="size-5 text-primary" />
|
|
306
|
+
Login Activity
|
|
307
|
+
</h2>
|
|
308
|
+
|
|
309
|
+
<div className="space-y-4">
|
|
310
|
+
<AnimatePresence mode="popLayout">
|
|
311
|
+
{sessions.map((sess: SessionLog) => (
|
|
312
|
+
<motion.div
|
|
313
|
+
key={sess.id}
|
|
314
|
+
layout
|
|
315
|
+
initial={{ opacity: 0, scale: 0.95 }}
|
|
316
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
317
|
+
exit={{ opacity: 0, scale: 0.95, x: -20 }}
|
|
318
|
+
className="flex items-center justify-between p-4 rounded-2xl bg-white/50 dark:bg-neutral-900/40 border border-neutral-200 dark:border-neutral-700/50"
|
|
319
|
+
>
|
|
320
|
+
<div className="flex items-center gap-4">
|
|
321
|
+
<div className="size-10 rounded-xl bg-white dark:bg-neutral-800 flex items-center justify-center border border-neutral-200 dark:border-neutral-700 shadow-sm">
|
|
322
|
+
{sess.device.toLowerCase().includes('phone') ? (
|
|
323
|
+
<Smartphone className="size-5 text-neutral-500" />
|
|
324
|
+
) : (
|
|
325
|
+
<Laptop className="size-5 text-neutral-500" />
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
<div>
|
|
329
|
+
<div className="flex items-center gap-2">
|
|
330
|
+
<p className="text-xs font-black uppercase text-neutral-900 dark:text-neutral-100">
|
|
331
|
+
{sess.device} • {sess.browser}
|
|
332
|
+
</p>
|
|
333
|
+
{sess.isCurrent && (
|
|
334
|
+
<span className="text-[8px] bg-emerald-500/10 text-emerald-500 px-2 py-0.5 rounded-full border border-emerald-500/20 font-black uppercase">
|
|
335
|
+
Current
|
|
336
|
+
</span>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
<p className="text-[10px] font-bold text-neutral-400 uppercase flex items-center gap-1 mt-0.5">
|
|
340
|
+
<Globe className="size-3" /> {sess.location} • {sess.lastActive}
|
|
341
|
+
</p>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
{!sess.isCurrent && (
|
|
346
|
+
<button
|
|
347
|
+
onClick={() => handleRevoke(sess.id)}
|
|
348
|
+
disabled={revokingId === sess.id}
|
|
349
|
+
className="text-[10px] font-black uppercase text-red-500 px-3 py-1.5 hover:bg-red-500/10 rounded-lg transition-all"
|
|
350
|
+
>
|
|
351
|
+
{revokingId === sess.id ? (
|
|
352
|
+
<div className="size-3 border-2 border-red-500 border-t-transparent rounded-full animate-spin" />
|
|
353
|
+
) : (
|
|
354
|
+
'Revoke'
|
|
355
|
+
)}
|
|
356
|
+
</button>
|
|
357
|
+
)}
|
|
358
|
+
</motion.div>
|
|
359
|
+
))}
|
|
360
|
+
</AnimatePresence>
|
|
361
|
+
</div>
|
|
362
|
+
</section>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function SecurityActions({ onPasswordClick }: { onPasswordClick: () => void }) {
|
|
367
|
+
return (
|
|
368
|
+
<section className="p-8 rounded-4xl bg-neutral-200 dark:bg-neutral-700/20 border border-neutral-300 dark:border-neutral-700">
|
|
369
|
+
<h2 className="text-sm font-black text-neutral-950 dark:text-neutral-50 uppercase mb-6">Security & Access</h2>
|
|
370
|
+
<div className="space-y-3">
|
|
371
|
+
<ActionButton label="Change Password" icon={Key} onClick={onPasswordClick} />
|
|
372
|
+
<ActionButton label="Two-Factor Auth" icon={Shield} onClick={() => {}} />
|
|
373
|
+
<ActionButton label="Billing Details" icon={CreditCard} onClick={() => {}} />
|
|
374
|
+
</div>
|
|
375
|
+
</section>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function ActionButton({ label, icon: Icon, onClick }: { label: string, icon: React.ElementType, onClick: () => void }) {
|
|
380
|
+
return (
|
|
381
|
+
<button onClick={onClick} className="w-full flex items-center justify-between p-4 rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-300 dark:border-neutral-700/50 hover:border-primary transition-all group">
|
|
382
|
+
<div className="flex items-center gap-3">
|
|
383
|
+
<div className="size-8 rounded-lg bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center group-hover:bg-primary/10 transition-colors"><Icon className="size-4 text-neutral-500 group-hover:text-primary" /></div>
|
|
384
|
+
<span className="text-xs font-bold text-neutral-700 dark:text-neutral-300 uppercase">{label}</span>
|
|
385
|
+
</div>
|
|
386
|
+
<ChevronRight className="size-4 text-neutral-300 group-hover:translate-x-1 transition-transform" />
|
|
387
|
+
</button>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function InputGroup({ label, value, onChange, type = "text" }: { label: string, value: string | undefined, onChange: (v: string) => void, type?: string }) {
|
|
392
|
+
return (
|
|
393
|
+
<div className="space-y-2">
|
|
394
|
+
<label className="text-[10px] font-black uppercase tracking-widest text-neutral-400">{label}</label>
|
|
395
|
+
<input
|
|
396
|
+
type={type} value={value || ""} onChange={(e) => onChange(e.target.value)}
|
|
397
|
+
className="w-full bg-white dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-700 p-4 rounded-2xl text-sm font-bold outline-none focus:border-primary transition-all dark:text-neutral-100"
|
|
398
|
+
/>
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function PasswordModal({ passwords, setPasswords, isChanging, onClose, onSubmit }: { passwords: { current: string, new: string, confirm: string }, setPasswords: (passwords: { current: string, new: string, confirm: string }) => void, isChanging: boolean, onClose: () => void, onSubmit: (e: React.FormEvent) => void }) {
|
|
404
|
+
// 1. Ensure this only runs on the client
|
|
405
|
+
const [mounted, setMounted] = useState(false);
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
setTimeout(() => {
|
|
408
|
+
setMounted(true);
|
|
409
|
+
}, 0);
|
|
410
|
+
}, []);
|
|
411
|
+
|
|
412
|
+
if (!mounted) return null;
|
|
413
|
+
|
|
414
|
+
// 2. Use createPortal to mount at the bottom of <body>
|
|
415
|
+
return createPortal(
|
|
416
|
+
<div
|
|
417
|
+
// 3. Handle background click to exit
|
|
418
|
+
onClick={onClose}
|
|
419
|
+
className="fixed inset-0 z-9999 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md cursor-pointer"
|
|
420
|
+
>
|
|
421
|
+
<motion.div
|
|
422
|
+
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
|
423
|
+
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
424
|
+
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
|
425
|
+
// 4. Prevent clicks inside the modal from closing it
|
|
426
|
+
onClick={(e) => e.stopPropagation()}
|
|
427
|
+
className="w-full max-w-md bg-white dark:bg-neutral-800 rounded-[2.5rem] p-8 shadow-2xl relative border border-neutral-300 dark:border-white/10 cursor-default"
|
|
428
|
+
>
|
|
429
|
+
<button
|
|
430
|
+
onClick={onClose}
|
|
431
|
+
className="absolute top-6 right-6 text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors"
|
|
432
|
+
>
|
|
433
|
+
<X className="size-6" />
|
|
434
|
+
</button>
|
|
435
|
+
|
|
436
|
+
<h3 className="text-xl font-black uppercase mb-6 flex items-center gap-2 dark:text-white text-neutral-950">
|
|
437
|
+
<Key className="size-5 text-primary" />
|
|
438
|
+
Security Update
|
|
439
|
+
</h3>
|
|
440
|
+
|
|
441
|
+
<form onSubmit={onSubmit} className="space-y-4">
|
|
442
|
+
<InputGroup
|
|
443
|
+
label="Current Password"
|
|
444
|
+
type="password"
|
|
445
|
+
value={passwords.current}
|
|
446
|
+
onChange={(v: string) => setPasswords({ ...passwords, current: v })}
|
|
447
|
+
/>
|
|
448
|
+
<InputGroup
|
|
449
|
+
label="New Password"
|
|
450
|
+
type="password"
|
|
451
|
+
value={passwords.new}
|
|
452
|
+
onChange={(v: string) => setPasswords({ ...passwords, current: passwords.current, new: v })}
|
|
453
|
+
/>
|
|
454
|
+
<InputGroup
|
|
455
|
+
label="Confirm New Password"
|
|
456
|
+
type="password"
|
|
457
|
+
value={passwords.confirm}
|
|
458
|
+
onChange={(v: string) => setPasswords({ ...passwords, confirm: v })}
|
|
459
|
+
/>
|
|
460
|
+
|
|
461
|
+
<button
|
|
462
|
+
type="submit"
|
|
463
|
+
disabled={isChanging}
|
|
464
|
+
className="w-full py-4 mt-4 bg-primary text-white text-xs font-black uppercase rounded-xl shadow-lg shadow-primary/20 transition-all hover:scale-[1.02] active:scale-95 disabled:opacity-50"
|
|
465
|
+
>
|
|
466
|
+
{isChanging ? 'Updating...' : 'Update Password'}
|
|
467
|
+
</button>
|
|
468
|
+
</form>
|
|
469
|
+
</motion.div>
|
|
470
|
+
</div>,
|
|
471
|
+
document.body
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function StatusCard() {
|
|
476
|
+
return (
|
|
477
|
+
<div className="p-6 rounded-4xl bg-primary flex items-center justify-between overflow-hidden relative">
|
|
478
|
+
<div className="relative z-10"><p className="text-[10px] font-black uppercase text-white/60 tracking-widest">Global Status</p><h3 className="text-lg font-black text-white uppercase italic">System Notifications</h3></div>
|
|
479
|
+
<div className="size-12 rounded-2xl bg-white/20 backdrop-blur-md flex items-center justify-center z-10"><Bell className="size-6 text-white animate-ring" /></div>
|
|
480
|
+
<div className="absolute -left-4 -bottom-4 size-24 bg-white/10 rounded-full blur-2xl" />
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function LoadingSpinner() {
|
|
486
|
+
return (
|
|
487
|
+
<div className="flex h-[60vh] items-center justify-center">
|
|
488
|
+
<div className="size-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NextIntlClientProvider } from 'next-intl';
|
|
2
|
+
import { getMessages } from 'next-intl/server';
|
|
3
|
+
import { Providers } from "../../components/Providers";
|
|
4
|
+
import "../globals.css";
|
|
5
|
+
|
|
6
|
+
export default async function LocaleLayout(props: {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
params: Promise<{ locale: string }>;
|
|
9
|
+
}) {
|
|
10
|
+
// 1. Unwrapping the params (Required for Next.js 15)
|
|
11
|
+
const { locale } = await props.params;
|
|
12
|
+
|
|
13
|
+
// 2. Extracting children from props
|
|
14
|
+
const children = props.children;
|
|
15
|
+
|
|
16
|
+
// 3. Getting the translations
|
|
17
|
+
const messages = await getMessages();
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<html lang={locale} suppressHydrationWarning>
|
|
21
|
+
<body className="antialiased">
|
|
22
|
+
<NextIntlClientProvider messages={messages} locale={locale}>
|
|
23
|
+
<Providers>{children}</Providers>
|
|
24
|
+
</NextIntlClientProvider>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
import { getServerSession } from "next-auth";
|
|
4
|
+
import { authOptions } from "../../lib/auth";
|
|
5
|
+
import clientPromise from "../../lib/mongodb";
|
|
6
|
+
import { UserPreferences } from "../../types/preferences";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PREFS: UserPreferences = {
|
|
9
|
+
theme: 'system',
|
|
10
|
+
language: 'en',
|
|
11
|
+
dashboardView: 'grid',
|
|
12
|
+
notifications: { marketing: false, security: true },
|
|
13
|
+
privacy: { stealthMode: false, publicSearch: true }
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export async function getPreferences() {
|
|
17
|
+
const session = await getServerSession(authOptions);
|
|
18
|
+
if (!session?.user?.email) return DEFAULT_PREFS;
|
|
19
|
+
|
|
20
|
+
const client = await clientPromise;
|
|
21
|
+
const db = client.db();
|
|
22
|
+
const user = await db.collection("users").findOne({ email: session.user.email });
|
|
23
|
+
|
|
24
|
+
return (user?.preferences as UserPreferences) || DEFAULT_PREFS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function updatePreferences(data: UserPreferences) {
|
|
28
|
+
const session = await getServerSession(authOptions);
|
|
29
|
+
if (!session?.user?.email) throw new Error("Unauthorized");
|
|
30
|
+
|
|
31
|
+
const client = await clientPromise;
|
|
32
|
+
const db = client.db();
|
|
33
|
+
|
|
34
|
+
await db.collection("users").updateOne(
|
|
35
|
+
{ email: session.user.email },
|
|
36
|
+
{ $set: { preferences: data } }
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return { success: true };
|
|
40
|
+
}
|