Update environment configuration and enhance user management features
- Changed VITE_SUPABASE_URL in .env.example for deployment consistency. - Added new user management functionality to reset user passwords in UserManagement component. - Updated supabase.ts to include first and last name fields in user profiles and added password reset functionality. - Enhanced DashboardLayout to include a user profile view and improved user display in TopNavbar. - Updated seed.sql to create additional users with roles for testing purposes.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# Web environment (Vite)
|
# Web environment (Vite)
|
||||||
VITE_SUPABASE_URL=http://localhost:54321
|
VITE_SUPABASE_URL=http://exp-dash:54321
|
||||||
VITE_SUPABASE_ANON_KEY=[REDACTED]
|
VITE_SUPABASE_ANON_KEY=[REDACTED]
|
||||||
|
|
||||||
# API config (optional)
|
# API config (optional)
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,3 +31,4 @@ camera-management-api/usda_vision_system.log
|
|||||||
|
|
||||||
camera-management-api/camera_sdk/
|
camera-management-api/camera_sdk/
|
||||||
camera-management-api/core
|
camera-management-api/core
|
||||||
|
management-dashboard-web-app/users.txt
|
||||||
|
|||||||
@@ -474,11 +474,11 @@ const apiConfig = {
|
|||||||
### Hostname Setup Guide
|
### Hostname Setup Guide
|
||||||
```bash
|
```bash
|
||||||
# Option 1: Add to /etc/hosts (Linux/Mac)
|
# Option 1: Add to /etc/hosts (Linux/Mac)
|
||||||
echo "127.0.0.1 vision" | sudo tee -a /etc/hosts
|
echo "exp-dash vision" | sudo tee -a /etc/hosts
|
||||||
|
|
||||||
# Option 2: Add to hosts file (Windows)
|
# Option 2: Add to hosts file (Windows)
|
||||||
# Add to C:\Windows\System32\drivers\etc\hosts:
|
# Add to C:\Windows\System32\drivers\etc\hosts:
|
||||||
# 127.0.0.1 vision
|
# exp-dash vision
|
||||||
|
|
||||||
# Option 3: Configure DNS
|
# Option 3: Configure DNS
|
||||||
# Point 'vision' hostname to your server's IP address
|
# Point 'vision' hostname to your server's IP address
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
#
|
#
|
||||||
# HOSTNAME SETUP:
|
# HOSTNAME SETUP:
|
||||||
# To use 'vision' hostname instead of 'localhost':
|
# To use 'vision' hostname instead of 'localhost':
|
||||||
# 1. Add to /etc/hosts: 127.0.0.1 vision
|
# 1. Add to /etc/hosts: exp-dash vision
|
||||||
# 2. Or configure DNS to point 'vision' to the server IP
|
# 2. Or configure DNS to point 'vision' to the server IP
|
||||||
# 3. Update camera_preview.html: API_BASE = 'http://localhost:8000'
|
# 3. Update camera_preview.html: API_BASE = 'http://localhost:8000'
|
||||||
###############################################################################
|
###############################################################################
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
# Option 1: Using 'vision' hostname (recommended for production)
|
# Option 1: Using 'vision' hostname (recommended for production)
|
||||||
# - Requires hostname resolution setup
|
# - Requires hostname resolution setup
|
||||||
# - Add to /etc/hosts: 127.0.0.1 vision
|
# - Add to /etc/hosts: exp-dash vision
|
||||||
# - Or configure DNS: vision -> server IP address
|
# - Or configure DNS: vision -> server IP address
|
||||||
# - Update camera_preview.html: API_BASE = 'http://localhost:8000'
|
# - Update camera_preview.html: API_BASE = 'http://localhost:8000'
|
||||||
# - Set @baseUrl = http://localhost:8000
|
# - Set @baseUrl = http://localhost:8000
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ server 3.us.pool.ntp.org iburst
|
|||||||
# Security settings
|
# Security settings
|
||||||
restrict default kod notrap nomodify nopeer noquery
|
restrict default kod notrap nomodify nopeer noquery
|
||||||
restrict -6 default kod notrap nomodify nopeer noquery
|
restrict -6 default kod notrap nomodify nopeer noquery
|
||||||
restrict 127.0.0.1
|
restrict exp-dash
|
||||||
restrict -6 ::1
|
restrict -6 ::1
|
||||||
|
|
||||||
# Local clock as fallback
|
# Local clock as fallback
|
||||||
|
|||||||
@@ -201,6 +201,57 @@ CREATE TABLE public.pecan_diameter_measurements (
|
|||||||
|
|
||||||
**Purpose**: Individual pecan diameter measurements (up to 10 per phase).
|
**Purpose**: Individual pecan diameter measurements (up to 10 per phase).
|
||||||
|
|
||||||
|
### 3. Conductor Availability Management
|
||||||
|
|
||||||
|
#### `public.conductor_availability`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.conductor_availability (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
|
||||||
|
available_from TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
available_to TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
notes TEXT, -- Optional notes about the availability
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'cancelled')),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_by UUID NOT NULL REFERENCES public.user_profiles(id),
|
||||||
|
|
||||||
|
-- Ensure available_to is after available_from
|
||||||
|
CONSTRAINT valid_time_range CHECK (available_to > available_from),
|
||||||
|
|
||||||
|
-- Ensure availability is in the future (can be modified if needed for past records)
|
||||||
|
CONSTRAINT future_availability CHECK (available_from >= NOW() - INTERVAL '1 day')
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose**: Stores conductor availability windows for experiment scheduling with overlap prevention.
|
||||||
|
|
||||||
|
#### `public.experiment_phase_assignments`
|
||||||
|
```sql
|
||||||
|
CREATE TABLE public.experiment_phase_assignments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
|
||||||
|
repetition_id UUID NOT NULL REFERENCES public.experiment_repetitions(id) ON DELETE CASCADE,
|
||||||
|
conductor_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
|
||||||
|
phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')),
|
||||||
|
scheduled_start_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
scheduled_end_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'in-progress', 'completed', 'cancelled')),
|
||||||
|
notes TEXT, -- Optional notes about the assignment
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_by UUID NOT NULL REFERENCES public.user_profiles(id),
|
||||||
|
|
||||||
|
-- Ensure scheduled_end_time is after scheduled_start_time
|
||||||
|
CONSTRAINT valid_scheduled_time_range CHECK (scheduled_end_time > scheduled_start_time),
|
||||||
|
|
||||||
|
-- Ensure unique assignment per conductor per phase per repetition
|
||||||
|
CONSTRAINT unique_conductor_phase_assignment UNIQUE (repetition_id, conductor_id, phase_name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose**: Assigns conductors to specific experiment repetition phases with scheduled times for future scheduling functionality.
|
||||||
|
|
||||||
## Key Functions
|
## Key Functions
|
||||||
|
|
||||||
### Authentication & Authorization
|
### Authentication & Authorization
|
||||||
@@ -218,6 +269,12 @@ CREATE TABLE public.pecan_diameter_measurements (
|
|||||||
### Data Validation
|
### Data Validation
|
||||||
- `validate_repetition_number()`: Ensures repetition numbers don't exceed experiment requirements
|
- `validate_repetition_number()`: Ensures repetition numbers don't exceed experiment requirements
|
||||||
- `public.check_repetition_lock_before_withdrawal()`: Prevents withdrawal of locked repetitions
|
- `public.check_repetition_lock_before_withdrawal()`: Prevents withdrawal of locked repetitions
|
||||||
|
- `public.check_availability_overlap()`: Prevents overlapping availabilities for the same conductor
|
||||||
|
- `public.adjust_overlapping_availability()`: Automatically adjusts overlapping availabilities (alternative approach)
|
||||||
|
|
||||||
|
### Availability Management
|
||||||
|
- `public.get_available_conductors(start_time, end_time)`: Returns conductors available for a specific time range
|
||||||
|
- `public.is_conductor_available(conductor_id, start_time, end_time)`: Checks if a conductor is available for a specific time range
|
||||||
|
|
||||||
### Timestamp Management
|
### Timestamp Management
|
||||||
- `public.handle_updated_at()`: Updates the updated_at timestamp
|
- `public.handle_updated_at()`: Updates the updated_at timestamp
|
||||||
@@ -227,7 +284,7 @@ CREATE TABLE public.pecan_diameter_measurements (
|
|||||||
|
|
||||||
### Access Control Summary
|
### Access Control Summary
|
||||||
- **Admin**: Full access to all tables and operations
|
- **Admin**: Full access to all tables and operations
|
||||||
- **Conductor**: Can manage experiments, experiment phases, and repetitions, view all data
|
- **Conductor**: Can manage experiments, experiment phases, and repetitions, view all data, manage their own availability
|
||||||
- **Analyst**: Read-only access to all data
|
- **Analyst**: Read-only access to all data
|
||||||
- **Data Recorder**: Can create and manage their own phase drafts and data
|
- **Data Recorder**: Can create and manage their own phase drafts and data
|
||||||
|
|
||||||
@@ -235,6 +292,8 @@ CREATE TABLE public.pecan_diameter_measurements (
|
|||||||
- All authenticated users can view experiments, experiment phases, and repetitions
|
- All authenticated users can view experiments, experiment phases, and repetitions
|
||||||
- Admin and conductor roles can manage experiment phases
|
- Admin and conductor roles can manage experiment phases
|
||||||
- Users can only modify their own phase drafts (unless admin)
|
- Users can only modify their own phase drafts (unless admin)
|
||||||
|
- Users can only manage their own availability (unless admin)
|
||||||
|
- Conductors can view their own experiment phase assignments
|
||||||
- Locked repetitions prevent draft modifications
|
- Locked repetitions prevent draft modifications
|
||||||
- Submitted drafts cannot be withdrawn if repetition is locked
|
- Submitted drafts cannot be withdrawn if repetition is locked
|
||||||
- Role-based access control for user management
|
- Role-based access control for user management
|
||||||
@@ -260,6 +319,10 @@ The database includes comprehensive indexing for performance:
|
|||||||
- Only one submitted draft per user per phase per repetition
|
- Only one submitted draft per user per phase per repetition
|
||||||
- Moisture percentages must be between 0-100
|
- Moisture percentages must be between 0-100
|
||||||
- Weights and measurements must be non-negative
|
- Weights and measurements must be non-negative
|
||||||
|
- Conductor availabilities cannot overlap for the same user
|
||||||
|
- Availability windows must have valid time ranges (end > start)
|
||||||
|
- Only one conductor assignment per phase per repetition
|
||||||
|
- Scheduled times must have valid ranges (end > start)
|
||||||
|
|
||||||
## Migration History
|
## Migration History
|
||||||
|
|
||||||
@@ -271,5 +334,6 @@ The schema has evolved through several migrations:
|
|||||||
5. **Data Entry System**: Phase-specific draft and data management
|
5. **Data Entry System**: Phase-specific draft and data management
|
||||||
6. **Constraint Fixes**: Refined business rules and constraints
|
6. **Constraint Fixes**: Refined business rules and constraints
|
||||||
7. **Experiment Phases**: Added experiment grouping for better organization
|
7. **Experiment Phases**: Added experiment grouping for better organization
|
||||||
|
8. **Conductor Availability**: Added availability management and experiment phase assignments for scheduling
|
||||||
|
|
||||||
This schema supports a comprehensive pecan processing experiment management system with robust security, data integrity, and flexible role-based access control.
|
This schema supports a comprehensive pecan processing experiment management system with robust security, data integrity, and flexible role-based access control.
|
||||||
|
|||||||
4
management-dashboard-web-app/.env.backup
Executable file
4
management-dashboard-web-app/.env.backup
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
# Local Supabase config for Vite dev server
|
||||||
|
VITE_SUPABASE_URL=http://127.0.0.1:54321
|
||||||
|
VITE_SUPABASE_ANON_KEY=[REDACTED]
|
||||||
|
|
||||||
@@ -216,3 +216,5 @@ If you encounter issues:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -125,3 +125,5 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,5 @@ export function CameraRoute({ cameraNumber }: CameraRouteProps) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DataEntry } from './DataEntry'
|
|||||||
import { VisionSystem } from './VisionSystem'
|
import { VisionSystem } from './VisionSystem'
|
||||||
import { Scheduling } from './Scheduling'
|
import { Scheduling } from './Scheduling'
|
||||||
import { VideoStreamingPage } from '../features/video-streaming'
|
import { VideoStreamingPage } from '../features/video-streaming'
|
||||||
|
import { UserProfile } from './UserProfile'
|
||||||
import { userManagement, type User } from '../lib/supabase'
|
import { userManagement, type User } from '../lib/supabase'
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
@@ -25,7 +26,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
|||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
// Valid dashboard views
|
// Valid dashboard views
|
||||||
const validViews = ['dashboard', 'user-management', 'experiments', 'analytics', 'data-entry', 'vision-system', 'scheduling', 'video-library']
|
const validViews = ['dashboard', 'user-management', 'experiments', 'analytics', 'data-entry', 'vision-system', 'scheduling', 'video-library', 'profile']
|
||||||
|
|
||||||
// Save current view to localStorage
|
// Save current view to localStorage
|
||||||
const saveCurrentView = (view: string) => {
|
const saveCurrentView = (view: string) => {
|
||||||
@@ -194,6 +195,8 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
|||||||
return <Scheduling user={user} currentRoute={currentRoute} />
|
return <Scheduling user={user} currentRoute={currentRoute} />
|
||||||
case 'video-library':
|
case 'video-library':
|
||||||
return <VideoStreamingPage />
|
return <VideoStreamingPage />
|
||||||
|
case 'profile':
|
||||||
|
return <UserProfile user={user} />
|
||||||
default:
|
default:
|
||||||
return <DashboardHome user={user} />
|
return <DashboardHome user={user} />
|
||||||
}
|
}
|
||||||
@@ -274,6 +277,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
|||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
onToggleSidebar={handleToggleSidebar}
|
onToggleSidebar={handleToggleSidebar}
|
||||||
isSidebarOpen={isMobileOpen}
|
isSidebarOpen={isMobileOpen}
|
||||||
|
onNavigateToProfile={() => handleViewChange('profile')}
|
||||||
/>
|
/>
|
||||||
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">
|
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">
|
||||||
{renderCurrentView()}
|
{renderCurrentView()}
|
||||||
|
|||||||
@@ -140,3 +140,5 @@ export function LiveCameraView({ cameraName }: LiveCameraViewProps) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface TopNavbarProps {
|
|||||||
currentView?: string
|
currentView?: string
|
||||||
onToggleSidebar?: () => void
|
onToggleSidebar?: () => void
|
||||||
isSidebarOpen?: boolean
|
isSidebarOpen?: boolean
|
||||||
|
onNavigateToProfile?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopNavbar({
|
export function TopNavbar({
|
||||||
@@ -14,7 +15,8 @@ export function TopNavbar({
|
|||||||
onLogout,
|
onLogout,
|
||||||
currentView = 'dashboard',
|
currentView = 'dashboard',
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
isSidebarOpen = false
|
isSidebarOpen = false,
|
||||||
|
onNavigateToProfile
|
||||||
}: TopNavbarProps) {
|
}: TopNavbarProps) {
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
|
||||||
|
|
||||||
@@ -145,11 +147,16 @@ export function TopNavbar({
|
|||||||
>
|
>
|
||||||
<span className="mr-3 overflow-hidden rounded-full h-11 w-11">
|
<span className="mr-3 overflow-hidden rounded-full h-11 w-11">
|
||||||
<div className="w-11 h-11 bg-brand-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
<div className="w-11 h-11 bg-brand-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||||
{user.email.charAt(0).toUpperCase()}
|
{(user.first_name || user.email).charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="block mr-1 font-medium text-theme-sm">{user.email.split('@')[0]}</span>
|
<span className="block mr-1 font-medium text-theme-sm">
|
||||||
|
{user.first_name && user.last_name
|
||||||
|
? `${user.first_name} ${user.last_name}`
|
||||||
|
: user.email.split('@')[0]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
<svg
|
<svg
|
||||||
className={`stroke-gray-500 dark:stroke-gray-400 transition-transform duration-200 ${isUserMenuOpen ? "rotate-180" : ""
|
className={`stroke-gray-500 dark:stroke-gray-400 transition-transform duration-200 ${isUserMenuOpen ? "rotate-180" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -174,7 +181,10 @@ export function TopNavbar({
|
|||||||
<div className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark z-50">
|
<div className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark z-50">
|
||||||
<div>
|
<div>
|
||||||
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
|
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
|
||||||
{user.email.split('@')[0]}
|
{user.first_name && user.last_name
|
||||||
|
? `${user.first_name} ${user.last_name}`
|
||||||
|
: user.email.split('@')[0]
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
|
<span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
|
||||||
{user.email}
|
{user.email}
|
||||||
@@ -183,7 +193,13 @@ export function TopNavbar({
|
|||||||
|
|
||||||
<ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
|
<ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
|
||||||
<li>
|
<li>
|
||||||
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsUserMenuOpen(false)
|
||||||
|
onNavigateToProfile?.()
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300 w-full text-left"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
className="fill-gray-500 dark:fill-gray-400"
|
className="fill-gray-500 dark:fill-gray-400"
|
||||||
width="24"
|
width="24"
|
||||||
@@ -200,7 +216,7 @@ export function TopNavbar({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Profile
|
Profile
|
||||||
</div>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
|
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ export function UserManagement() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePasswordReset = async (userId: string, userEmail: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to reset the password for ${userEmail}? The password will be reset to "password123".`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await userManagement.resetUserPassword(userId)
|
||||||
|
alert(`Password reset successfully for ${result.email}. New password: ${result.new_password}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Password reset error:', err)
|
||||||
|
alert('Failed to reset user password')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleUserCreated = (newUser: User) => {
|
const handleUserCreated = (newUser: User) => {
|
||||||
setUsers([...users, newUser])
|
setUsers([...users, newUser])
|
||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
@@ -260,6 +274,7 @@ export function UserManagement() {
|
|||||||
onStatusToggle={handleStatusToggle}
|
onStatusToggle={handleStatusToggle}
|
||||||
onRoleUpdate={handleRoleUpdate}
|
onRoleUpdate={handleRoleUpdate}
|
||||||
onEmailUpdate={handleEmailUpdate}
|
onEmailUpdate={handleEmailUpdate}
|
||||||
|
onPasswordReset={handlePasswordReset}
|
||||||
getRoleBadgeColor={getRoleBadgeColor}
|
getRoleBadgeColor={getRoleBadgeColor}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -290,6 +305,7 @@ interface UserRowProps {
|
|||||||
onStatusToggle: (userId: string, currentStatus: UserStatus) => void
|
onStatusToggle: (userId: string, currentStatus: UserStatus) => void
|
||||||
onRoleUpdate: (userId: string, newRoles: RoleName[]) => void
|
onRoleUpdate: (userId: string, newRoles: RoleName[]) => void
|
||||||
onEmailUpdate: (userId: string, newEmail: string) => void
|
onEmailUpdate: (userId: string, newEmail: string) => void
|
||||||
|
onPasswordReset: (userId: string, userEmail: string) => void
|
||||||
getRoleBadgeColor: (role: string) => string
|
getRoleBadgeColor: (role: string) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +318,7 @@ function UserRow({
|
|||||||
onStatusToggle,
|
onStatusToggle,
|
||||||
onRoleUpdate,
|
onRoleUpdate,
|
||||||
onEmailUpdate,
|
onEmailUpdate,
|
||||||
|
onPasswordReset,
|
||||||
getRoleBadgeColor
|
getRoleBadgeColor
|
||||||
}: UserRowProps) {
|
}: UserRowProps) {
|
||||||
const [editEmail, setEditEmail] = useState(user.email)
|
const [editEmail, setEditEmail] = useState(user.email)
|
||||||
@@ -381,8 +398,8 @@ function UserRow({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onStatusToggle(user.id, user.status)}
|
onClick={() => onStatusToggle(user.id, user.status)}
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
||||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||||
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||||
@@ -408,12 +425,26 @@ function UserRow({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="flex space-x-2">
|
||||||
onClick={onEdit}
|
<button
|
||||||
className="text-blue-600 hover:text-blue-900"
|
onClick={onEdit}
|
||||||
>
|
className="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
|
||||||
Edit
|
title="Edit user"
|
||||||
</button>
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPasswordReset(user.id, user.email)}
|
||||||
|
className="p-1 text-orange-600 hover:text-orange-900 hover:bg-orange-50 rounded"
|
||||||
|
title="Reset password to 'password123'"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
312
management-dashboard-web-app/src/components/UserProfile.tsx
Normal file
312
management-dashboard-web-app/src/components/UserProfile.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { userManagement, type User } from '../lib/supabase'
|
||||||
|
|
||||||
|
interface UserProfileProps {
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangePasswordModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChangePasswordModal({ isOpen, onClose, onSuccess }: ChangePasswordModalProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
|
||||||
|
const handlePasswordChange = async () => {
|
||||||
|
// Validation
|
||||||
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
|
setError('All fields are required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError('New password must be at least 6 characters long')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const result = await userManagement.changeUserPassword(currentPassword, newPassword)
|
||||||
|
alert('Password changed successfully!')
|
||||||
|
onSuccess()
|
||||||
|
onClose()
|
||||||
|
// Clear form
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
setConfirmPassword('')
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Password change error:', err)
|
||||||
|
if (err.message?.includes('Current password is incorrect')) {
|
||||||
|
setError('Current password is incorrect')
|
||||||
|
} else {
|
||||||
|
setError('Failed to change password. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose}></div>
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Change Password</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Current Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Enter your current password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Enter your new password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Confirm your new password"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePasswordChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Changing...' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserProfile({ user }: UserProfileProps) {
|
||||||
|
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false)
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'admin':
|
||||||
|
return 'bg-red-100 text-red-800'
|
||||||
|
case 'conductor':
|
||||||
|
return 'bg-blue-100 text-blue-800'
|
||||||
|
case 'analyst':
|
||||||
|
return 'bg-green-100 text-green-800'
|
||||||
|
case 'data recorder':
|
||||||
|
return 'bg-purple-100 text-purple-800'
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="bg-white shadow rounded-lg">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">User Profile</h1>
|
||||||
|
<p className="text-sm text-gray-600">Manage your account information and settings</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-16 h-16 bg-brand-500 rounded-full flex items-center justify-center text-white text-xl font-medium">
|
||||||
|
{(user.first_name || user.email).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Information */}
|
||||||
|
<div className="px-6 py-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Basic Information</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md border">
|
||||||
|
{user.first_name && user.last_name
|
||||||
|
? `${user.first_name} ${user.last_name}`
|
||||||
|
: 'Name not set'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-gray-900 bg-gray-50 px-3 py-2 rounded-md border">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Account Status
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Member Since
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{formatDate(user.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roles and Permissions */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Roles & Permissions</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Assigned Roles
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{user.roles.map((role) => (
|
||||||
|
<span
|
||||||
|
key={role}
|
||||||
|
className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${getRoleBadgeColor(role)}`}
|
||||||
|
>
|
||||||
|
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Last Updated
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{formatDate(user.updated_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Account Actions</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowChangePasswordModal(true)}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-blue-300 text-sm font-medium rounded-md text-blue-700 bg-blue-50 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||||
|
</svg>
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Change Password Modal */}
|
||||||
|
<ChangePasswordModal
|
||||||
|
isOpen={showChangePasswordModal}
|
||||||
|
onClose={() => setShowChangePasswordModal(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
// Could add success notification here
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ export type ResultsStatus = 'valid' | 'invalid'
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
roles: RoleName[]
|
roles: RoleName[]
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -257,6 +259,8 @@ export const userManagement = {
|
|||||||
.select(`
|
.select(`
|
||||||
id,
|
id,
|
||||||
email,
|
email,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
status,
|
status,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
@@ -373,6 +377,8 @@ export const userManagement = {
|
|||||||
.select(`
|
.select(`
|
||||||
id,
|
id,
|
||||||
email,
|
email,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
status,
|
status,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
@@ -397,6 +403,27 @@ export const userManagement = {
|
|||||||
...profile,
|
...profile,
|
||||||
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
|
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset user password to default (admin only)
|
||||||
|
async resetUserPassword(userId: string): Promise<{ user_id: string; email: string; new_password: string; reset_at: string }> {
|
||||||
|
const { data, error } = await supabase.rpc('reset_user_password', {
|
||||||
|
target_user_id: userId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Change user password (user can only change their own password)
|
||||||
|
async changeUserPassword(currentPassword: string, newPassword: string): Promise<{ user_id: string; email: string; password_changed_at: string }> {
|
||||||
|
const { data, error } = await supabase.rpc('change_user_password', {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
v2.34.3
|
v2.40.7
|
||||||
@@ -81,7 +81,7 @@ enabled = true
|
|||||||
# Port to use for Supabase Studio.
|
# Port to use for Supabase Studio.
|
||||||
port = 54323
|
port = 54323
|
||||||
# External URL of the API server that frontend connects to.
|
# External URL of the API server that frontend connects to.
|
||||||
api_url = "http://127.0.0.1"
|
api_url = "http://exp-dash"
|
||||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||||
openai_api_key = "env(OPENAI_API_KEY)"
|
openai_api_key = "env(OPENAI_API_KEY)"
|
||||||
|
|
||||||
@@ -117,9 +117,9 @@ file_size_limit = "50MiB"
|
|||||||
enabled = true
|
enabled = true
|
||||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||||
# in emails.
|
# in emails.
|
||||||
site_url = "http://127.0.0.1:3000"
|
site_url = "http://exp-dash:3000"
|
||||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
additional_redirect_urls = ["https://exp-dash:3000"]
|
||||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||||
jwt_expiry = 3600
|
jwt_expiry = 3600
|
||||||
# If disabled, the refresh token will never expire.
|
# If disabled, the refresh token will never expire.
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
-- Add Conductor Availability and Experiment Phase Assignment Tables
|
||||||
|
-- This migration adds tables for conductor availability management and future experiment scheduling
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 1. CONDUCTOR AVAILABILITY TABLE
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- Create conductor_availability table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.conductor_availability (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
|
||||||
|
available_from TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
available_to TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
notes TEXT, -- Optional notes about the availability
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'cancelled')),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_by UUID NOT NULL REFERENCES public.user_profiles(id),
|
||||||
|
|
||||||
|
-- Ensure available_to is after available_from
|
||||||
|
CONSTRAINT valid_time_range CHECK (available_to > available_from),
|
||||||
|
|
||||||
|
-- Ensure availability is in the future (can be modified if needed for past records)
|
||||||
|
CONSTRAINT future_availability CHECK (available_from >= NOW() - INTERVAL '1 day')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 2. EXPERIMENT PHASE ASSIGNMENTS TABLE (Future Scheduling)
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- Create experiment_phase_assignments table for scheduling conductors to experiment phases
|
||||||
|
CREATE TABLE IF NOT EXISTS public.experiment_phase_assignments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
experiment_id UUID NOT NULL REFERENCES public.experiments(id) ON DELETE CASCADE,
|
||||||
|
repetition_id UUID NOT NULL REFERENCES public.experiment_repetitions(id) ON DELETE CASCADE,
|
||||||
|
conductor_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
|
||||||
|
phase_name TEXT NOT NULL CHECK (phase_name IN ('pre-soaking', 'air-drying', 'cracking', 'shelling')),
|
||||||
|
scheduled_start_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
scheduled_end_time TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'in-progress', 'completed', 'cancelled')),
|
||||||
|
notes TEXT, -- Optional notes about the assignment
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
created_by UUID NOT NULL REFERENCES public.user_profiles(id),
|
||||||
|
|
||||||
|
-- Ensure scheduled_end_time is after scheduled_start_time
|
||||||
|
CONSTRAINT valid_scheduled_time_range CHECK (scheduled_end_time > scheduled_start_time),
|
||||||
|
|
||||||
|
-- Ensure unique assignment per conductor per phase per repetition
|
||||||
|
CONSTRAINT unique_conductor_phase_assignment UNIQUE (repetition_id, conductor_id, phase_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 3. INDEXES FOR PERFORMANCE
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- Conductor availability indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conductor_availability_user_id ON public.conductor_availability(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conductor_availability_available_from ON public.conductor_availability(available_from);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conductor_availability_available_to ON public.conductor_availability(available_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conductor_availability_status ON public.conductor_availability(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conductor_availability_created_by ON public.conductor_availability(created_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conductor_availability_time_range ON public.conductor_availability(available_from, available_to);
|
||||||
|
|
||||||
|
-- Experiment phase assignments indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiment_phase_assignments_experiment_id ON public.experiment_phase_assignments(experiment_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiment_phase_assignments_repetition_id ON public.experiment_phase_assignments(repetition_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiment_phase_assignments_conductor_id ON public.experiment_phase_assignments(conductor_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiment_phase_assignments_phase_name ON public.experiment_phase_assignments(phase_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiment_phase_assignments_status ON public.experiment_phase_assignments(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiment_phase_assignments_scheduled_start ON public.experiment_phase_assignments(scheduled_start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_experiment_phase_assignments_created_by ON public.experiment_phase_assignments(created_by);
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 4. FUNCTIONS FOR OVERLAP PREVENTION
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- Function to check for overlapping availabilities
|
||||||
|
CREATE OR REPLACE FUNCTION public.check_availability_overlap()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
overlap_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Check for overlapping availabilities for the same user
|
||||||
|
SELECT COUNT(*) INTO overlap_count
|
||||||
|
FROM public.conductor_availability
|
||||||
|
WHERE user_id = NEW.user_id
|
||||||
|
AND id != COALESCE(NEW.id, '00000000-0000-0000-0000-000000000000'::UUID)
|
||||||
|
AND status = 'active'
|
||||||
|
AND (
|
||||||
|
-- New availability starts during an existing one
|
||||||
|
(NEW.available_from >= available_from AND NEW.available_from < available_to) OR
|
||||||
|
-- New availability ends during an existing one
|
||||||
|
(NEW.available_to > available_from AND NEW.available_to <= available_to) OR
|
||||||
|
-- New availability completely contains an existing one
|
||||||
|
(NEW.available_from <= available_from AND NEW.available_to >= available_to) OR
|
||||||
|
-- Existing availability completely contains the new one
|
||||||
|
(available_from <= NEW.available_from AND available_to >= NEW.available_to)
|
||||||
|
);
|
||||||
|
|
||||||
|
IF overlap_count > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Availability overlaps with existing availability for user %. Please adjust the time range or cancel the conflicting availability.', NEW.user_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Function to automatically adjust overlapping availabilities (alternative approach)
|
||||||
|
CREATE OR REPLACE FUNCTION public.adjust_overlapping_availability()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
overlapping_record RECORD;
|
||||||
|
BEGIN
|
||||||
|
-- Find overlapping availabilities and adjust them
|
||||||
|
FOR overlapping_record IN
|
||||||
|
SELECT id, available_from, available_to
|
||||||
|
FROM public.conductor_availability
|
||||||
|
WHERE user_id = NEW.user_id
|
||||||
|
AND id != COALESCE(NEW.id, '00000000-0000-0000-0000-000000000000'::UUID)
|
||||||
|
AND status = 'active'
|
||||||
|
AND (
|
||||||
|
(NEW.available_from >= available_from AND NEW.available_from < available_to) OR
|
||||||
|
(NEW.available_to > available_from AND NEW.available_to <= available_to) OR
|
||||||
|
(NEW.available_from <= available_from AND NEW.available_to >= available_to) OR
|
||||||
|
(available_from <= NEW.available_from AND available_to >= NEW.available_to)
|
||||||
|
)
|
||||||
|
LOOP
|
||||||
|
-- Adjust the overlapping record to end where the new one starts
|
||||||
|
IF overlapping_record.available_from < NEW.available_from AND overlapping_record.available_to > NEW.available_from THEN
|
||||||
|
UPDATE public.conductor_availability
|
||||||
|
SET available_to = NEW.available_from,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = overlapping_record.id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- If the overlapping record starts after the new one, adjust it to start where the new one ends
|
||||||
|
IF overlapping_record.available_from < NEW.available_to AND overlapping_record.available_to > NEW.available_to THEN
|
||||||
|
UPDATE public.conductor_availability
|
||||||
|
SET available_from = NEW.available_to,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = overlapping_record.id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- If the overlapping record is completely contained within the new one, cancel it
|
||||||
|
IF overlapping_record.available_from >= NEW.available_from AND overlapping_record.available_to <= NEW.available_to THEN
|
||||||
|
UPDATE public.conductor_availability
|
||||||
|
SET status = 'cancelled',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = overlapping_record.id;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 5. TRIGGERS
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- Create trigger for updated_at on conductor_availability
|
||||||
|
CREATE TRIGGER set_updated_at_conductor_availability
|
||||||
|
BEFORE UPDATE ON public.conductor_availability
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.handle_updated_at();
|
||||||
|
|
||||||
|
-- Create trigger for updated_at on experiment_phase_assignments
|
||||||
|
CREATE TRIGGER set_updated_at_experiment_phase_assignments
|
||||||
|
BEFORE UPDATE ON public.experiment_phase_assignments
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.handle_updated_at();
|
||||||
|
|
||||||
|
-- Create trigger to prevent overlapping availabilities (strict approach)
|
||||||
|
CREATE TRIGGER trigger_check_availability_overlap
|
||||||
|
BEFORE INSERT OR UPDATE ON public.conductor_availability
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.check_availability_overlap();
|
||||||
|
|
||||||
|
-- Alternative trigger to automatically adjust overlapping availabilities (uncomment if preferred)
|
||||||
|
-- CREATE TRIGGER trigger_adjust_overlapping_availability
|
||||||
|
-- BEFORE INSERT OR UPDATE ON public.conductor_availability
|
||||||
|
-- FOR EACH ROW
|
||||||
|
-- EXECUTE FUNCTION public.adjust_overlapping_availability();
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 6. HELPER FUNCTIONS
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- Function to get available conductors for a specific time range
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_available_conductors(
|
||||||
|
start_time TIMESTAMP WITH TIME ZONE,
|
||||||
|
end_time TIMESTAMP WITH TIME ZONE
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
user_id UUID,
|
||||||
|
email TEXT,
|
||||||
|
available_from TIMESTAMP WITH TIME ZONE,
|
||||||
|
available_to TIMESTAMP WITH TIME ZONE
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
ca.user_id,
|
||||||
|
up.email,
|
||||||
|
ca.available_from,
|
||||||
|
ca.available_to
|
||||||
|
FROM public.conductor_availability ca
|
||||||
|
JOIN public.user_profiles up ON ca.user_id = up.id
|
||||||
|
JOIN public.user_roles ur ON up.id = ur.user_id
|
||||||
|
JOIN public.roles r ON ur.role_id = r.id
|
||||||
|
WHERE ca.status = 'active'
|
||||||
|
AND r.name = 'conductor'
|
||||||
|
AND ca.available_from <= start_time
|
||||||
|
AND ca.available_to >= end_time
|
||||||
|
ORDER BY up.email;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Function to check if a conductor is available for a specific time range
|
||||||
|
CREATE OR REPLACE FUNCTION public.is_conductor_available(
|
||||||
|
conductor_user_id UUID,
|
||||||
|
start_time TIMESTAMP WITH TIME ZONE,
|
||||||
|
end_time TIMESTAMP WITH TIME ZONE
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
availability_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO availability_count
|
||||||
|
FROM public.conductor_availability
|
||||||
|
WHERE user_id = conductor_user_id
|
||||||
|
AND status = 'active'
|
||||||
|
AND available_from <= start_time
|
||||||
|
AND available_to >= end_time;
|
||||||
|
|
||||||
|
RETURN availability_count > 0;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 7. ROW LEVEL SECURITY (RLS)
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- Enable RLS on new tables
|
||||||
|
ALTER TABLE public.conductor_availability ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.experiment_phase_assignments ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Conductor availability policies
|
||||||
|
CREATE POLICY "conductor_availability_select_policy" ON public.conductor_availability
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
-- Users can view their own availability, admins can view all
|
||||||
|
user_id = auth.uid() OR public.is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "conductor_availability_insert_policy" ON public.conductor_availability
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (
|
||||||
|
-- Users can create their own availability, admins can create for anyone
|
||||||
|
(user_id = auth.uid() AND created_by = auth.uid()) OR public.is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "conductor_availability_update_policy" ON public.conductor_availability
|
||||||
|
FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
-- Users can update their own availability, admins can update any
|
||||||
|
user_id = auth.uid() OR public.is_admin()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
-- Users can update their own availability, admins can update any
|
||||||
|
user_id = auth.uid() OR public.is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "conductor_availability_delete_policy" ON public.conductor_availability
|
||||||
|
FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
-- Users can delete their own availability, admins can delete any
|
||||||
|
user_id = auth.uid() OR public.is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Experiment phase assignments policies
|
||||||
|
CREATE POLICY "experiment_phase_assignments_select_policy" ON public.experiment_phase_assignments
|
||||||
|
FOR SELECT
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
-- Conductors can view their own assignments, admins can view all
|
||||||
|
conductor_id = auth.uid() OR public.is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "experiment_phase_assignments_insert_policy" ON public.experiment_phase_assignments
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (
|
||||||
|
-- Only admins and conductors can create assignments
|
||||||
|
public.can_manage_experiments()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "experiment_phase_assignments_update_policy" ON public.experiment_phase_assignments
|
||||||
|
FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
-- Conductors can update their own assignments, admins can update any
|
||||||
|
conductor_id = auth.uid() OR public.is_admin()
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
-- Conductors can update their own assignments, admins can update any
|
||||||
|
conductor_id = auth.uid() OR public.is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "experiment_phase_assignments_delete_policy" ON public.experiment_phase_assignments
|
||||||
|
FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
-- Only admins can delete assignments
|
||||||
|
public.is_admin()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 8. COMMENTS FOR DOCUMENTATION
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.conductor_availability IS 'Stores conductor availability windows for experiment scheduling';
|
||||||
|
COMMENT ON TABLE public.experiment_phase_assignments IS 'Assigns conductors to specific experiment repetition phases with scheduled times';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.conductor_availability.available_from IS 'Start time of availability window';
|
||||||
|
COMMENT ON COLUMN public.conductor_availability.available_to IS 'End time of availability window';
|
||||||
|
COMMENT ON COLUMN public.conductor_availability.notes IS 'Optional notes about the availability period';
|
||||||
|
COMMENT ON COLUMN public.conductor_availability.status IS 'Status of the availability (active or cancelled)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.experiment_phase_assignments.phase_name IS 'Experiment phase being assigned (pre-soaking, air-drying, cracking, shelling)';
|
||||||
|
COMMENT ON COLUMN public.experiment_phase_assignments.scheduled_start_time IS 'Planned start time for the phase';
|
||||||
|
COMMENT ON COLUMN public.experiment_phase_assignments.scheduled_end_time IS 'Planned end time for the phase';
|
||||||
|
COMMENT ON COLUMN public.experiment_phase_assignments.status IS 'Current status of the assignment';
|
||||||
|
COMMENT ON COLUMN public.experiment_phase_assignments.notes IS 'Optional notes about the assignment';
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Add password reset function for admin use
|
||||||
|
-- This migration adds a function to reset user passwords back to the default "password123"
|
||||||
|
|
||||||
|
-- Function to reset user password (admin only)
|
||||||
|
CREATE OR REPLACE FUNCTION public.reset_user_password(
|
||||||
|
target_user_id UUID
|
||||||
|
)
|
||||||
|
RETURNS JSON AS $$
|
||||||
|
DECLARE
|
||||||
|
user_email TEXT;
|
||||||
|
result JSON;
|
||||||
|
BEGIN
|
||||||
|
-- Only admins can reset passwords
|
||||||
|
IF NOT public.is_admin() THEN
|
||||||
|
RAISE EXCEPTION 'Only administrators can reset user passwords';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check if target user exists
|
||||||
|
SELECT email INTO user_email
|
||||||
|
FROM public.user_profiles
|
||||||
|
WHERE id = target_user_id;
|
||||||
|
|
||||||
|
IF user_email IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'User not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Update the password in auth.users table
|
||||||
|
UPDATE auth.users
|
||||||
|
SET
|
||||||
|
encrypted_password = crypt('password123', gen_salt('bf')),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = target_user_id;
|
||||||
|
|
||||||
|
-- Return result
|
||||||
|
result := json_build_object(
|
||||||
|
'user_id', target_user_id,
|
||||||
|
'email', user_email,
|
||||||
|
'new_password', 'password123',
|
||||||
|
'reset_at', NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN result;
|
||||||
|
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Add first_name and last_name fields to user_profiles table
|
||||||
|
-- This migration adds name fields to store user's first and last names
|
||||||
|
|
||||||
|
-- Add first_name and last_name columns to user_profiles table
|
||||||
|
ALTER TABLE public.user_profiles
|
||||||
|
ADD COLUMN first_name TEXT,
|
||||||
|
ADD COLUMN last_name TEXT;
|
||||||
|
|
||||||
|
-- Add comments for documentation
|
||||||
|
COMMENT ON COLUMN public.user_profiles.first_name IS 'User first name';
|
||||||
|
COMMENT ON COLUMN public.user_profiles.last_name IS 'User last name';
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- Add change password function for users
|
||||||
|
-- This migration adds a function to allow users to change their own password
|
||||||
|
|
||||||
|
-- Function to change user password (user can only change their own password)
|
||||||
|
CREATE OR REPLACE FUNCTION public.change_user_password(
|
||||||
|
current_password TEXT,
|
||||||
|
new_password TEXT
|
||||||
|
)
|
||||||
|
RETURNS JSON AS $$
|
||||||
|
DECLARE
|
||||||
|
user_id UUID;
|
||||||
|
user_email TEXT;
|
||||||
|
result JSON;
|
||||||
|
BEGIN
|
||||||
|
-- Get current user ID
|
||||||
|
user_id := auth.uid();
|
||||||
|
|
||||||
|
IF user_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'User not authenticated';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get user email
|
||||||
|
SELECT email INTO user_email
|
||||||
|
FROM public.user_profiles
|
||||||
|
WHERE id = user_id;
|
||||||
|
|
||||||
|
IF user_email IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'User profile not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verify current password
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM auth.users
|
||||||
|
WHERE id = user_id
|
||||||
|
AND encrypted_password = crypt(current_password, encrypted_password)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Current password is incorrect';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Update the password in auth.users table
|
||||||
|
UPDATE auth.users
|
||||||
|
SET
|
||||||
|
encrypted_password = crypt(new_password, gen_salt('bf')),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = user_id;
|
||||||
|
|
||||||
|
-- Return result
|
||||||
|
result := json_build_object(
|
||||||
|
'user_id', user_id,
|
||||||
|
'email', user_email,
|
||||||
|
'password_changed_at', NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN result;
|
||||||
|
|
||||||
|
EXCEPTION
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RAISE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ INSERT INTO auth.users (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Create user profile
|
-- Create user profile
|
||||||
INSERT INTO public.user_profiles (id, email, status)
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
SELECT id, email, 'active'
|
SELECT id, email, 'Alireza', 'Vaezi', 'active'
|
||||||
FROM auth.users
|
FROM auth.users
|
||||||
WHERE email = 's.alireza.v@gmail.com'
|
WHERE email = 's.alireza.v@gmail.com'
|
||||||
;
|
;
|
||||||
@@ -66,7 +66,443 @@ AND r.name = 'admin'
|
|||||||
;
|
;
|
||||||
|
|
||||||
-- =============================================
|
-- =============================================
|
||||||
-- 3. CREATE EXPERIMENT PHASES
|
-- 3. CREATE ADDITIONAL USERS
|
||||||
|
-- =============================================
|
||||||
|
|
||||||
|
-- Create Claire Floyd (Conductor & Data Recorder)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
'Ashlyn.Floyd@uga.edu',
|
||||||
|
crypt('password123', gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
|
SELECT id, email, 'Claire', 'Floyd', 'active'
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'Ashlyn.Floyd@uga.edu'
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
SELECT
|
||||||
|
up.id,
|
||||||
|
r.id,
|
||||||
|
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||||
|
FROM public.user_profiles up
|
||||||
|
CROSS JOIN public.roles r
|
||||||
|
WHERE up.email = 'Ashlyn.Floyd@uga.edu'
|
||||||
|
AND r.name IN ('conductor', 'data recorder')
|
||||||
|
;
|
||||||
|
|
||||||
|
-- Create Bruna Dos-Santos (Conductor & Data Recorder)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
'bkvsantos@uga.edu',
|
||||||
|
crypt('password123', gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
|
SELECT id, email, 'Bruna', 'Dos-Santos', 'active'
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'bkvsantos@uga.edu'
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
SELECT
|
||||||
|
up.id,
|
||||||
|
r.id,
|
||||||
|
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||||
|
FROM public.user_profiles up
|
||||||
|
CROSS JOIN public.roles r
|
||||||
|
WHERE up.email = 'bkvsantos@uga.edu'
|
||||||
|
AND r.name IN ('conductor', 'data recorder')
|
||||||
|
;
|
||||||
|
|
||||||
|
-- Create Beni Rodriguez (Conductor & Data Recorder)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
'Beni.Rodriguez@uga.edu',
|
||||||
|
crypt('password123', gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
|
SELECT id, email, 'Beni', 'Rodriguez', 'active'
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'Beni.Rodriguez@uga.edu'
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
SELECT
|
||||||
|
up.id,
|
||||||
|
r.id,
|
||||||
|
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||||
|
FROM public.user_profiles up
|
||||||
|
CROSS JOIN public.roles r
|
||||||
|
WHERE up.email = 'Beni.Rodriguez@uga.edu'
|
||||||
|
AND r.name IN ('conductor', 'data recorder')
|
||||||
|
;
|
||||||
|
|
||||||
|
-- Create Brendan Surio (Data Recorder)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
'Brendan.Surio@uga.edu',
|
||||||
|
crypt('password123', gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
|
SELECT id, email, 'Brendan', 'Surio', 'active'
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'Brendan.Surio@uga.edu'
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
SELECT
|
||||||
|
up.id,
|
||||||
|
r.id,
|
||||||
|
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||||
|
FROM public.user_profiles up
|
||||||
|
CROSS JOIN public.roles r
|
||||||
|
WHERE up.email = 'Brendan.Surio@uga.edu'
|
||||||
|
AND r.name = 'data recorder'
|
||||||
|
;
|
||||||
|
|
||||||
|
-- Create William Mcconnell (Data Recorder)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
'William.McConnell@uga.edu',
|
||||||
|
crypt('password123', gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
|
SELECT id, email, 'William', 'Mcconnell', 'active'
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'William.McConnell@uga.edu'
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
SELECT
|
||||||
|
up.id,
|
||||||
|
r.id,
|
||||||
|
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||||
|
FROM public.user_profiles up
|
||||||
|
CROSS JOIN public.roles r
|
||||||
|
WHERE up.email = 'William.McConnell@uga.edu'
|
||||||
|
AND r.name = 'data recorder'
|
||||||
|
;
|
||||||
|
|
||||||
|
-- Create Camille Deguzman (Data Recorder)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
'cpd08598@uga.edu',
|
||||||
|
crypt('password123', gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
|
SELECT id, email, 'Camille', 'Deguzman', 'active'
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'cpd08598@uga.edu'
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
SELECT
|
||||||
|
up.id,
|
||||||
|
r.id,
|
||||||
|
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||||
|
FROM public.user_profiles up
|
||||||
|
CROSS JOIN public.roles r
|
||||||
|
WHERE up.email = 'cpd08598@uga.edu'
|
||||||
|
AND r.name = 'data recorder'
|
||||||
|
;
|
||||||
|
|
||||||
|
-- Create Justin Hetzler (Data Recorder)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
'Justin.Hetzler@uga.edu',
|
||||||
|
crypt('password123', gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
|
SELECT id, email, 'Justin', 'Hetzler', 'active'
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'Justin.Hetzler@uga.edu'
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
SELECT
|
||||||
|
up.id,
|
||||||
|
r.id,
|
||||||
|
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||||
|
FROM public.user_profiles up
|
||||||
|
CROSS JOIN public.roles r
|
||||||
|
WHERE up.email = 'Justin.Hetzler@uga.edu'
|
||||||
|
AND r.name = 'data recorder'
|
||||||
|
;
|
||||||
|
|
||||||
|
-- Create Joshua Wilson (Data Recorder)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
'jdw58940@uga.edu',
|
||||||
|
crypt('password123', gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
|
SELECT id, email, 'Joshua', 'Wilson', 'active'
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'jdw58940@uga.edu'
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
SELECT
|
||||||
|
up.id,
|
||||||
|
r.id,
|
||||||
|
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||||
|
FROM public.user_profiles up
|
||||||
|
CROSS JOIN public.roles r
|
||||||
|
WHERE up.email = 'jdw58940@uga.edu'
|
||||||
|
AND r.name = 'data recorder'
|
||||||
|
;
|
||||||
|
|
||||||
|
-- Create Sydney Orlofsky (Data Recorder)
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
instance_id,
|
||||||
|
id,
|
||||||
|
aud,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
encrypted_password,
|
||||||
|
email_confirmed_at,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
confirmation_token,
|
||||||
|
email_change,
|
||||||
|
email_change_token_new,
|
||||||
|
recovery_token
|
||||||
|
) VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'authenticated',
|
||||||
|
'authenticated',
|
||||||
|
'Sydney.Orlofsky@uga.edu',
|
||||||
|
crypt('password123', gen_salt('bf')),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.user_profiles (id, email, first_name, last_name, status)
|
||||||
|
SELECT id, email, 'Sydney', 'Orlofsky', 'active'
|
||||||
|
FROM auth.users
|
||||||
|
WHERE email = 'Sydney.Orlofsky@uga.edu'
|
||||||
|
;
|
||||||
|
|
||||||
|
INSERT INTO public.user_roles (user_id, role_id, assigned_by)
|
||||||
|
SELECT
|
||||||
|
up.id,
|
||||||
|
r.id,
|
||||||
|
(SELECT id FROM public.user_profiles WHERE email = 's.alireza.v@gmail.com')
|
||||||
|
FROM public.user_profiles up
|
||||||
|
CROSS JOIN public.roles r
|
||||||
|
WHERE up.email = 'Sydney.Orlofsky@uga.edu'
|
||||||
|
AND r.name = 'data recorder'
|
||||||
|
;
|
||||||
|
|
||||||
|
-- =============================================
|
||||||
|
-- 4. CREATE EXPERIMENT PHASES
|
||||||
-- =============================================
|
-- =============================================
|
||||||
|
|
||||||
-- Create "Phase 2 of JC Experiments" phase
|
-- Create "Phase 2 of JC Experiments" phase
|
||||||
@@ -80,7 +516,7 @@ WHERE up.email = 's.alireza.v@gmail.com'
|
|||||||
;
|
;
|
||||||
|
|
||||||
-- =============================================
|
-- =============================================
|
||||||
-- 4. INSERT EXPERIMENTS (First 10 as example)
|
-- 5. INSERT EXPERIMENTS (First 10 as example)
|
||||||
-- =============================================
|
-- =============================================
|
||||||
|
|
||||||
INSERT INTO public.experiments (
|
INSERT INTO public.experiments (
|
||||||
@@ -130,7 +566,7 @@ INSERT INTO public.experiments (
|
|||||||
;
|
;
|
||||||
|
|
||||||
-- =============================================
|
-- =============================================
|
||||||
-- 5. CREATE SAMPLE EXPERIMENT REPETITIONS
|
-- 6. CREATE SAMPLE EXPERIMENT REPETITIONS
|
||||||
-- =============================================
|
-- =============================================
|
||||||
|
|
||||||
-- Create repetitions for first 5 experiments as examples
|
-- Create repetitions for first 5 experiments as examples
|
||||||
|
|||||||
Reference in New Issue
Block a user