Compare commits
3 Commits
892cf1a040
...
67636120d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 67636120d6 | |||
| 4909815807 | |||
| a87b94219d |
Binary file not shown.
62
apps/admin/scripts/migrate-roles.js
Normal file
62
apps/admin/scripts/migrate-roles.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '../data/fitai.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
function migrateRoles() {
|
||||||
|
try {
|
||||||
|
console.log('Starting migration to update role constraints...');
|
||||||
|
|
||||||
|
// 1. Disable foreign keys
|
||||||
|
db.pragma('foreign_keys = OFF');
|
||||||
|
|
||||||
|
// 2. Start transaction
|
||||||
|
db.transaction(() => {
|
||||||
|
// 3. Create new table with updated check constraint
|
||||||
|
console.log('Creating new users table...');
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
firstName TEXT NOT NULL,
|
||||||
|
lastName TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
phone TEXT,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
updatedAt DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
// 4. Copy data
|
||||||
|
console.log('Copying data...');
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO users_new (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
|
||||||
|
SELECT id, email, firstName, lastName, password, phone, role, createdAt, updatedAt FROM users
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
// 5. Drop old table
|
||||||
|
console.log('Dropping old table...');
|
||||||
|
db.prepare('DROP TABLE users').run();
|
||||||
|
|
||||||
|
// 6. Rename new table
|
||||||
|
console.log('Renaming new table...');
|
||||||
|
db.prepare('ALTER TABLE users_new RENAME TO users').run();
|
||||||
|
|
||||||
|
// 7. Re-enable foreign keys (in a separate step usually, but good to be safe)
|
||||||
|
})();
|
||||||
|
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
console.log('Migration completed successfully.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateRoles();
|
||||||
51
apps/admin/scripts/seed-superadmin.js
Normal file
51
apps/admin/scripts/seed-superadmin.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '../data/fitai.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
async function seedSuperAdmin() {
|
||||||
|
const email = 'taratur@gmail.com';
|
||||||
|
const password = 'password123';
|
||||||
|
const firstName = 'Super';
|
||||||
|
const lastName = 'Admin';
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
const id = 'user_superadmin_' + Math.random().toString(36).substr(2, 9);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Creating Super Admin...');
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
const existing = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||||
|
if (existing) {
|
||||||
|
console.log('Super Admin already exists. Updating role...');
|
||||||
|
db.prepare('UPDATE users SET role = "superAdmin" WHERE email = ?').run(email);
|
||||||
|
console.log('Role updated.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO users (id, email, firstName, lastName, password, role, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(id, email, firstName, lastName, hashedPassword, 'superAdmin', now, now);
|
||||||
|
|
||||||
|
console.log(`Super Admin created successfully.`);
|
||||||
|
console.log(`Email: ${email}`);
|
||||||
|
console.log(`Password: ${password}`);
|
||||||
|
console.log(`ID: ${id}`);
|
||||||
|
|
||||||
|
console.log('\nIMPORTANT: You must also create this user in Clerk manually or sign up with this email to link the accounts if you want to log in as this user.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating Super Admin:', error);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedSuperAdmin();
|
||||||
@ -10,7 +10,7 @@ export async function GET(req: Request) {
|
|||||||
const db = await getDatabase()
|
const db = await getDatabase()
|
||||||
const user = await db.getUserById(userId)
|
const user = await db.getUserById(userId)
|
||||||
|
|
||||||
if (!user || user.role !== 'admin') {
|
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
|
||||||
return new NextResponse('Forbidden', { status: 403 })
|
return new NextResponse('Forbidden', { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,12 @@ export async function GET() {
|
|||||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
|
||||||
const db = await getDatabase()
|
const db = await getDatabase()
|
||||||
|
const user = await db.getUserById(userId)
|
||||||
|
|
||||||
|
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
|
||||||
|
return new NextResponse('Forbidden', { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const stats = await db.getDashboardStats()
|
const stats = await db.getDashboardStats()
|
||||||
|
|
||||||
return NextResponse.json(stats)
|
return NextResponse.json(stats)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getDatabase } from "../../../lib/database/index";
|
import { getDatabase } from "../../../lib/database/index";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { auth } from "@clerk/nextjs/server";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@ -34,7 +35,34 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const { userId: clerkUserId } = await auth();
|
||||||
|
if (!clerkUserId) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
|
|
||||||
|
// Get current user to check role
|
||||||
|
// Note: In a real app, we'd map Clerk ID to our DB ID.
|
||||||
|
// For now, we'll assume we can find the user by some means or trust the Clerk metadata if we synced it.
|
||||||
|
// Since we don't have Clerk ID in our local DB users table yet (we only have our own ID),
|
||||||
|
// we might need to rely on the user being synced.
|
||||||
|
// Let's assume the user calling this API is already in our DB.
|
||||||
|
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
|
||||||
|
// OR we'll assume the first user is Super Admin if no users exist?
|
||||||
|
// Actually, we should look up the user by email if we can't by ID, or add a clerkId column.
|
||||||
|
// For this step, let's assume we can get the user.
|
||||||
|
|
||||||
|
// WAIT: The current `users` table has `id` as a string. Is it the Clerk ID?
|
||||||
|
// In `sync-user.ts`, we use `evt.data.id` as the `id` when creating the user.
|
||||||
|
// So yes, `users.id` IS the Clerk ID.
|
||||||
|
|
||||||
|
const currentUser = await db.getUserById(clerkUserId);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return NextResponse.json({ error: "Current user not found in database" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { email, password, firstName, lastName, role, phone } = body;
|
const { email, password, firstName, lastName, role, phone } = body;
|
||||||
|
|
||||||
@ -45,6 +73,22 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce Hierarchy
|
||||||
|
const allowed = {
|
||||||
|
superAdmin: ["admin", "trainer", "client"], // Super Admin can create anyone (except maybe another superAdmin via this UI?)
|
||||||
|
admin: ["trainer", "client"],
|
||||||
|
trainer: ["client"],
|
||||||
|
client: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const userRole = currentUser.role as keyof typeof allowed;
|
||||||
|
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `You are not authorized to create a ${role}` },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if user already exists
|
||||||
const existingUser = await db.getUserByEmail(email);
|
const existingUser = await db.getUserByEmail(email);
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
@ -58,7 +102,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const userId = await db.createUser({
|
const newUserId = await db.createUser({
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
firstName,
|
firstName,
|
||||||
@ -67,7 +111,17 @@ export async function POST(request: NextRequest) {
|
|||||||
phone,
|
phone,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ userId }, { status: 201 });
|
// If creating a client, create the client record too
|
||||||
|
if (role === 'client') {
|
||||||
|
await db.createClient({
|
||||||
|
userId: newUserId.id,
|
||||||
|
membershipType: 'basic',
|
||||||
|
membershipStatus: 'active',
|
||||||
|
joinDate: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ userId: newUserId.id }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create user error:", error);
|
console.error("Create user error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -69,8 +69,8 @@ export function UserGrid({
|
|||||||
filter: "agTextColumnFilter",
|
filter: "agTextColumnFilter",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cellRenderer: (params: any) => {
|
cellRenderer: (params: any) => {
|
||||||
if (!params.value) return null;
|
|
||||||
const roleColors = {
|
const roleColors = {
|
||||||
|
superAdmin: "bg-red-100 text-red-800",
|
||||||
admin: "bg-purple-100 text-purple-800",
|
admin: "bg-purple-100 text-purple-800",
|
||||||
trainer: "bg-blue-100 text-blue-800",
|
trainer: "bg-blue-100 text-blue-800",
|
||||||
client: "bg-green-100 text-green-800",
|
client: "bg-green-100 text-green-800",
|
||||||
@ -79,7 +79,7 @@ export function UserGrid({
|
|||||||
roleColors[params.value as keyof typeof roleColors] ||
|
roleColors[params.value as keyof typeof roleColors] ||
|
||||||
"bg-gray-100 text-gray-800";
|
"bg-gray-100 text-gray-800";
|
||||||
|
|
||||||
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
const label = params.value === 'superAdmin' ? 'Super Admin' : params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
|
|||||||
@ -221,6 +221,12 @@ export function UserManagement() {
|
|||||||
>
|
>
|
||||||
Admins
|
Admins
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={filter === "superAdmin" ? "primary" : "secondary"}
|
||||||
|
onClick={() => setFilter("superAdmin")}
|
||||||
|
>
|
||||||
|
Super Admins
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -316,10 +322,17 @@ export function UserManagement() {
|
|||||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
{/* Ideally we fetch current user role to filter these.
|
||||||
|
For now, we show all but the API will enforce it.
|
||||||
|
We can add a visual indicator or fetch "me" to filter. */}
|
||||||
<option value="client">Client</option>
|
<option value="client">Client</option>
|
||||||
<option value="trainer">Trainer</option>
|
<option value="trainer">Trainer</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
|
<option value="superAdmin">Super Admin</option>
|
||||||
</select>
|
</select>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Note: You can only assign roles lower than your own.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium mb-1">Phone</label>
|
<label className="block text-sm font-medium mb-1">Phone</label>
|
||||||
@ -436,8 +449,8 @@ export function UserManagement() {
|
|||||||
<span className="font-medium">Last Visit:</span>{" "}
|
<span className="font-medium">Last Visit:</span>{" "}
|
||||||
{selectedUser.client.lastVisit
|
{selectedUser.client.lastVisit
|
||||||
? new Date(
|
? new Date(
|
||||||
selectedUser.client.lastVisit,
|
selectedUser.client.lastVisit,
|
||||||
).toLocaleDateString()
|
).toLocaleDateString()
|
||||||
: "Never"}
|
: "Never"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -60,7 +60,7 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
lastName TEXT NOT NULL,
|
lastName TEXT NOT NULL,
|
||||||
password TEXT NOT NULL,
|
password TEXT NOT NULL,
|
||||||
phone TEXT,
|
phone TEXT,
|
||||||
role TEXT NOT NULL CHECK (role IN ('admin', 'client')),
|
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
|
||||||
createdAt DATETIME NOT NULL,
|
createdAt DATETIME NOT NULL,
|
||||||
updatedAt DATETIME NOT NULL
|
updatedAt DATETIME NOT NULL
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export interface User {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
password: string;
|
password: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
role: "admin" | "trainer" | "client";
|
role: "superAdmin" | "admin" | "trainer" | "client";
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,8 @@ export default function HomeScreen() {
|
|||||||
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<View style={styles.actionButton}>
|
<View style={styles.actionButton}>
|
||||||
<View style={styles.actionIcon}>
|
<View style={styles.actionIcon}>
|
||||||
<Ionicons name="log-in-outline" size={24} color="#2563eb" />
|
<Ionicons name="log-in-outline" size={24} color="#2563eb" />
|
||||||
@ -88,6 +90,7 @@ export default function HomeScreen() {
|
|||||||
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
<Ionicons name="chevron-forward" size={20} color="#9ca3af" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
||||||
<View style={styles.actionButton}>
|
<View style={styles.actionButton}>
|
||||||
<View style={styles.actionIcon}>
|
<View style={styles.actionIcon}>
|
||||||
<Ionicons name="calendar-outline" size={24} color="#10b981" />
|
<Ionicons name="calendar-outline" size={24} color="#10b981" />
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { useAuth } from "@clerk/clerk-expo";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Input } from "../components/Input";
|
import { Input } from "../components/Input";
|
||||||
import { Picker } from "../components/Picker";
|
import { Picker } from "../components/Picker";
|
||||||
|
import { API_BASE_URL } from "../config/api";
|
||||||
|
|
||||||
interface FitnessProfileData {
|
interface FitnessProfileData {
|
||||||
height?: number;
|
height?: number;
|
||||||
@ -64,7 +65,7 @@ export default function FitnessProfileScreen() {
|
|||||||
try {
|
try {
|
||||||
setFetchingProfile(true);
|
setFetchingProfile(true);
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const apiUrl = process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000";
|
const apiUrl = `${API_BASE_URL}` || "http://localhost:3000";
|
||||||
const response = await fetch(`${apiUrl}/api/fitness-profile`, {
|
const response = await fetch(`${apiUrl}/api/fitness-profile`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
@ -98,9 +99,9 @@ export default function FitnessProfileScreen() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const token = await getToken();
|
const token = await getToken();
|
||||||
const apiUrl = process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000";
|
const apiUrl = `${API_BASE_URL}/api/fitness-profile` || "http://localhost:3000";
|
||||||
|
|
||||||
const response = await fetch(`${apiUrl}/api/fitness-profile`, {
|
const response = await fetch(`${apiUrl}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export const users = sqliteTable("users", {
|
|||||||
firstName: text("first_name").notNull(),
|
firstName: text("first_name").notNull(),
|
||||||
lastName: text("last_name").notNull(),
|
lastName: text("last_name").notNull(),
|
||||||
password: text("password"), // Optional - Clerk handles authentication
|
password: text("password"), // Optional - Clerk handles authentication
|
||||||
role: text("role", { enum: ["admin", "trainer", "client"] })
|
role: text("role", { enum: ["superAdmin", "admin", "trainer", "client"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("client"),
|
.default("client"),
|
||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user