Merge pull request 'feat: Worker scaling from admin UI with password confirmation' (#33) from feat/worker-scaling into master

Reviewed-on: https://code.cannabrands.app/Creationshop/dispensary-scraper/pulls/33
This commit is contained in:
kelly
2025-12-11 16:29:04 +00:00
6 changed files with 273 additions and 39 deletions

View File

@@ -47,4 +47,27 @@ router.post('/refresh', authMiddleware, async (req: AuthRequest, res) => {
res.json({ token }); res.json({ token });
}); });
// Verify password for sensitive actions (requires current user to be authenticated)
router.post('/verify-password', authMiddleware, async (req: AuthRequest, res) => {
try {
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: 'Password required' });
}
// Re-authenticate the current user with the provided password
const user = await authenticateUser(req.user!.email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid password', verified: false });
}
res.json({ verified: true });
} catch (error) {
console.error('Password verification error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router; export default router;

View File

@@ -0,0 +1,138 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../lib/api';
import { Shield, X, Loader2 } from 'lucide-react';
interface PasswordConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description: string;
}
export function PasswordConfirmModal({
isOpen,
onClose,
onConfirm,
title,
description,
}: PasswordConfirmModalProps) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setPassword('');
setError('');
// Focus the input when modal opens
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setError('Password is required');
return;
}
setLoading(true);
setError('');
try {
const result = await api.verifyPassword(password);
if (result.verified) {
onConfirm();
onClose();
} else {
setError('Invalid password');
}
} catch (err: any) {
setError(err.message || 'Verification failed');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 rounded-lg">
<Shield className="w-5 h-5 text-amber-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit}>
<div className="px-6 py-4">
<p className="text-gray-600 mb-4">{description}</p>
<div className="space-y-2">
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Enter your password to continue
</label>
<input
ref={inputRef}
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
placeholder="Password"
disabled={loading}
/>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Confirm
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -84,6 +84,13 @@ class ApiClient {
}); });
} }
async verifyPassword(password: string) {
return this.request<{ verified: boolean; error?: string }>('/api/auth/verify-password', {
method: 'POST',
body: JSON.stringify({ password }),
});
}
async getMe() { async getMe() {
return this.request<{ user: any }>('/api/auth/me'); return this.request<{ user: any }>('/api/auth/me');
} }

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { Layout } from '../components/Layout'; import { Layout } from '../components/Layout';
import { PasswordConfirmModal } from '../components/PasswordConfirmModal';
import { import {
ListChecks, ListChecks,
Clock, Clock,
@@ -149,6 +150,10 @@ export default function TasksDashboard() {
const [workerReady, setWorkerReady] = useState(0); const [workerReady, setWorkerReady] = useState(0);
const [scalingWorkers, setScalingWorkers] = useState(false); const [scalingWorkers, setScalingWorkers] = useState(false);
// Password confirmation for scaling
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [pendingScaleDelta, setPendingScaleDelta] = useState(0);
// Filters // Filters
const [roleFilter, setRoleFilter] = useState<string>(''); const [roleFilter, setRoleFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
@@ -185,10 +190,19 @@ export default function TasksDashboard() {
} }
}; };
const scaleWorkers = async (delta: number) => { // Request to scale workers - shows confirmation modal first
const requestScaleWorkers = (delta: number) => {
const newReplicas = Math.max(0, Math.min(50, workerReplicas + delta)); const newReplicas = Math.max(0, Math.min(50, workerReplicas + delta));
if (newReplicas === workerReplicas) return; if (newReplicas === workerReplicas) return;
setPendingScaleDelta(delta);
setShowConfirmModal(true);
};
// Actually scale workers after password confirmation
const executeScaleWorkers = async () => {
const newReplicas = Math.max(0, Math.min(50, workerReplicas + pendingScaleDelta));
setScalingWorkers(true); setScalingWorkers(true);
try { try {
const res = await api.scaleK8sWorkers(newReplicas); const res = await api.scaleK8sWorkers(newReplicas);
@@ -201,6 +215,7 @@ export default function TasksDashboard() {
setError(err.message || 'Failed to scale workers'); setError(err.message || 'Failed to scale workers');
} finally { } finally {
setScalingWorkers(false); setScalingWorkers(false);
setPendingScaleDelta(0);
} }
}; };
@@ -256,7 +271,8 @@ export default function TasksDashboard() {
return ( return (
<Layout> <Layout>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Sticky Header */}
<div className="sticky top-0 z-10 bg-white pb-4 -mx-6 px-6 pt-2 border-b border-gray-200 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2"> <h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
@@ -274,7 +290,7 @@ export default function TasksDashboard() {
<div className="flex items-center gap-2 px-3 py-2 bg-gray-100 rounded-lg"> <div className="flex items-center gap-2 px-3 py-2 bg-gray-100 rounded-lg">
<Server className="w-4 h-4 text-gray-500" /> <Server className="w-4 h-4 text-gray-500" />
<button <button
onClick={() => scaleWorkers(-5)} onClick={() => requestScaleWorkers(-5)}
disabled={scalingWorkers || workerReplicas === 0} disabled={scalingWorkers || workerReplicas === 0}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-50" className="p-1 hover:bg-gray-200 rounded disabled:opacity-50"
title="Remove 5 workers" title="Remove 5 workers"
@@ -285,7 +301,7 @@ export default function TasksDashboard() {
{workerReady}/{workerReplicas} {workerReady}/{workerReplicas}
</span> </span>
<button <button
onClick={() => scaleWorkers(5)} onClick={() => requestScaleWorkers(5)}
disabled={scalingWorkers || workerReplicas >= 50} disabled={scalingWorkers || workerReplicas >= 50}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-50" className="p-1 hover:bg-gray-200 rounded disabled:opacity-50"
title="Add 5 workers" title="Add 5 workers"
@@ -320,6 +336,7 @@ export default function TasksDashboard() {
<span className="text-sm text-gray-400">Auto-refreshes every 15s</span> <span className="text-sm text-gray-400">Auto-refreshes every 15s</span>
</div> </div>
</div> </div>
</div>
{error && ( {error && (
<div className="p-4 bg-red-50 text-red-700 rounded-lg">{error}</div> <div className="p-4 bg-red-50 text-red-700 rounded-lg">{error}</div>
@@ -576,6 +593,18 @@ export default function TasksDashboard() {
</div> </div>
</div> </div>
</div> </div>
{/* Password Confirmation Modal for Worker Scaling */}
<PasswordConfirmModal
isOpen={showConfirmModal}
onClose={() => {
setShowConfirmModal(false);
setPendingScaleDelta(0);
}}
onConfirm={executeScaleWorkers}
title="Confirm Worker Scaling"
description={`You are about to ${pendingScaleDelta > 0 ? 'add' : 'remove'} ${Math.abs(pendingScaleDelta)} workers (${workerReplicas}${Math.max(0, Math.min(50, workerReplicas + pendingScaleDelta))}). This action affects production infrastructure.`}
/>
</Layout> </Layout>
); );
} }

36
k8s/scraper-rbac.yaml Normal file
View File

@@ -0,0 +1,36 @@
# RBAC configuration for scraper pod to control worker scaling
# Allows the scraper to read and scale the scraper-worker deployment
apiVersion: v1
kind: ServiceAccount
metadata:
name: scraper-sa
namespace: dispensary-scraper
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: worker-scaler
namespace: dispensary-scraper
rules:
# Allow reading deployment status
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list"]
# Allow scaling deployments (read/write the scale subresource)
- apiGroups: ["apps"]
resources: ["deployments/scale"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: scraper-worker-scaler
namespace: dispensary-scraper
subjects:
- kind: ServiceAccount
name: scraper-sa
namespace: dispensary-scraper
roleRef:
kind: Role
name: worker-scaler
apiGroup: rbac.authorization.k8s.io

View File

@@ -25,6 +25,7 @@ spec:
labels: labels:
app: scraper app: scraper
spec: spec:
serviceAccountName: scraper-sa
imagePullSecrets: imagePullSecrets:
- name: regcred - name: regcred
containers: containers: