create-varity-app 2.0.0-beta.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/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/create.js +141 -0
- package/dist/index.js +45 -0
- package/dist/utils.js +29 -0
- package/package.json +61 -0
- package/template/.env.example +17 -0
- package/template/KNOWN_ISSUES.md +69 -0
- package/template/LICENSE +21 -0
- package/template/README.md +241 -0
- package/template/gitignore +42 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.js +21 -0
- package/template/package.json +39 -0
- package/template/postcss.config.js +6 -0
- package/template/public/logo.svg +4 -0
- package/template/public/robots.txt +4 -0
- package/template/public/sitemap.xml +4 -0
- package/template/src/app/dashboard/layout.tsx +298 -0
- package/template/src/app/dashboard/page.tsx +209 -0
- package/template/src/app/dashboard/projects/page.tsx +638 -0
- package/template/src/app/dashboard/settings/page.tsx +749 -0
- package/template/src/app/dashboard/tasks/page.tsx +301 -0
- package/template/src/app/dashboard/team/page.tsx +295 -0
- package/template/src/app/globals.css +177 -0
- package/template/src/app/icon.svg +4 -0
- package/template/src/app/layout.tsx +33 -0
- package/template/src/app/login/page.tsx +98 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +23 -0
- package/template/src/components/dashboard/DashboardStats.tsx +137 -0
- package/template/src/components/dashboard/RecentActivity.tsx +63 -0
- package/template/src/components/landing/CTA.tsx +42 -0
- package/template/src/components/landing/Features.tsx +116 -0
- package/template/src/components/landing/Hero.tsx +146 -0
- package/template/src/components/landing/HowItWorks.tsx +80 -0
- package/template/src/components/landing/Pricing.tsx +124 -0
- package/template/src/components/landing/Testimonials.tsx +78 -0
- package/template/src/components/providers.tsx +11 -0
- package/template/src/components/shared/Footer.tsx +71 -0
- package/template/src/components/shared/Navbar.tsx +87 -0
- package/template/src/lib/constants.ts +35 -0
- package/template/src/lib/database.ts +7 -0
- package/template/src/lib/hooks.ts +331 -0
- package/template/src/lib/utils.ts +68 -0
- package/template/src/lib/varity.ts +1 -0
- package/template/src/services/dashboardService.ts +589 -0
- package/template/src/types/index.ts +52 -0
- package/template/tailwind.config.js +27 -0
- package/template/tsconfig.json +23 -0
- package/template/varity.config.json +14 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { useCurrentUser, useUserSettings } from '@/lib/hooks';
|
|
5
|
+
import type { UserSettings } from '@/types';
|
|
6
|
+
import {
|
|
7
|
+
Button,
|
|
8
|
+
Input,
|
|
9
|
+
Select,
|
|
10
|
+
Dialog,
|
|
11
|
+
ConfirmDialog,
|
|
12
|
+
useToast,
|
|
13
|
+
Toggle,
|
|
14
|
+
Avatar,
|
|
15
|
+
ProgressBar,
|
|
16
|
+
RadioGroup,
|
|
17
|
+
Checkbox,
|
|
18
|
+
Badge,
|
|
19
|
+
DataTable,
|
|
20
|
+
Skeleton,
|
|
21
|
+
} from '@varity-labs/ui-kit';
|
|
22
|
+
import { APP_NAME } from '@/lib/constants';
|
|
23
|
+
import {
|
|
24
|
+
User,
|
|
25
|
+
Copy,
|
|
26
|
+
Camera,
|
|
27
|
+
Shield,
|
|
28
|
+
CreditCard,
|
|
29
|
+
Database,
|
|
30
|
+
Download,
|
|
31
|
+
Lock,
|
|
32
|
+
Globe,
|
|
33
|
+
Bell,
|
|
34
|
+
Trash2,
|
|
35
|
+
LogOut,
|
|
36
|
+
Smartphone,
|
|
37
|
+
Key,
|
|
38
|
+
} from 'lucide-react';
|
|
39
|
+
|
|
40
|
+
const privyAppId = process.env.NEXT_PUBLIC_PRIVY_APP_ID;
|
|
41
|
+
|
|
42
|
+
let PrivyUserProfile: any = null;
|
|
43
|
+
let usePrivyHook: any = null;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const uiKit = require('@varity-labs/ui-kit');
|
|
47
|
+
PrivyUserProfile = uiKit.PrivyUserProfile;
|
|
48
|
+
usePrivyHook = uiKit.usePrivy;
|
|
49
|
+
} catch {}
|
|
50
|
+
|
|
51
|
+
const TABS = [
|
|
52
|
+
{ id: 'general', label: 'General', icon: User },
|
|
53
|
+
{ id: 'security', label: 'Security', icon: Shield },
|
|
54
|
+
{ id: 'billing', label: 'Billing', icon: CreditCard },
|
|
55
|
+
{ id: 'account', label: 'Account', icon: Database },
|
|
56
|
+
] as const;
|
|
57
|
+
|
|
58
|
+
type TabId = (typeof TABS)[number]['id'];
|
|
59
|
+
|
|
60
|
+
// Mock usage data (developers replace with their billing provider)
|
|
61
|
+
const MOCK_USAGE = {
|
|
62
|
+
projects: { used: 5, limit: 10 },
|
|
63
|
+
storage: { used: 2.3, limit: 5 },
|
|
64
|
+
bandwidth: { used: 45, limit: 100 },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function SettingsSkeleton() {
|
|
68
|
+
return (
|
|
69
|
+
<div className="space-y-4">
|
|
70
|
+
<Skeleton width="100%" height={40} />
|
|
71
|
+
<Skeleton width="100%" height={40} />
|
|
72
|
+
<Skeleton width="60%" height={40} />
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default function SettingsPage() {
|
|
78
|
+
const { email, name, id } = useCurrentUser();
|
|
79
|
+
const { settings, loading, update: updateSettings } = useUserSettings();
|
|
80
|
+
const toast = useToast();
|
|
81
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- conditional on require() + env var, stable across renders
|
|
82
|
+
const privy = privyAppId && usePrivyHook ? usePrivyHook() : { logout: async () => {} };
|
|
83
|
+
|
|
84
|
+
const [activeTab, setActiveTab] = useState<TabId>('general');
|
|
85
|
+
|
|
86
|
+
// Dialog state
|
|
87
|
+
const [editProfileOpen, setEditProfileOpen] = useState(false);
|
|
88
|
+
const [profileForm, setProfileForm] = useState({ name: '', email: '' });
|
|
89
|
+
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
|
90
|
+
const [revokeSessionConfirmOpen, setRevokeSessionConfirmOpen] = useState(false);
|
|
91
|
+
const [sessionToRevoke, setSessionToRevoke] = useState<string | null>(null);
|
|
92
|
+
const [deleteAccountConfirmOpen, setDeleteAccountConfirmOpen] = useState(false);
|
|
93
|
+
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
|
94
|
+
|
|
95
|
+
// Active sessions (stateful so revoke actually removes from UI)
|
|
96
|
+
const [sessions, setSessions] = useState([
|
|
97
|
+
{
|
|
98
|
+
id: '1',
|
|
99
|
+
browser: 'Chrome on Windows',
|
|
100
|
+
location: 'San Francisco, US',
|
|
101
|
+
ip: '192.168.1.1',
|
|
102
|
+
lastActive: '2026-02-12T12:00:00.000Z',
|
|
103
|
+
current: true,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: '2',
|
|
107
|
+
browser: 'Safari on macOS',
|
|
108
|
+
location: 'New York, US',
|
|
109
|
+
ip: '192.168.1.2',
|
|
110
|
+
lastActive: '2026-02-12T11:00:00.000Z',
|
|
111
|
+
current: false,
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
// Sync profile form when Privy data arrives asynchronously
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (name || email) {
|
|
118
|
+
setProfileForm({ name: name || '', email: email || '' });
|
|
119
|
+
}
|
|
120
|
+
}, [name, email]);
|
|
121
|
+
|
|
122
|
+
const handleCopyId = () => {
|
|
123
|
+
navigator.clipboard.writeText(id);
|
|
124
|
+
toast.success('User ID copied to clipboard');
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleSaveProfile = () => {
|
|
128
|
+
toast.info('Profile changes are managed by your auth provider');
|
|
129
|
+
setEditProfileOpen(false);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleChangePassword = () => {
|
|
133
|
+
toast.info('Password changes are managed by your auth provider');
|
|
134
|
+
setChangePasswordOpen(false);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleRevokeSession = () => {
|
|
138
|
+
if (sessionToRevoke) {
|
|
139
|
+
setSessions((prev) => prev.filter((s) => s.id !== sessionToRevoke));
|
|
140
|
+
toast.success('Session revoked successfully');
|
|
141
|
+
}
|
|
142
|
+
setRevokeSessionConfirmOpen(false);
|
|
143
|
+
setSessionToRevoke(null);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleDownloadData = () => {
|
|
147
|
+
const exportData = {
|
|
148
|
+
user: { id, name, email },
|
|
149
|
+
settings: settings,
|
|
150
|
+
exportedAt: new Date().toISOString(),
|
|
151
|
+
};
|
|
152
|
+
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
|
153
|
+
const url = URL.createObjectURL(blob);
|
|
154
|
+
const a = document.createElement('a');
|
|
155
|
+
a.href = url;
|
|
156
|
+
a.download = `${APP_NAME.toLowerCase()}-data-export.json`;
|
|
157
|
+
a.click();
|
|
158
|
+
URL.revokeObjectURL(url);
|
|
159
|
+
toast.success('Data exported successfully');
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const handleDeleteAccount = () => {
|
|
163
|
+
toast.error('Account deletion is not available in the demo');
|
|
164
|
+
setDeleteAccountConfirmOpen(false);
|
|
165
|
+
setDeleteConfirmText('');
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const handleSettingChange = (updates: Partial<UserSettings>) => {
|
|
169
|
+
updateSettings(updates).catch(() => {
|
|
170
|
+
toast.error('Failed to save setting');
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const sessionColumns = [
|
|
175
|
+
{
|
|
176
|
+
key: 'browser',
|
|
177
|
+
header: 'Device',
|
|
178
|
+
render: (value: string, row: any) => (
|
|
179
|
+
<div className="flex items-center gap-2">
|
|
180
|
+
<Smartphone className="h-4 w-4 text-gray-400" />
|
|
181
|
+
<div>
|
|
182
|
+
<div className="font-medium text-gray-900">
|
|
183
|
+
{value}
|
|
184
|
+
{row.current && (
|
|
185
|
+
<span className="ml-2 text-xs text-green-600 font-normal">(this device)</span>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
<div className="text-xs text-gray-500">{row.ip}</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
),
|
|
192
|
+
},
|
|
193
|
+
{ key: 'location', header: 'Location' },
|
|
194
|
+
{
|
|
195
|
+
key: 'lastActive',
|
|
196
|
+
header: 'Last Active',
|
|
197
|
+
render: (value: string) => {
|
|
198
|
+
const date = new Date(value);
|
|
199
|
+
const now = new Date();
|
|
200
|
+
const diff = now.getTime() - date.getTime();
|
|
201
|
+
const hours = Math.floor(diff / 3600000);
|
|
202
|
+
if (hours === 0) return 'Just now';
|
|
203
|
+
if (hours < 24) return `${hours}h ago`;
|
|
204
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
key: 'id',
|
|
209
|
+
header: '',
|
|
210
|
+
render: (value: string, row: any) => (
|
|
211
|
+
row.current ? (
|
|
212
|
+
<Badge variant="green">Current</Badge>
|
|
213
|
+
) : (
|
|
214
|
+
<Button
|
|
215
|
+
size="sm"
|
|
216
|
+
variant="outline"
|
|
217
|
+
onClick={() => {
|
|
218
|
+
setSessionToRevoke(value);
|
|
219
|
+
setRevokeSessionConfirmOpen(true);
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
Revoke
|
|
223
|
+
</Button>
|
|
224
|
+
)
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
// --- Tab content renderers ---
|
|
230
|
+
|
|
231
|
+
const renderGeneral = () => (
|
|
232
|
+
<div className="space-y-6">
|
|
233
|
+
{/* Profile card */}
|
|
234
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
235
|
+
<div className="flex items-start justify-between mb-6">
|
|
236
|
+
<div className="flex items-center gap-4">
|
|
237
|
+
<div className="relative">
|
|
238
|
+
<Avatar name={name || 'User'} size="lg" />
|
|
239
|
+
<button
|
|
240
|
+
className="absolute bottom-0 right-0 p-1.5 bg-primary-600 rounded-full text-white hover:bg-primary-700 transition-colors"
|
|
241
|
+
onClick={() => toast.info('Profile picture upload coming soon')}
|
|
242
|
+
>
|
|
243
|
+
<Camera className="h-3 w-3" />
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
<div>
|
|
247
|
+
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
248
|
+
{name || 'User'}
|
|
249
|
+
<Badge variant="blue">Free Plan</Badge>
|
|
250
|
+
</h2>
|
|
251
|
+
<p className="text-sm text-gray-600">{email || 'No email set'}</p>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
<Button variant="outline" onClick={() => setEditProfileOpen(true)}>
|
|
255
|
+
<User className="h-4 w-4" />
|
|
256
|
+
Edit Profile
|
|
257
|
+
</Button>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-gray-100">
|
|
261
|
+
<div>
|
|
262
|
+
<label className="block text-xs font-medium text-gray-500 mb-1">User ID</label>
|
|
263
|
+
<div className="flex items-center gap-2">
|
|
264
|
+
<code className="text-sm text-gray-900 font-mono bg-gray-50 px-2 py-1 rounded">
|
|
265
|
+
{id.substring(0, 16)}...
|
|
266
|
+
</code>
|
|
267
|
+
<Button size="sm" variant="ghost" onClick={handleCopyId}>
|
|
268
|
+
<Copy className="h-3 w-3" />
|
|
269
|
+
</Button>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
<div>
|
|
273
|
+
<label className="block text-xs font-medium text-gray-500 mb-1">Member Since</label>
|
|
274
|
+
<p className="text-sm text-gray-900">January 2026</p>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{/* Preferences */}
|
|
280
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
281
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
282
|
+
<Globe className="h-5 w-5" />
|
|
283
|
+
Preferences
|
|
284
|
+
</h2>
|
|
285
|
+
|
|
286
|
+
{loading || !settings ? (
|
|
287
|
+
<SettingsSkeleton />
|
|
288
|
+
) : (
|
|
289
|
+
<div className="space-y-6">
|
|
290
|
+
<div>
|
|
291
|
+
<label className="block text-sm font-medium text-gray-900 mb-2">Theme</label>
|
|
292
|
+
<Select
|
|
293
|
+
value={settings.theme}
|
|
294
|
+
onChange={(e) => handleSettingChange({ theme: e.target.value as UserSettings['theme'] })}
|
|
295
|
+
options={[
|
|
296
|
+
{ value: 'light', label: 'Light' },
|
|
297
|
+
{ value: 'dark', label: 'Dark' },
|
|
298
|
+
{ value: 'system', label: 'System' },
|
|
299
|
+
]}
|
|
300
|
+
/>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<div className="border-t border-gray-100 pt-4">
|
|
304
|
+
<h3 className="text-sm font-medium text-gray-900 flex items-center gap-2 mb-4">
|
|
305
|
+
<Bell className="h-4 w-4" />
|
|
306
|
+
Notifications
|
|
307
|
+
</h3>
|
|
308
|
+
<div className="space-y-4">
|
|
309
|
+
<Toggle
|
|
310
|
+
checked={settings.email_notifications}
|
|
311
|
+
onChange={(v) => handleSettingChange({ email_notifications: v })}
|
|
312
|
+
label="Email notifications"
|
|
313
|
+
description="Receive email notifications about your activity"
|
|
314
|
+
/>
|
|
315
|
+
<Toggle
|
|
316
|
+
checked={settings.marketing_emails}
|
|
317
|
+
onChange={(v) => handleSettingChange({ marketing_emails: v })}
|
|
318
|
+
label="Marketing emails"
|
|
319
|
+
description="Receive emails about new features and updates"
|
|
320
|
+
/>
|
|
321
|
+
<Toggle
|
|
322
|
+
checked={settings.product_updates}
|
|
323
|
+
onChange={(v) => handleSettingChange({ product_updates: v })}
|
|
324
|
+
label="Product updates"
|
|
325
|
+
description="Get notified when we ship new features"
|
|
326
|
+
/>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<div className="border-t border-gray-100 pt-4">
|
|
331
|
+
<h3 className="text-sm font-medium text-gray-900 mb-4">Locale</h3>
|
|
332
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
333
|
+
<div>
|
|
334
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">Date Format</label>
|
|
335
|
+
<Select
|
|
336
|
+
value={settings.date_format}
|
|
337
|
+
onChange={(e) => handleSettingChange({ date_format: e.target.value })}
|
|
338
|
+
options={[
|
|
339
|
+
{ value: 'MM/DD/YYYY', label: 'MM/DD/YYYY' },
|
|
340
|
+
{ value: 'DD/MM/YYYY', label: 'DD/MM/YYYY' },
|
|
341
|
+
{ value: 'YYYY-MM-DD', label: 'YYYY-MM-DD' },
|
|
342
|
+
]}
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
<div>
|
|
346
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">Timezone</label>
|
|
347
|
+
<Select
|
|
348
|
+
value={settings.timezone}
|
|
349
|
+
onChange={(e) => handleSettingChange({ timezone: e.target.value })}
|
|
350
|
+
options={[
|
|
351
|
+
{ value: 'America/Los_Angeles', label: 'Pacific Time' },
|
|
352
|
+
{ value: 'America/New_York', label: 'Eastern Time' },
|
|
353
|
+
{ value: 'Europe/London', label: 'London' },
|
|
354
|
+
{ value: 'Asia/Tokyo', label: 'Tokyo' },
|
|
355
|
+
]}
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
<div>
|
|
359
|
+
<label className="block text-sm font-medium text-gray-700 mb-2">Language</label>
|
|
360
|
+
<Select
|
|
361
|
+
value={settings.language}
|
|
362
|
+
onChange={(e) => handleSettingChange({ language: e.target.value })}
|
|
363
|
+
options={[
|
|
364
|
+
{ value: 'en', label: 'English' },
|
|
365
|
+
{ value: 'es', label: 'Español' },
|
|
366
|
+
{ value: 'fr', label: 'Français' },
|
|
367
|
+
]}
|
|
368
|
+
/>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<div className="border-t border-gray-100 pt-4">
|
|
374
|
+
<RadioGroup
|
|
375
|
+
value={settings.dashboard_layout}
|
|
376
|
+
onChange={(v) => handleSettingChange({ dashboard_layout: v as UserSettings['dashboard_layout'] })}
|
|
377
|
+
name="dashboard-layout"
|
|
378
|
+
label="Dashboard Layout"
|
|
379
|
+
options={[
|
|
380
|
+
{ value: 'comfortable', label: 'Comfortable', description: 'More spacing and larger elements' },
|
|
381
|
+
{ value: 'compact', label: 'Compact', description: 'Tighter spacing, fit more content' },
|
|
382
|
+
]}
|
|
383
|
+
orientation="horizontal"
|
|
384
|
+
/>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const renderSecurity = () => (
|
|
393
|
+
<div className="space-y-6">
|
|
394
|
+
{/* 2FA */}
|
|
395
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
396
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
397
|
+
<Shield className="h-5 w-5" />
|
|
398
|
+
Two-Factor Authentication
|
|
399
|
+
</h2>
|
|
400
|
+
|
|
401
|
+
{loading || !settings ? (
|
|
402
|
+
<SettingsSkeleton />
|
|
403
|
+
) : (
|
|
404
|
+
<div className="space-y-4">
|
|
405
|
+
<div>
|
|
406
|
+
<Toggle
|
|
407
|
+
checked={settings.two_factor_enabled}
|
|
408
|
+
onChange={(v) => handleSettingChange({ two_factor_enabled: v })}
|
|
409
|
+
label={<span className="inline-flex items-center gap-2">Enable 2FA{settings.two_factor_enabled && <Badge variant="green">Enabled</Badge>}</span>}
|
|
410
|
+
description="Add an extra layer of security to your account"
|
|
411
|
+
/>
|
|
412
|
+
</div>
|
|
413
|
+
{settings.two_factor_enabled && (
|
|
414
|
+
<p className="text-sm text-gray-500 pl-14">
|
|
415
|
+
Two-factor authentication is active. You will be prompted for a verification code when signing in.
|
|
416
|
+
</p>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
)}
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
{/* Password */}
|
|
423
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
424
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
|
425
|
+
<Lock className="h-5 w-5" />
|
|
426
|
+
Password
|
|
427
|
+
</h2>
|
|
428
|
+
<p className="text-sm text-gray-500 mb-4">
|
|
429
|
+
Password management is handled by your authentication provider.
|
|
430
|
+
</p>
|
|
431
|
+
<Button variant="outline" onClick={() => setChangePasswordOpen(true)}>
|
|
432
|
+
<Lock className="h-4 w-4" />
|
|
433
|
+
Change Password
|
|
434
|
+
</Button>
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
{/* Active Sessions */}
|
|
438
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
439
|
+
<div className="flex items-center justify-between mb-4">
|
|
440
|
+
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
441
|
+
<Smartphone className="h-5 w-5" />
|
|
442
|
+
Active Sessions
|
|
443
|
+
</h2>
|
|
444
|
+
<span className="text-sm text-gray-500">{sessions.length} active</span>
|
|
445
|
+
</div>
|
|
446
|
+
<DataTable
|
|
447
|
+
columns={sessionColumns}
|
|
448
|
+
data={sessions}
|
|
449
|
+
emptyMessage="No active sessions"
|
|
450
|
+
/>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
const renderBilling = () => (
|
|
456
|
+
<div className="space-y-6">
|
|
457
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
458
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
459
|
+
<CreditCard className="h-5 w-5" />
|
|
460
|
+
Current Plan
|
|
461
|
+
</h2>
|
|
462
|
+
|
|
463
|
+
<div className="p-4 bg-gray-50 rounded-lg">
|
|
464
|
+
<div className="flex items-center justify-between mb-2">
|
|
465
|
+
<span className="text-sm font-medium text-gray-700">Free Plan</span>
|
|
466
|
+
<Badge variant="blue">Active</Badge>
|
|
467
|
+
</div>
|
|
468
|
+
<p className="text-2xl font-bold text-gray-900">$0<span className="text-sm font-normal text-gray-600">/month</span></p>
|
|
469
|
+
<Button className="mt-4 w-full" variant="primary">
|
|
470
|
+
Upgrade to Pro
|
|
471
|
+
</Button>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
|
|
475
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
476
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-4">Usage</h2>
|
|
477
|
+
<div className="space-y-5">
|
|
478
|
+
<div>
|
|
479
|
+
<div className="flex items-center justify-between mb-2">
|
|
480
|
+
<span className="text-sm font-medium text-gray-700">Projects</span>
|
|
481
|
+
<span className="text-sm text-gray-600">{MOCK_USAGE.projects.used} / {MOCK_USAGE.projects.limit}</span>
|
|
482
|
+
</div>
|
|
483
|
+
<ProgressBar
|
|
484
|
+
value={(MOCK_USAGE.projects.used / MOCK_USAGE.projects.limit) * 100}
|
|
485
|
+
variant="primary"
|
|
486
|
+
/>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<div>
|
|
490
|
+
<div className="flex items-center justify-between mb-2">
|
|
491
|
+
<span className="text-sm font-medium text-gray-700">Storage</span>
|
|
492
|
+
<span className="text-sm text-gray-600">{MOCK_USAGE.storage.used} GB / {MOCK_USAGE.storage.limit} GB</span>
|
|
493
|
+
</div>
|
|
494
|
+
<ProgressBar
|
|
495
|
+
value={(MOCK_USAGE.storage.used / MOCK_USAGE.storage.limit) * 100}
|
|
496
|
+
variant="success"
|
|
497
|
+
/>
|
|
498
|
+
</div>
|
|
499
|
+
|
|
500
|
+
<div>
|
|
501
|
+
<div className="flex items-center justify-between mb-2">
|
|
502
|
+
<span className="text-sm font-medium text-gray-700">Bandwidth</span>
|
|
503
|
+
<span className="text-sm text-gray-600">{MOCK_USAGE.bandwidth.used} GB / {MOCK_USAGE.bandwidth.limit} GB</span>
|
|
504
|
+
</div>
|
|
505
|
+
<ProgressBar
|
|
506
|
+
value={(MOCK_USAGE.bandwidth.used / MOCK_USAGE.bandwidth.limit) * 100}
|
|
507
|
+
variant="warning"
|
|
508
|
+
/>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
</div>
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
const renderAccount = () => (
|
|
516
|
+
<div className="space-y-6">
|
|
517
|
+
{/* Data Export */}
|
|
518
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
519
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
|
520
|
+
<Download className="h-5 w-5" />
|
|
521
|
+
Export Data
|
|
522
|
+
</h2>
|
|
523
|
+
<p className="text-sm text-gray-500 mb-4">
|
|
524
|
+
Download a copy of all your data including profile, settings, and activity in JSON format.
|
|
525
|
+
</p>
|
|
526
|
+
<Button variant="outline" onClick={handleDownloadData}>
|
|
527
|
+
<Download className="h-4 w-4" />
|
|
528
|
+
Download My Data
|
|
529
|
+
</Button>
|
|
530
|
+
</div>
|
|
531
|
+
|
|
532
|
+
{/* Privacy Preferences */}
|
|
533
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
534
|
+
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
535
|
+
<Database className="h-5 w-5" />
|
|
536
|
+
Privacy Preferences
|
|
537
|
+
</h2>
|
|
538
|
+
|
|
539
|
+
{loading || !settings ? (
|
|
540
|
+
<SettingsSkeleton />
|
|
541
|
+
) : (
|
|
542
|
+
<div className="space-y-4">
|
|
543
|
+
<Checkbox
|
|
544
|
+
checked={settings.analytics_enabled}
|
|
545
|
+
onChange={(v) => handleSettingChange({ analytics_enabled: v })}
|
|
546
|
+
label="Usage analytics"
|
|
547
|
+
description="Help us improve by sharing anonymous usage data"
|
|
548
|
+
/>
|
|
549
|
+
<Checkbox
|
|
550
|
+
checked={settings.cookies_enabled}
|
|
551
|
+
onChange={(v) => handleSettingChange({ cookies_enabled: v })}
|
|
552
|
+
label="Optional cookies"
|
|
553
|
+
description="Allow non-essential cookies for personalization"
|
|
554
|
+
/>
|
|
555
|
+
</div>
|
|
556
|
+
)}
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
{/* Privy Account Settings */}
|
|
560
|
+
{privyAppId && PrivyUserProfile && (
|
|
561
|
+
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
|
|
562
|
+
<h2 className="mb-4 text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
563
|
+
<Key className="h-5 w-5" />
|
|
564
|
+
Account Settings (Privy)
|
|
565
|
+
</h2>
|
|
566
|
+
<PrivyUserProfile showLogoutButton={false} />
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
569
|
+
|
|
570
|
+
{/* Danger Zone */}
|
|
571
|
+
<div className="rounded-xl border border-red-200 bg-red-50 p-6">
|
|
572
|
+
<h2 className="mb-2 text-lg font-semibold text-red-900 flex items-center gap-2">
|
|
573
|
+
<Trash2 className="h-5 w-5" />
|
|
574
|
+
Danger Zone
|
|
575
|
+
</h2>
|
|
576
|
+
<p className="mb-4 text-sm text-red-700">
|
|
577
|
+
These actions are permanent and cannot be undone.
|
|
578
|
+
</p>
|
|
579
|
+
<div className="flex flex-wrap gap-3">
|
|
580
|
+
<Button variant="danger" onClick={() => privy.logout()}>
|
|
581
|
+
<LogOut className="h-4 w-4" />
|
|
582
|
+
Sign Out
|
|
583
|
+
</Button>
|
|
584
|
+
<Button variant="outline" onClick={() => setDeleteAccountConfirmOpen(true)}>
|
|
585
|
+
<Trash2 className="h-4 w-4" />
|
|
586
|
+
Delete Account
|
|
587
|
+
</Button>
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
</div>
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const tabContent: Record<TabId, () => JSX.Element> = {
|
|
594
|
+
general: renderGeneral,
|
|
595
|
+
security: renderSecurity,
|
|
596
|
+
billing: renderBilling,
|
|
597
|
+
account: renderAccount,
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
return (
|
|
601
|
+
<div className="space-y-6">
|
|
602
|
+
{/* Header */}
|
|
603
|
+
<div>
|
|
604
|
+
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
|
605
|
+
<p className="mt-1 text-sm text-gray-600">
|
|
606
|
+
Manage your account, preferences, and security settings.
|
|
607
|
+
</p>
|
|
608
|
+
</div>
|
|
609
|
+
|
|
610
|
+
{/* Tab bar */}
|
|
611
|
+
<div className="border-b border-gray-200">
|
|
612
|
+
<nav className="flex gap-1 -mb-px" aria-label="Settings tabs">
|
|
613
|
+
{TABS.map((tab) => {
|
|
614
|
+
const Icon = tab.icon;
|
|
615
|
+
const isActive = activeTab === tab.id;
|
|
616
|
+
return (
|
|
617
|
+
<button
|
|
618
|
+
key={tab.id}
|
|
619
|
+
onClick={() => setActiveTab(tab.id)}
|
|
620
|
+
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
|
621
|
+
isActive
|
|
622
|
+
? 'border-primary-600 text-primary-600'
|
|
623
|
+
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
624
|
+
}`}
|
|
625
|
+
>
|
|
626
|
+
<Icon className="h-4 w-4" />
|
|
627
|
+
{tab.label}
|
|
628
|
+
</button>
|
|
629
|
+
);
|
|
630
|
+
})}
|
|
631
|
+
</nav>
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
{/* Tab content */}
|
|
635
|
+
{tabContent[activeTab]()}
|
|
636
|
+
|
|
637
|
+
{/* Dialogs (always mounted, independent of tabs) */}
|
|
638
|
+
<Dialog
|
|
639
|
+
open={editProfileOpen}
|
|
640
|
+
onClose={() => setEditProfileOpen(false)}
|
|
641
|
+
title="Edit Profile"
|
|
642
|
+
>
|
|
643
|
+
<div className="space-y-4">
|
|
644
|
+
<p className="text-sm text-gray-500">
|
|
645
|
+
Your profile is managed by your authentication provider. Changes made here are for display purposes only.
|
|
646
|
+
</p>
|
|
647
|
+
<Input
|
|
648
|
+
label="Display Name"
|
|
649
|
+
value={profileForm.name}
|
|
650
|
+
onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
|
|
651
|
+
placeholder="Your name"
|
|
652
|
+
/>
|
|
653
|
+
<Input
|
|
654
|
+
label="Email"
|
|
655
|
+
type="email"
|
|
656
|
+
value={profileForm.email}
|
|
657
|
+
onChange={(e) => setProfileForm({ ...profileForm, email: e.target.value })}
|
|
658
|
+
placeholder="your@email.com"
|
|
659
|
+
disabled
|
|
660
|
+
hint="Email is managed by your auth provider"
|
|
661
|
+
/>
|
|
662
|
+
<div className="flex justify-end gap-3 pt-4">
|
|
663
|
+
<Button variant="outline" onClick={() => setEditProfileOpen(false)}>
|
|
664
|
+
Cancel
|
|
665
|
+
</Button>
|
|
666
|
+
<Button onClick={handleSaveProfile}>
|
|
667
|
+
Save Changes
|
|
668
|
+
</Button>
|
|
669
|
+
</div>
|
|
670
|
+
</div>
|
|
671
|
+
</Dialog>
|
|
672
|
+
|
|
673
|
+
<Dialog
|
|
674
|
+
open={changePasswordOpen}
|
|
675
|
+
onClose={() => setChangePasswordOpen(false)}
|
|
676
|
+
title="Change Password"
|
|
677
|
+
>
|
|
678
|
+
<div className="space-y-4">
|
|
679
|
+
<p className="text-sm text-gray-500">
|
|
680
|
+
Password management is handled by your authentication provider. Use the fields below to request a password change.
|
|
681
|
+
</p>
|
|
682
|
+
<Input
|
|
683
|
+
label="Current Password"
|
|
684
|
+
type="password"
|
|
685
|
+
placeholder="Enter current password"
|
|
686
|
+
/>
|
|
687
|
+
<Input
|
|
688
|
+
label="New Password"
|
|
689
|
+
type="password"
|
|
690
|
+
placeholder="Enter new password"
|
|
691
|
+
/>
|
|
692
|
+
<Input
|
|
693
|
+
label="Confirm New Password"
|
|
694
|
+
type="password"
|
|
695
|
+
placeholder="Confirm new password"
|
|
696
|
+
/>
|
|
697
|
+
<div className="flex justify-end gap-3 pt-4">
|
|
698
|
+
<Button variant="outline" onClick={() => setChangePasswordOpen(false)}>
|
|
699
|
+
Cancel
|
|
700
|
+
</Button>
|
|
701
|
+
<Button onClick={handleChangePassword}>
|
|
702
|
+
Update Password
|
|
703
|
+
</Button>
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
</Dialog>
|
|
707
|
+
|
|
708
|
+
<ConfirmDialog
|
|
709
|
+
open={revokeSessionConfirmOpen}
|
|
710
|
+
onClose={() => { setRevokeSessionConfirmOpen(false); setSessionToRevoke(null); }}
|
|
711
|
+
onConfirm={handleRevokeSession}
|
|
712
|
+
title="Revoke Session"
|
|
713
|
+
description="Are you sure you want to revoke this session? The device will be signed out immediately."
|
|
714
|
+
confirmLabel="Revoke"
|
|
715
|
+
confirmVariant="danger"
|
|
716
|
+
/>
|
|
717
|
+
|
|
718
|
+
<Dialog
|
|
719
|
+
open={deleteAccountConfirmOpen}
|
|
720
|
+
onClose={() => { setDeleteAccountConfirmOpen(false); setDeleteConfirmText(''); }}
|
|
721
|
+
title="Delete Account"
|
|
722
|
+
>
|
|
723
|
+
<div className="space-y-4">
|
|
724
|
+
<p className="text-sm text-gray-600">
|
|
725
|
+
This will permanently delete your account and all associated data. This action cannot be undone.
|
|
726
|
+
</p>
|
|
727
|
+
<Input
|
|
728
|
+
label={`Type "${APP_NAME}" to confirm`}
|
|
729
|
+
value={deleteConfirmText}
|
|
730
|
+
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
|
731
|
+
placeholder={APP_NAME}
|
|
732
|
+
/>
|
|
733
|
+
<div className="flex justify-end gap-3 pt-2">
|
|
734
|
+
<Button variant="outline" onClick={() => { setDeleteAccountConfirmOpen(false); setDeleteConfirmText(''); }}>
|
|
735
|
+
Cancel
|
|
736
|
+
</Button>
|
|
737
|
+
<Button
|
|
738
|
+
variant="danger"
|
|
739
|
+
disabled={deleteConfirmText !== APP_NAME}
|
|
740
|
+
onClick={handleDeleteAccount}
|
|
741
|
+
>
|
|
742
|
+
Delete Forever
|
|
743
|
+
</Button>
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
</Dialog>
|
|
747
|
+
</div>
|
|
748
|
+
);
|
|
749
|
+
}
|