Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 168 additions & 2 deletions apps/web/src/app/system/users/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client'

import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Users, Search, Shield, CheckCircle2, XCircle,
Eye, Key, Clock, Loader2, AlertCircle
Eye, Key, Clock, Loader2, AlertCircle, UserPlus, Mail
} from 'lucide-react'
import { limsAPI, getErrorMessage, type LimsUser } from '@/lib/api-client'
import { Button } from '@/components/ui/button'
Expand Down Expand Up @@ -59,6 +59,49 @@ export default function UsersPage() {
const [selectedUser, setSelectedUser] = useState<UserVM | null>(null)
const [showDetailsDialog, setShowDetailsDialog] = useState(false)

// Phase 7b: admin actions (invite + role/status changes). The backend
// enforces admin-only and self-protection; UI errors surface verbatim.
const queryClient = useQueryClient()
const [showInviteDialog, setShowInviteDialog] = useState(false)
const [inviteEmail, setInviteEmail] = useState('')
const [inviteName, setInviteName] = useState('')
const [inviteRole, setInviteRole] = useState('viewer')
const [inviteSent, setInviteSent] = useState(false)
const [actionError, setActionError] = useState('')

const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['lims', 'users'] })

const inviteMutation = useMutation({
mutationFn: () =>
limsAPI.users.create({ email: inviteEmail, name: inviteName, role: inviteRole }),
onSuccess: () => {
setInviteSent(true)
setActionError('')
invalidateUsers()
},
onError: (err) => setActionError(getErrorMessage(err)),
})

const updateMutation = useMutation({
mutationFn: (vars: { id: string; data: { role?: string; is_active?: boolean } }) =>
limsAPI.users.update(vars.id, vars.data),
onSuccess: (updated) => {
setActionError('')
setSelectedUser(toUserVM(updated))
invalidateUsers()
},
onError: (err) => setActionError(getErrorMessage(err)),
})

const openInvite = () => {
setInviteEmail('')
setInviteName('')
setInviteRole('viewer')
setInviteSent(false)
setActionError('')
setShowInviteDialog(true)
}

// Real users from the LIMS service (read-only list).
const {
data: rawUsers,
Expand Down Expand Up @@ -145,6 +188,10 @@ export default function UsersPage() {
<p className="text-gray-600 mt-1">Manage user accounts and permissions</p>
</div>
</div>
<Button onClick={openInvite} data-testid="invite-user-button">
<UserPlus className="w-4 h-4 mr-2" />
Invite user
</Button>
</div>

{/* Statistics */}
Expand Down Expand Up @@ -364,11 +411,130 @@ export default function UsersPage() {
</div>
)}

{selectedUser && (
<div className="border-t pt-4 space-y-3">
<Label className="text-gray-600">Admin actions</Label>
{actionError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800" data-testid="admin-action-error">
{actionError}
</div>
)}
<div className="flex items-center gap-3">
<Select
value={selectedUser.role}
onValueChange={(role) =>
updateMutation.mutate({ id: selectedUser.id, data: { role } })
}
>
<SelectTrigger className="w-44" data-testid="role-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['admin', 'pi', 'engineer', 'technician', 'viewer'].map((r) => (
<SelectItem key={r} value={r}>{titleCase(r)}</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={selectedUser.status === 'Active' ? 'destructive' : 'default'}
disabled={updateMutation.isPending}
data-testid="toggle-active-button"
onClick={() =>
updateMutation.mutate({
id: selectedUser.id,
data: { is_active: selectedUser.status !== 'Active' },
})
}
>
{selectedUser.status === 'Active' ? 'Deactivate' : 'Reactivate'}
</Button>
</div>
<p className="text-xs text-gray-500">
Role and status changes apply immediately. You cannot demote or
deactivate your own account.
</p>
</div>
)}

<DialogFooter>
<Button onClick={() => setShowDetailsDialog(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Invite Dialog (Phase 7b) */}
<Dialog open={showInviteDialog} onOpenChange={setShowInviteDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Invite a user</DialogTitle>
</DialogHeader>
{inviteSent ? (
<div className="rounded-md bg-green-50 p-4 text-sm text-green-800" data-testid="invite-sent">
<Mail className="w-4 h-4 inline mr-1" />
Invitation created for <strong>{inviteEmail}</strong>. They&apos;ll
receive a set-password link valid for 7 days.
</div>
) : (
<div className="space-y-4">
{actionError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800">
{actionError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="person@yourlab.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-name">Name</Label>
<Input
id="invite-name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
placeholder="Full name"
/>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger data-testid="invite-role-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{['admin', 'pi', 'engineer', 'technician', 'viewer'].map((r) => (
<SelectItem key={r} value={r}>{titleCase(r)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter>
{inviteSent ? (
<Button onClick={() => setShowInviteDialog(false)}>Done</Button>
) : (
<>
<Button variant="outline" onClick={() => setShowInviteDialog(false)}>
Cancel
</Button>
<Button
disabled={!inviteEmail || !inviteName || inviteMutation.isPending}
onClick={() => inviteMutation.mutate()}
data-testid="send-invite-button"
>
{inviteMutation.isPending ? 'Inviting…' : 'Send invite'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
15 changes: 15 additions & 0 deletions apps/web/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,21 @@ export const limsAPI = {
// Users & roles directory (org-scoped). Backend: services/lims/app/api/users.py
users: {
list: () => fetchAPI<LimsUser[]>('lims', '/api/v1/lims/users'),
// Phase 7b: admin-only. Create = invite (backend emails the
// set-password link); update covers name/role/is_active.
create: (data: { email: string; name: string; role: string }) =>
fetchAPI<LimsUser>('lims', '/api/v1/lims/users', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (
userId: string,
data: { name?: string; role?: string; is_active?: boolean }
) =>
fetchAPI<LimsUser>('lims', `/api/v1/lims/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
},

// 21 CFR Part 11 e-signature ledger. Backend: services/lims/app/api/signatures.py
Expand Down
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ services:
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET env var is required (see .env.example)}
- JWT_ALGORITHM=HS256
- JWT_ISSUER=spectra-lab
# Outbound email (Phase 7 — account lifecycle). Unset SMTP_HOST =
# dev mode: emails are logged, not sent (reset links readable via
# `docker logs spectra-lims`).
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USERNAME=${SMTP_USERNAME:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_STARTTLS=${SMTP_STARTTLS:-true}
- SMTP_FROM=${SMTP_FROM:-no-reply@spectra-lab.local}
- APP_BASE_URL=${APP_BASE_URL:-http://localhost:3012}
# Phase 6.6: env-driven SSO. Off by default; flip via .env — see
# infra/keycloak/README.md. Issuer/JWKS use the compose-internal
# keycloak DNS so in-container JWKS fetches work; override for k8s.
Expand Down
1 change: 1 addition & 0 deletions docs/deployment/PRODUCTION_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Related docs: [SECRETS.md](SECRETS.md) (Sealed Secrets flow),
| Object storage | Start with in-cluster MinIO (ships in `k8s/base/minio.yaml`). Swap `OBJECT_STORE_ENDPOINT` to managed S3 later — the app speaks plain S3 either way. |
| SSO | Skip at first (`OIDC_ENABLED=false`, built-in auth). Add Keycloak later per AUTH.md. |
| Domain | You need one you control, plus the ability to add DNS records. |
| Email (SMTP) | Optional at first: without an `smtp-credentials` secret, reset/invite emails are logged by the lims pod instead of sent. Any relay works (SES, Mailgun, your org's). See `k8s/base/secrets/smtp-credentials-template.yaml`. |

Rough monthly cost at the small end (DO): 3-node cluster (~$72) +
load balancer (~$12) + volumes (~$5) ≈ **$90/month**.
Expand Down
26 changes: 26 additions & 0 deletions k8s/base/secrets/smtp-credentials-template.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# TEMPLATE — do not apply as-is. OPTIONAL secret: without it the platform
# still runs, but reset/invite emails are logged by the lims pod instead of
# sent (`kubectl logs deploy/lims-service | grep "EMAIL NOT SENT"`).
#
# Any SMTP relay works (SES, Mailgun, Postmark, your org's relay). Create
# it with real values, or seal it per docs/deployment/SECRETS.md:
#
# kubectl create secret generic smtp-credentials \
# --namespace spectra-lab \
# --from-literal=host=smtp.example.com \
# --from-literal=port=587 \
# --from-literal=username=<smtp-user> \
# --from-literal=password=<smtp-password> \
# --from-literal=from=no-reply@yourdomain.com
apiVersion: v1
kind: Secret
metadata:
name: smtp-credentials
namespace: spectra-lab
type: Opaque
stringData:
host: smtp.example.com
port: "587"
username: CHANGE_ME
password: CHANGE_ME_IN_PRODUCTION
from: no-reply@example.com
36 changes: 36 additions & 0 deletions k8s/base/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,42 @@ spec:
secretKeyRef:
name: minio-credentials
key: root-password
# Outbound email (Phase 7 — account lifecycle). APP_BASE_URL is
# the public web URL used in reset/invite links — set it to your
# real host (runbook step 4). SMTP creds come from the OPTIONAL
# smtp-credentials secret: absent = emails logged, not sent.
- name: APP_BASE_URL
value: "https://spectra-lab.example.com"
- name: SMTP_HOST
valueFrom:
secretKeyRef:
name: smtp-credentials
key: host
optional: true
- name: SMTP_PORT
valueFrom:
secretKeyRef:
name: smtp-credentials
key: port
optional: true
- name: SMTP_USERNAME
valueFrom:
secretKeyRef:
name: smtp-credentials
key: username
optional: true
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: smtp-credentials
key: password
optional: true
- name: SMTP_FROM
valueFrom:
secretKeyRef:
name: smtp-credentials
key: from
optional: true
resources:
requests:
memory: "256Mi"
Expand Down
Loading
Loading