firebase-os 1.1.3 → 1.1.5
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/dist/FirebaseOS.d.ts +15 -0
- package/dist/firebase-os.cjs.js +2 -17
- package/dist/firebase-os.es.js +63 -72
- package/dist/index.d.ts +3 -0
- package/dist/lib/ConfigContext.d.ts +12 -0
- package/package.json +3 -2
- package/scripts/postinstall.js +89 -10
- package/src/App.css +184 -0
- package/src/App.tsx +214 -0
- package/src/FirebaseOS.tsx +80 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/AdminNotifications.test.tsx +98 -0
- package/src/components/AdminNotifications.tsx +194 -0
- package/src/components/Button.test.tsx +22 -0
- package/src/components/Button.tsx +53 -0
- package/src/components/ConfirmModal.test.tsx +98 -0
- package/src/components/ConfirmModal.tsx +73 -0
- package/src/components/ContactPopup.test.tsx +98 -0
- package/src/components/ContactPopup.tsx +437 -0
- package/src/components/CustomSelect.test.tsx +47 -0
- package/src/components/CustomSelect.tsx +89 -0
- package/src/components/DashboardNav.test.tsx +98 -0
- package/src/components/DashboardNav.tsx +281 -0
- package/src/components/Input.test.tsx +33 -0
- package/src/components/Input.tsx +61 -0
- package/src/components/JsonEditor.tsx +579 -0
- package/src/components/Navbar.test.tsx +98 -0
- package/src/components/Navbar.tsx +563 -0
- package/src/configs/forms/contactForm.config.ts +15 -0
- package/src/configs/forms/index.ts +29 -0
- package/src/configs/forms/pubForm.config.ts +11 -0
- package/src/configs/forms/supportForm.config.ts +14 -0
- package/src/configs/forms/userForm.config.ts +11 -0
- package/src/configs/pages/admin.config.ts +29 -0
- package/src/configs/pages/contact.config.ts +6 -0
- package/src/configs/pages/home.config.ts +18 -0
- package/src/configs/pages/mem.config.ts +2 -0
- package/src/configs/pages/menuOrders.config.ts +11 -0
- package/src/configs/pages/pub.config.ts +11 -0
- package/src/configs/pages/shared.config.ts +29 -0
- package/src/configs/pages/support.config.ts +7 -0
- package/src/configs/pages/tabOrders.config.ts +33 -0
- package/src/configs/pages/user.config.ts +29 -0
- package/src/configs/theme.config.ts +93 -0
- package/src/index.css +403 -0
- package/src/index.ts +22 -0
- package/src/lib/AuthContext.test.tsx +88 -0
- package/src/lib/AuthContext.tsx +191 -0
- package/src/lib/ConfigContext.tsx +45 -0
- package/src/lib/ThemeContext.tsx +227 -0
- package/src/lib/firebase.ts +91 -0
- package/src/main.tsx +22 -0
- package/src/microcomponents/AdminExampleContent.tsx +44 -0
- package/src/microcomponents/PrivateExampleContent.tsx +39 -0
- package/src/microcomponents/Public.tsx +126 -0
- package/src/microcomponents/SharedExampleContent.tsx +53 -0
- package/src/pages/Dashboard.test.tsx +98 -0
- package/src/pages/Dashboard.tsx +60 -0
- package/src/pages/DynamicPage.tsx +237 -0
- package/src/pages/FormsAdmin.test.tsx +98 -0
- package/src/pages/FormsAdmin.tsx +459 -0
- package/src/pages/Home.test.tsx +98 -0
- package/src/pages/Home.tsx +144 -0
- package/src/pages/Login.test.tsx +98 -0
- package/src/pages/Login.tsx +108 -0
- package/src/pages/PagesAdmin.test.tsx +98 -0
- package/src/pages/PagesAdmin.tsx +1022 -0
- package/src/pages/Profile.test.tsx +98 -0
- package/src/pages/Profile.tsx +319 -0
- package/src/pages/Register.test.tsx +98 -0
- package/src/pages/Register.tsx +116 -0
- package/src/pages/Requests.test.tsx +95 -0
- package/src/pages/Requests.tsx +422 -0
- package/src/pages/ResetPassword.test.tsx +98 -0
- package/src/pages/ResetPassword.tsx +92 -0
- package/src/pages/Settings.test.tsx +98 -0
- package/src/pages/Settings.tsx +393 -0
- package/src/pages/Setup.tsx +401 -0
- package/src/pages/StorageAdmin.test.tsx +150 -0
- package/src/pages/StorageAdmin.tsx +769 -0
- package/src/pages/Submissions.test.tsx +95 -0
- package/src/pages/Submissions.tsx +372 -0
- package/src/pages/Templates.test.tsx +98 -0
- package/src/pages/Templates.tsx +103 -0
- package/src/pages/ThemeAdmin.test.tsx +144 -0
- package/src/pages/ThemeAdmin.tsx +1000 -0
- package/src/pages/Users.test.tsx +95 -0
- package/src/pages/Users.tsx +334 -0
- package/src/pages/Verify.test.tsx +98 -0
- package/src/pages/Verify.tsx +95 -0
- package/src/prompts/index.ts +13 -0
- package/src/prompts/pages/publicPage.ts +44 -0
- package/src/prompts/sharedConstants.ts +12 -0
- package/src/prompts/tabs/board/adminboard.ts +32 -0
- package/src/prompts/tabs/board/privateboard.ts +36 -0
- package/src/prompts/tabs/board/publicboard.ts +36 -0
- package/src/prompts/tabs/calendar/admincalendar.ts +32 -0
- package/src/prompts/tabs/calendar/privatecalendar.ts +36 -0
- package/src/prompts/tabs/calendar/publiccalendar.ts +36 -0
- package/src/prompts/tabs/crud/admin.ts +54 -0
- package/src/prompts/tabs/crud/private.ts +55 -0
- package/src/prompts/tabs/crud/shared.ts +53 -0
- package/src/prompts/tabs/table/admintable.ts +32 -0
- package/src/prompts/tabs/table/privatetable.ts +36 -0
- package/src/prompts/tabs/table/publictable.ts +36 -0
- package/src/setupTests.ts +1 -0
- package/src/templates/AdminPageTemplate.tsx +678 -0
- package/src/templates/PrivatePageTemplate.tsx +594 -0
- package/src/templates/PublicPageTemplate.tsx +92 -0
- package/src/templates/SharedPageTemplate.tsx +551 -0
- package/src/templates/TemplateBoard.test.tsx +106 -0
- package/src/templates/TemplateBoard.tsx +642 -0
- package/src/templates/TemplateCalendar.test.tsx +106 -0
- package/src/templates/TemplateCalendar.tsx +848 -0
- package/src/templates/TemplateConfirmation.test.tsx +106 -0
- package/src/templates/TemplateConfirmation.tsx +145 -0
- package/src/templates/TemplateInlineForm.test.tsx +106 -0
- package/src/templates/TemplateInlineForm.tsx +129 -0
- package/src/templates/TemplatePopupForm.test.tsx +106 -0
- package/src/templates/TemplatePopupForm.tsx +174 -0
- package/src/templates/TemplateTable.test.tsx +106 -0
- package/src/templates/TemplateTable.tsx +675 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// @generated-test
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render, screen, act } from '@testing-library/react';
|
|
4
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
5
|
+
import { AuthProvider } from '../lib/AuthContext';
|
|
6
|
+
import { ThemeProvider } from '../lib/ThemeContext';
|
|
7
|
+
import { UsersTab } from './Users';
|
|
8
|
+
|
|
9
|
+
// Global mocks
|
|
10
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
11
|
+
writable: true,
|
|
12
|
+
value: vi.fn().mockImplementation(query => ({
|
|
13
|
+
matches: false,
|
|
14
|
+
media: query,
|
|
15
|
+
onchange: null,
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
addEventListener: vi.fn(),
|
|
19
|
+
removeEventListener: vi.fn(),
|
|
20
|
+
dispatchEvent: vi.fn(),
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock('../lib/AuthContext', () => ({
|
|
25
|
+
AuthProvider: ({ children }: any) => <>{children}</>,
|
|
26
|
+
useAuth: () => ({
|
|
27
|
+
user: { uid: 'mock-user-123', email: 'test@example.com' },
|
|
28
|
+
userWorkspaces: [],
|
|
29
|
+
activeWorkspace: null,
|
|
30
|
+
activeOrg: null,
|
|
31
|
+
loading: false
|
|
32
|
+
})
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../lib/ThemeContext', () => ({
|
|
36
|
+
ThemeProvider: ({ children }: any) => <>{children}</>,
|
|
37
|
+
useTheme: () => ({ themeMode: 'light', setThemeMode: vi.fn(), activeConfig: {} })
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('firebase/auth', () => ({
|
|
41
|
+
getAuth: vi.fn(() => ({})),
|
|
42
|
+
onAuthStateChanged: vi.fn((auth, cb) => { cb({ uid: 'mock-user-123', email: 'test@example.com', getIdToken: vi.fn(() => Promise.resolve('mock-token')) }); return () => {}; })
|
|
43
|
+
}));
|
|
44
|
+
vi.mock('firebase/firestore', () => ({
|
|
45
|
+
getFirestore: vi.fn(() => ({})),
|
|
46
|
+
collection: vi.fn(),
|
|
47
|
+
doc: vi.fn(),
|
|
48
|
+
setDoc: vi.fn(() => Promise.resolve()),
|
|
49
|
+
addDoc: vi.fn(() => Promise.resolve()),
|
|
50
|
+
updateDoc: vi.fn(() => Promise.resolve()),
|
|
51
|
+
deleteDoc: vi.fn(() => Promise.resolve()),
|
|
52
|
+
query: vi.fn(),
|
|
53
|
+
where: vi.fn(),
|
|
54
|
+
orderBy: vi.fn(),
|
|
55
|
+
limit: vi.fn(),
|
|
56
|
+
getDoc: vi.fn(() => Promise.resolve({ exists: () => true, data: () => ({ role: 'super_admin' }) })),
|
|
57
|
+
getDocs: vi.fn(() => Promise.resolve({ docs: [], forEach: vi.fn() })),
|
|
58
|
+
onSnapshot: vi.fn((...args: any[]) => {
|
|
59
|
+
let cb = args[1];
|
|
60
|
+
if (typeof cb === 'function') {
|
|
61
|
+
cb({docs: [], forEach: vi.fn(), data: () => ({}), exists: () => true});
|
|
62
|
+
}
|
|
63
|
+
return () => {};
|
|
64
|
+
})
|
|
65
|
+
}));
|
|
66
|
+
vi.mock('firebase/storage', () => ({
|
|
67
|
+
getStorage: vi.fn(() => ({})),
|
|
68
|
+
ref: vi.fn(),
|
|
69
|
+
listAll: vi.fn(() => Promise.resolve({ items: [], prefixes: [] })),
|
|
70
|
+
getDownloadURL: vi.fn(() => Promise.resolve('mock-url')),
|
|
71
|
+
getMetadata: vi.fn(() => Promise.resolve({ size: 1024, timeCreated: new Date().toISOString() }))
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
describe('UsersTab Component', () => {
|
|
75
|
+
it('renders without crashing', async () => {
|
|
76
|
+
// Wrap in standard application providers inside act to process async side effects and prevent warnings
|
|
77
|
+
await act(async () => {
|
|
78
|
+
render(
|
|
79
|
+
<BrowserRouter>
|
|
80
|
+
<AuthProvider>
|
|
81
|
+
<ThemeProvider>
|
|
82
|
+
{/* @ts-ignore */}
|
|
83
|
+
<UsersTab />
|
|
84
|
+
</ThemeProvider>
|
|
85
|
+
</AuthProvider>
|
|
86
|
+
</BrowserRouter>
|
|
87
|
+
);
|
|
88
|
+
// Wait a tick to flush background state updates
|
|
89
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Check if the document has anything rendered without throwing
|
|
93
|
+
expect(document.body).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { useParams, useNavigate } from 'react-router-dom';
|
|
3
|
+
import { useAuth } from '../lib/AuthContext';
|
|
4
|
+
import { db, storage } from '../lib/firebase';
|
|
5
|
+
import { collection, onSnapshot, doc, updateDoc } from 'firebase/firestore';
|
|
6
|
+
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
|
7
|
+
import { Users as UsersIcon, Loader2, CheckCircle2, User as UserIcon, Camera } from 'lucide-react';
|
|
8
|
+
import { motion, AnimatePresence } from 'framer-motion';
|
|
9
|
+
import { DashboardNav } from '../components/DashboardNav';
|
|
10
|
+
|
|
11
|
+
interface UserProfile {
|
|
12
|
+
id: string;
|
|
13
|
+
email: string;
|
|
14
|
+
displayName?: string;
|
|
15
|
+
role?: string;
|
|
16
|
+
avatar?: string;
|
|
17
|
+
photoURL?: string;
|
|
18
|
+
createdAt?: string;
|
|
19
|
+
inactive?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function UsersTab() {
|
|
23
|
+
const { user, userRole } = useAuth();
|
|
24
|
+
const [loadingMembers, setLoadingMembers] = useState(true);
|
|
25
|
+
const navigate = useNavigate();
|
|
26
|
+
const { id: paramUserId } = useParams();
|
|
27
|
+
|
|
28
|
+
const [members, setMembers] = useState<UserProfile[]>([]);
|
|
29
|
+
const [msg, setMsg] = useState<{ text: string, type: 'success' | 'error' } | null>(null);
|
|
30
|
+
|
|
31
|
+
const [editingName, setEditingName] = useState(false);
|
|
32
|
+
const [tempName, setTempName] = useState('');
|
|
33
|
+
const [photoLoading, setPhotoLoading] = useState(false);
|
|
34
|
+
|
|
35
|
+
const selectedMember = paramUserId ? members.find(m => m.id === paramUserId) || null : (members.length > 0 ? members[0] : null);
|
|
36
|
+
|
|
37
|
+
const setSelectedMember = (member: UserProfile | null) => {
|
|
38
|
+
if (member) navigate(`/users/${member.id}`);
|
|
39
|
+
else navigate(`/users`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
setLoadingMembers(true);
|
|
44
|
+
const unsubscribe = onSnapshot(collection(db, 'user_profiles'), (snapshot) => {
|
|
45
|
+
const fetched: UserProfile[] = [];
|
|
46
|
+
snapshot.forEach(docSnap => {
|
|
47
|
+
fetched.push({ id: docSnap.id, ...docSnap.data() } as UserProfile);
|
|
48
|
+
});
|
|
49
|
+
setMembers(fetched);
|
|
50
|
+
setLoadingMembers(false);
|
|
51
|
+
|
|
52
|
+
if (!paramUserId && fetched.length > 0) {
|
|
53
|
+
navigate(`/users/${fetched[0].id}`, { replace: true });
|
|
54
|
+
}
|
|
55
|
+
}, (error) => {
|
|
56
|
+
console.error("Error fetching users:", error);
|
|
57
|
+
setLoadingMembers(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return () => unsubscribe();
|
|
61
|
+
}, [paramUserId, navigate]);
|
|
62
|
+
|
|
63
|
+
const myProfile = members.find(m => m.id === user?.uid);
|
|
64
|
+
|
|
65
|
+
const canManageTarget = (target: UserProfile) => {
|
|
66
|
+
if (target.id === myProfile?.id) return false;
|
|
67
|
+
if (target.role !== 'admin') return true;
|
|
68
|
+
|
|
69
|
+
// Legacy admins without createdAt are treated as absolute oldest
|
|
70
|
+
if (!myProfile?.createdAt) return true;
|
|
71
|
+
if (!target.createdAt) return false;
|
|
72
|
+
|
|
73
|
+
// Normal comparison: oldest wins
|
|
74
|
+
return new Date(myProfile.createdAt).getTime() <= new Date(target.createdAt).getTime();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handleUpdateRole = async (target: UserProfile, newIsAdmin: boolean) => {
|
|
78
|
+
if (userRole !== 'admin' || !canManageTarget(target)) return;
|
|
79
|
+
try {
|
|
80
|
+
await updateDoc(doc(db, 'user_profiles', target.id), { role: newIsAdmin ? 'admin' : null });
|
|
81
|
+
setMsg({ text: `User role updated successfully`, type: 'success' });
|
|
82
|
+
setTimeout(() => setMsg(null), 3000);
|
|
83
|
+
} catch (err: any) {
|
|
84
|
+
setMsg({ text: err.message, type: 'error' });
|
|
85
|
+
setTimeout(() => setMsg(null), 3000);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleDeactivate = async (target: UserProfile, deactivated: boolean) => {
|
|
90
|
+
if (userRole !== 'admin' || !canManageTarget(target)) return;
|
|
91
|
+
try {
|
|
92
|
+
await updateDoc(doc(db, 'user_profiles', target.id), { inactive: deactivated });
|
|
93
|
+
setMsg({ text: `User ${deactivated ? 'deactivated' : 'activated'} successfully`, type: 'success' });
|
|
94
|
+
setTimeout(() => setMsg(null), 3000);
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
setMsg({ text: err.message, type: 'error' });
|
|
97
|
+
setTimeout(() => setMsg(null), 3000);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleNameSave = async () => {
|
|
102
|
+
setEditingName(false);
|
|
103
|
+
if (!selectedMember || !canManageTarget(selectedMember) || tempName === selectedMember.displayName) return;
|
|
104
|
+
try {
|
|
105
|
+
await updateDoc(doc(db, 'user_profiles', selectedMember.id), { displayName: tempName });
|
|
106
|
+
setMsg({ text: 'Name updated successfully', type: 'success' });
|
|
107
|
+
setTimeout(() => setMsg(null), 3000);
|
|
108
|
+
} catch (err: any) {
|
|
109
|
+
setMsg({ text: err.message, type: 'error' });
|
|
110
|
+
setTimeout(() => setMsg(null), 3000);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
115
|
+
const file = e.target.files?.[0];
|
|
116
|
+
if (!file || !selectedMember || !canManageTarget(selectedMember)) return;
|
|
117
|
+
if (file.size > 2 * 1024 * 1024) {
|
|
118
|
+
setMsg({ text: 'File too large.', type: 'error' }); return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setPhotoLoading(true);
|
|
122
|
+
try {
|
|
123
|
+
const storagePath = `user_avatars/${selectedMember.id}/file.png`;
|
|
124
|
+
const fileRef = ref(storage, storagePath);
|
|
125
|
+
await uploadBytes(fileRef, file);
|
|
126
|
+
const newPhotoURL = await getDownloadURL(fileRef);
|
|
127
|
+
await updateDoc(doc(db, 'user_profiles', selectedMember.id), { avatar: newPhotoURL });
|
|
128
|
+
setMsg({ text: 'Avatar successfully updated.', type: 'success' });
|
|
129
|
+
setTimeout(() => setMsg(null), 3000);
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
setMsg({ text: 'Failed to upload photo.', type: 'error' });
|
|
132
|
+
}
|
|
133
|
+
setPhotoLoading(false);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const [showLoader, setShowLoader] = useState(false);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
let timer: any;
|
|
140
|
+
if (loadingMembers && members.length === 0) {
|
|
141
|
+
timer = setTimeout(() => setShowLoader(true), 150);
|
|
142
|
+
} else {
|
|
143
|
+
setShowLoader(false);
|
|
144
|
+
}
|
|
145
|
+
return () => clearTimeout(timer);
|
|
146
|
+
}, [loadingMembers, members.length]);
|
|
147
|
+
|
|
148
|
+
if (loadingMembers && members.length === 0) {
|
|
149
|
+
if (!showLoader) return null;
|
|
150
|
+
return (
|
|
151
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/60 backdrop-blur-xl transition-all duration-300">
|
|
152
|
+
<Loader2 className="w-8 h-8 animate-spin text-accent" />
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8 z-10 flex flex-col">
|
|
159
|
+
|
|
160
|
+
{/* Header */}
|
|
161
|
+
<motion.div
|
|
162
|
+
initial={{ opacity: 0, y: 20 }}
|
|
163
|
+
animate={{ opacity: 1, y: 0 }}
|
|
164
|
+
className="mb-8"
|
|
165
|
+
>
|
|
166
|
+
<h1 className="w-fit inline-block text-3xl md:text-4xl lg:text-5xl font-extrabold tracking-tight mb-3 text-gradient mt-1">
|
|
167
|
+
Users
|
|
168
|
+
</h1>
|
|
169
|
+
<div className="flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]">
|
|
170
|
+
<span className="w-8 h-[1px] bg-foreground/10" />
|
|
171
|
+
/users
|
|
172
|
+
</div>
|
|
173
|
+
</motion.div>
|
|
174
|
+
|
|
175
|
+
<div className="flex flex-col gap-6 animate-in fade-in duration-500 pb-20">
|
|
176
|
+
|
|
177
|
+
<AnimatePresence>
|
|
178
|
+
{msg && (
|
|
179
|
+
<motion.div
|
|
180
|
+
initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
|
|
181
|
+
className={`p-4 text-[14px] font-medium border ${msg.type === 'error' ? 'text-red-500 bg-red-500/10 border-red-500/20 shadow-[0_0_15px_rgba(239,68,68,0.1)]' : 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20 shadow-[0_0_15px_rgba(52,211,153,0.1)]'} rounded-xl flex items-center gap-2`}
|
|
182
|
+
>
|
|
183
|
+
{msg.type === 'success' && <CheckCircle2 className="w-4 h-4 shrink-0" />}
|
|
184
|
+
{msg.text}
|
|
185
|
+
</motion.div>
|
|
186
|
+
)}
|
|
187
|
+
</AnimatePresence>
|
|
188
|
+
|
|
189
|
+
<div className="flex justify-start">
|
|
190
|
+
<DashboardNav />
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div className="glass-panel rounded-3xl border border-[var(--panel-border)] shadow-sm">
|
|
194
|
+
{members.length === 0 ? (
|
|
195
|
+
<div className="p-8 text-center text-foreground/50 text-[14px]">
|
|
196
|
+
No members found.
|
|
197
|
+
</div>
|
|
198
|
+
) : (
|
|
199
|
+
<div className="grid grid-cols-1 md:grid-cols-3 min-h-[500px]">
|
|
200
|
+
{/* Left Box: List */}
|
|
201
|
+
<div className="md:col-span-1 border-r md:border-r-[var(--panel-border)] border-b md:border-b-0 border-[var(--panel-border)] flex flex-col hide-scrollbar bg-black/5 dark:bg-white/5 rounded-t-3xl md:rounded-tr-none md:rounded-l-3xl">
|
|
202
|
+
<div className="p-4 border-b border-[var(--panel-border)] font-bold text-[13px] text-foreground/70 uppercase tracking-wider">
|
|
203
|
+
System Users
|
|
204
|
+
</div>
|
|
205
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
|
206
|
+
{members.map(member => (
|
|
207
|
+
<div
|
|
208
|
+
key={member.id}
|
|
209
|
+
onClick={() => setSelectedMember(member)}
|
|
210
|
+
className={`p-4 py-5 rounded-2xl border transition-all flex items-center gap-4 cursor-pointer ${selectedMember?.id === member.id ? 'bg-accent/10 border-accent/40 shadow-sm' : 'glass-panel border-transparent hover:border-[var(--panel-border)]'
|
|
211
|
+
} ${member.inactive ? 'opacity-50 grayscale' : ''}`}
|
|
212
|
+
>
|
|
213
|
+
<div className="w-12 h-12 rounded-full overflow-hidden bg-accent/20 shrink-0 flex items-center justify-center border border-[var(--panel-border)]">
|
|
214
|
+
{(member.avatar || member.photoURL) ? (
|
|
215
|
+
<img src={member.avatar || member.photoURL} alt="" className="w-full h-full object-cover" />
|
|
216
|
+
) : (
|
|
217
|
+
<UserIcon className="w-5 h-5 text-accent" />
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
<div className="flex flex-col truncate">
|
|
221
|
+
<span className="font-bold text-[14px] truncate">{member.displayName || 'No Name'}</span>
|
|
222
|
+
<span className="text-[11px] text-foreground/50 truncate font-mono mt-0.5">{member.email}</span>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* Right Box: Preview */}
|
|
230
|
+
<div className="md:col-span-2 flex flex-col p-6 md:p-10 relative">
|
|
231
|
+
{selectedMember ? (
|
|
232
|
+
<div className="animate-in fade-in duration-300 w-full max-w-lg">
|
|
233
|
+
|
|
234
|
+
<div className="flex flex-col md:flex-row gap-6 md:gap-8 items-start mb-10">
|
|
235
|
+
<div className="w-24 h-24 rounded-full glass-panel border-4 border-[var(--panel-border)] shadow-xl bg-accent/10 flex items-center justify-center relative overflow-hidden shrink-0 group">
|
|
236
|
+
{photoLoading ? (
|
|
237
|
+
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
|
238
|
+
) : (selectedMember.avatar || selectedMember.photoURL) ? (
|
|
239
|
+
<img src={selectedMember.avatar || selectedMember.photoURL} alt="Avatar" className="w-full h-full object-cover" />
|
|
240
|
+
) : (
|
|
241
|
+
<UserIcon className="w-10 h-10 text-accent" />
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{!photoLoading && canManageTarget(selectedMember) && (
|
|
245
|
+
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center z-10 backdrop-blur-md">
|
|
246
|
+
<label className="flex flex-col items-center justify-center cursor-pointer p-2 hover:bg-white/20 transition-colors text-white rounded-full" title="Upload Photo">
|
|
247
|
+
<Camera className="w-5 h-5" />
|
|
248
|
+
<input type="file" accept="image/*" onChange={handlePhotoUpload} className="hidden" />
|
|
249
|
+
</label>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<div className="flex flex-col mt-2 flex-1 w-full relative">
|
|
255
|
+
{editingName ? (
|
|
256
|
+
<input
|
|
257
|
+
autoFocus
|
|
258
|
+
value={tempName}
|
|
259
|
+
onChange={e => setTempName(e.target.value)}
|
|
260
|
+
onBlur={handleNameSave}
|
|
261
|
+
onKeyDown={e => e.key === 'Enter' && handleNameSave()}
|
|
262
|
+
className="text-2xl md:text-3xl font-extrabold mb-1 bg-transparent border-b-2 border-accent outline-none text-foreground w-full py-1"
|
|
263
|
+
/>
|
|
264
|
+
) : (
|
|
265
|
+
<h3
|
|
266
|
+
onDoubleClick={() => {
|
|
267
|
+
if (canManageTarget(selectedMember)) {
|
|
268
|
+
setTempName(selectedMember.displayName || '');
|
|
269
|
+
setEditingName(true);
|
|
270
|
+
}
|
|
271
|
+
}}
|
|
272
|
+
className={`text-2xl md:text-3xl font-extrabold mb-1 select-none ${canManageTarget(selectedMember) ? 'cursor-pointer hover:text-accent transition-colors' : ''}`}
|
|
273
|
+
title={canManageTarget(selectedMember) ? "Double-click to edit" : ""}
|
|
274
|
+
>
|
|
275
|
+
{selectedMember.displayName || 'No Name'}
|
|
276
|
+
</h3>
|
|
277
|
+
)}
|
|
278
|
+
<p className="text-[14px] font-mono text-foreground/50">{selectedMember.email}</p>
|
|
279
|
+
|
|
280
|
+
{!canManageTarget(selectedMember) && selectedMember.id !== myProfile?.id && (
|
|
281
|
+
<div className="mt-3 inline-block self-start px-3 py-1.5 rounded-lg bg-foreground/5 border border-[var(--panel-border)] text-[11px] font-bold text-foreground/50 tracking-wide uppercase">
|
|
282
|
+
Privileged Admin Account
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<div className="flex flex-col gap-3">
|
|
289
|
+
<div className="p-4 rounded-2xl glass-panel border border-[var(--panel-border)] flex items-center justify-between">
|
|
290
|
+
<div className="flex flex-col">
|
|
291
|
+
<span className="font-bold text-[14px] text-foreground">Admin Access</span>
|
|
292
|
+
<span className="text-[12px] text-foreground/50">Toggle system privileges</span>
|
|
293
|
+
</div>
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
disabled={!canManageTarget(selectedMember)}
|
|
297
|
+
onClick={() => handleUpdateRole(selectedMember, selectedMember.role !== 'admin')}
|
|
298
|
+
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors focus:outline-none shadow-inner ${selectedMember.role === 'admin' ? 'bg-accent' : 'bg-foreground/20'} ${!canManageTarget(selectedMember) ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
299
|
+
>
|
|
300
|
+
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform shadow-md ${selectedMember.role === 'admin' ? 'translate-x-6' : 'translate-x-1'}`} />
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<div className={`p-4 rounded-2xl glass-panel border flex items-center justify-between ${selectedMember.inactive ? 'border-amber-500/30 bg-amber-500/5' : 'border-red-500/20 bg-red-500/5'}`}>
|
|
305
|
+
<div className="flex flex-col">
|
|
306
|
+
<span className={`font-bold text-[14px] ${selectedMember.inactive ? 'text-amber-500' : 'text-red-500'}`}>{selectedMember.inactive ? 'Reactivate User' : 'Deactivate User'}</span>
|
|
307
|
+
<span className={`text-[12px] ${selectedMember.inactive ? 'text-amber-500/70' : 'text-red-500/70'}`}>Block access to this app</span>
|
|
308
|
+
</div>
|
|
309
|
+
<button
|
|
310
|
+
type="button"
|
|
311
|
+
disabled={!canManageTarget(selectedMember)}
|
|
312
|
+
onClick={() => handleDeactivate(selectedMember, !selectedMember.inactive)}
|
|
313
|
+
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors focus:outline-none shadow-inner ${selectedMember.inactive ? 'bg-amber-500' : 'bg-red-500/20'} ${!canManageTarget(selectedMember) ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
|
314
|
+
>
|
|
315
|
+
<span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform shadow-md ${selectedMember.inactive ? 'translate-x-6' : 'translate-x-1'}`} />
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
</div>
|
|
321
|
+
) : (
|
|
322
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center opacity-30 pointer-events-none">
|
|
323
|
+
<UsersIcon className="w-12 h-12 mb-4" />
|
|
324
|
+
<p className="font-bold tracking-widest uppercase text-[12px]">Select a User</p>
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
</main>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @generated-test
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { render, screen, act } from '@testing-library/react';
|
|
4
|
+
import { BrowserRouter } from 'react-router-dom';
|
|
5
|
+
import { AuthProvider } from '../lib/AuthContext';
|
|
6
|
+
import { ThemeProvider } from '../lib/ThemeContext';
|
|
7
|
+
import { Verify } from './Verify';
|
|
8
|
+
|
|
9
|
+
// Global mocks
|
|
10
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
11
|
+
writable: true,
|
|
12
|
+
value: vi.fn().mockImplementation(query => ({
|
|
13
|
+
matches: false,
|
|
14
|
+
media: query,
|
|
15
|
+
onchange: null,
|
|
16
|
+
addListener: vi.fn(),
|
|
17
|
+
removeListener: vi.fn(),
|
|
18
|
+
addEventListener: vi.fn(),
|
|
19
|
+
removeEventListener: vi.fn(),
|
|
20
|
+
dispatchEvent: vi.fn(),
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock('../lib/AuthContext', () => ({
|
|
25
|
+
AuthProvider: ({ children }: any) => <>{children}</>,
|
|
26
|
+
useAuth: () => ({
|
|
27
|
+
user: { uid: 'mock-user-123', email: 'test@example.com' },
|
|
28
|
+
userWorkspaces: [],
|
|
29
|
+
activeWorkspace: null,
|
|
30
|
+
activeOrg: null,
|
|
31
|
+
loading: false
|
|
32
|
+
})
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../lib/ThemeContext', () => ({
|
|
36
|
+
ThemeProvider: ({ children }: any) => <>{children}</>,
|
|
37
|
+
useTheme: () => ({ themeMode: 'light', setThemeMode: vi.fn(), activeConfig: {} })
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('firebase/auth', () => ({
|
|
41
|
+
getAuth: vi.fn(() => ({})),
|
|
42
|
+
onAuthStateChanged: vi.fn((auth, cb) => { cb({ uid: 'mock-user-123', email: 'test@example.com', getIdToken: vi.fn(() => Promise.resolve('mock-token')) }); return () => {}; })
|
|
43
|
+
}));
|
|
44
|
+
vi.mock('firebase/firestore', () => ({
|
|
45
|
+
getFirestore: vi.fn(() => ({})),
|
|
46
|
+
collection: vi.fn(),
|
|
47
|
+
doc: vi.fn(),
|
|
48
|
+
setDoc: vi.fn(() => Promise.resolve()),
|
|
49
|
+
addDoc: vi.fn(() => Promise.resolve()),
|
|
50
|
+
updateDoc: vi.fn(() => Promise.resolve()),
|
|
51
|
+
deleteDoc: vi.fn(() => Promise.resolve()),
|
|
52
|
+
query: vi.fn(),
|
|
53
|
+
where: vi.fn(),
|
|
54
|
+
orderBy: vi.fn(),
|
|
55
|
+
limit: vi.fn(),
|
|
56
|
+
getDoc: vi.fn(() => Promise.resolve({ exists: () => true, data: () => ({ role: 'super_admin' }) })),
|
|
57
|
+
getDocs: vi.fn(() => Promise.resolve({ docs: [], forEach: vi.fn() })),
|
|
58
|
+
onSnapshot: vi.fn((...args: any[]) => {
|
|
59
|
+
let cb = args[1];
|
|
60
|
+
if (typeof args[2] === 'function') {
|
|
61
|
+
cb = args[2];
|
|
62
|
+
}
|
|
63
|
+
if (typeof cb === 'function') {
|
|
64
|
+
cb({docs: [], forEach: vi.fn(), data: () => ({}), exists: () => true});
|
|
65
|
+
}
|
|
66
|
+
return () => {};
|
|
67
|
+
})
|
|
68
|
+
}));
|
|
69
|
+
vi.mock('firebase/storage', () => ({
|
|
70
|
+
getStorage: vi.fn(() => ({})),
|
|
71
|
+
ref: vi.fn(),
|
|
72
|
+
listAll: vi.fn(() => Promise.resolve({ items: [], prefixes: [] })),
|
|
73
|
+
getDownloadURL: vi.fn(() => Promise.resolve('mock-url')),
|
|
74
|
+
getMetadata: vi.fn(() => Promise.resolve({ size: 1024, timeCreated: new Date().toISOString() }))
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
describe('Verify Component', () => {
|
|
78
|
+
it('renders without crashing', async () => {
|
|
79
|
+
// Wrap in standard application providers inside act to process async side effects and prevent warnings
|
|
80
|
+
await act(async () => {
|
|
81
|
+
render(
|
|
82
|
+
<BrowserRouter>
|
|
83
|
+
<AuthProvider>
|
|
84
|
+
<ThemeProvider>
|
|
85
|
+
{/* @ts-ignore */}
|
|
86
|
+
<Verify />
|
|
87
|
+
</ThemeProvider>
|
|
88
|
+
</AuthProvider>
|
|
89
|
+
</BrowserRouter>
|
|
90
|
+
);
|
|
91
|
+
// Wait a tick to flush background state updates
|
|
92
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Check if the document has anything rendered without throwing
|
|
96
|
+
expect(document.body).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { Mail } from 'lucide-react';
|
|
4
|
+
import { useAuth } from '../lib/AuthContext';
|
|
5
|
+
import { Button } from '../components/Button';
|
|
6
|
+
import { sendEmailVerification } from 'firebase/auth';
|
|
7
|
+
import { auth } from '../lib/firebase';
|
|
8
|
+
import { useNavigate } from 'react-router-dom';
|
|
9
|
+
|
|
10
|
+
export function Verify() {
|
|
11
|
+
const { user, refreshUser } = useAuth();
|
|
12
|
+
const navigate = useNavigate();
|
|
13
|
+
const [verifying, setVerifying] = useState(false);
|
|
14
|
+
const [resending, setResending] = useState(false);
|
|
15
|
+
const [msg, setMsg] = useState('');
|
|
16
|
+
|
|
17
|
+
const handleVerify = async () => {
|
|
18
|
+
setVerifying(true);
|
|
19
|
+
setMsg('');
|
|
20
|
+
try {
|
|
21
|
+
if (auth.currentUser) {
|
|
22
|
+
await auth.currentUser.reload();
|
|
23
|
+
if (!auth.currentUser.emailVerified) {
|
|
24
|
+
setMsg('Email is not verified yet. Please check your inbox.');
|
|
25
|
+
setVerifying(false);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// Force refresh JWT so the rest of the app sees the verified state
|
|
29
|
+
await auth.currentUser.getIdToken(true);
|
|
30
|
+
await refreshUser();
|
|
31
|
+
navigate('/dashboard');
|
|
32
|
+
}
|
|
33
|
+
} catch (e) {
|
|
34
|
+
setMsg('Something went wrong. Please try again.');
|
|
35
|
+
setVerifying(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const handleResend = async () => {
|
|
40
|
+
if (user) {
|
|
41
|
+
setResending(true);
|
|
42
|
+
setMsg('');
|
|
43
|
+
try {
|
|
44
|
+
await sendEmailVerification(user);
|
|
45
|
+
setMsg('Verification email sent! Please check your inbox.');
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
if (err?.code === 'auth/too-many-requests') {
|
|
48
|
+
setMsg('Failed to resend: Please wait a minute before trying again.');
|
|
49
|
+
} else {
|
|
50
|
+
setMsg(err?.message || 'Failed to resend. Please try again later.');
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
setResending(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="flex-1 flex items-center justify-center w-full z-10 px-4 mb-16">
|
|
60
|
+
<motion.div
|
|
61
|
+
initial={{ opacity: 0, y: 10 }}
|
|
62
|
+
animate={{ opacity: 1, y: 0 }}
|
|
63
|
+
transition={{ duration: 0.5 }}
|
|
64
|
+
className="w-full max-w-[480px] p-6 md:p-8 lg:p-10 rounded-3xl glass-panel relative glow-hover text-center shadow-[0_8px_32px_rgba(0,0,0,0.08)]"
|
|
65
|
+
>
|
|
66
|
+
<div className="absolute -inset-[1px] bg-gradient-to-br from-[var(--primary-glow)] to-transparent opacity-30 blur-[20px] rounded-3xl -z-10" />
|
|
67
|
+
|
|
68
|
+
<div className="w-14 h-14 md:w-16 md:h-16 rounded-2xl glass-panel border border-[var(--panel-border)] flex items-center justify-center mx-auto mb-5 md:mb-6 animate-float shadow-xl">
|
|
69
|
+
<Mail className="w-7 h-7 md:w-8 md:h-8 text-accent opacity-90" />
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<h1 className="text-2xl md:text-3xl font-extrabold mb-3 text-foreground tracking-tight">Verify your email</h1>
|
|
73
|
+
<p className="text-[14px] md:text-[15px] text-foreground/60 mb-6 md:mb-8 leading-[1.6] tracking-wide">
|
|
74
|
+
We've sent a verification link to <span className="font-semibold text-foreground">{user?.email}</span>.
|
|
75
|
+
Please check your inbox (and spam folder) to verify your account and access your dashboard.
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
{msg && (
|
|
79
|
+
<div className={`p-3 md:p-4 mb-5 md:mb-6 text-[13px] md:text-[14px] font-medium rounded-xl border ${msg.includes('Failed') || msg.includes('not verified') || msg.includes('wrong') ? 'text-red-400 bg-red-500/10 border-red-500/20' : 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'}`}>
|
|
80
|
+
{msg}
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<div className="flex flex-col gap-3 max-w-[360px] mx-auto">
|
|
85
|
+
<Button variant="primary" onClick={handleVerify} isLoading={verifying} className="w-full py-2.5 md:py-3 text-[14px] font-bold tracking-wide rounded-xl transition-all hover:-translate-y-0.5">
|
|
86
|
+
I've verified my email
|
|
87
|
+
</Button>
|
|
88
|
+
<Button variant="ghost" onClick={handleResend} isLoading={resending} className="w-full py-2.5 md:py-3 text-[13px] md:text-[14px] font-semibold tracking-wide rounded-xl transition-all hover:-translate-y-0.5">
|
|
89
|
+
Resend verification link
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
</motion.div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './pages/publicPage';
|
|
2
|
+
export * from './tabs/calendar/privatecalendar';
|
|
3
|
+
export * from './tabs/calendar/admincalendar';
|
|
4
|
+
export * from './tabs/calendar/publiccalendar';
|
|
5
|
+
export * from './tabs/board/privateboard';
|
|
6
|
+
export * from './tabs/board/adminboard';
|
|
7
|
+
export * from './tabs/board/publicboard';
|
|
8
|
+
export * from './tabs/table/privatetable';
|
|
9
|
+
export * from './tabs/table/admintable';
|
|
10
|
+
export * from './tabs/table/publictable';
|
|
11
|
+
export * from './tabs/crud/shared';
|
|
12
|
+
export * from './tabs/crud/admin';
|
|
13
|
+
export * from './tabs/crud/private';
|