feature: RBAC + Clinical Audit Logging

This commit is contained in:
voltsrage
2026-06-21 15:46:55 +08:00
parent a43db52813
commit 5af6ab490e
83 changed files with 4281 additions and 70 deletions
+4 -3
View File
@@ -14,7 +14,7 @@ npm install
npm run dev
```
Open http://localhost:5173
Open http://localhost:5173 — you will be redirected to `/login`. Demo credentials are seeded by the API (see repo root README / Phase 31 plan).
Optional `.env`:
@@ -35,6 +35,7 @@ VITE_API_URL=http://localhost:5270
| Path | View |
|---|---|
| `/login` | Sign in — JWT auth against the clinical API |
| `/ward` | Virtual Ward — active patients by NEWS2 risk |
| `/patients/:encounterId` | Patient detail — vitals, alerts, charts, replay, reasoning |
| `/alerts` | Alert Center — global alert inbox with feedback buttons |
@@ -73,8 +74,8 @@ src/
│ ├── ui/ # Button, Badge, Card, Modal, EmptyState, Skeleton
│ └── ward/ # WardTable, PatientRow, PatientCard
├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, chartFormat
├── stores/ # ward, alerts, settings, feedback (localStorage persistence)
├── views/ # WardDashboard, PatientDetail, AlertCenter, FeedbackSummary
├── stores/ # ward, alerts, auth, settings, feedback (localStorage persistence)
├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary
└── __tests__/ # Vitest — store, composables, components, views
```
+7 -1
View File
@@ -1,9 +1,14 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppShell from '@/components/layout/AppShell.vue'
const route = useRoute()
const useShell = computed(() => !route.meta.public)
</script>
<template>
<AppShell>
<AppShell v-if="useShell">
<RouterView v-slot="{ Component }">
<KeepAlive include="WardDashboard">
<Transition name="fade" mode="out-in">
@@ -12,6 +17,7 @@ import AppShell from '@/components/layout/AppShell.vue'
</KeepAlive>
</RouterView>
</AppShell>
<RouterView v-else />
</template>
<style>
+2 -2
View File
@@ -12,8 +12,8 @@ export function fetchAllAlerts(status) {
return api.get(`/api/v1/alerts?${params}`)
}
export function acknowledgeAlert(alertId, clinicianId, note) {
return api.post(`/api/v1/alerts/${alertId}/acknowledge`, { clinicianId, note })
export function acknowledgeAlert(alertId, note) {
return api.post(`/api/v1/alerts/${alertId}/acknowledge`, { note })
}
export function resolveAlert(alertId) {
+22 -4
View File
@@ -1,10 +1,25 @@
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5270'
let authToken = null
export function setAuthToken(token) {
authToken = token
}
function authHeaders(extra = {}) {
const headers = { 'Content-Type': 'application/json', ...extra }
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
return headers
}
async function request(path, options = {}) {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
headers: authHeaders(options.headers),
...options,
})
if (res.status === 401) {
throw new Error('Session expired — please log in again.')
}
const envelope = await res.json()
if (!res.ok || !envelope.success) {
const msg = envelope.error?.message ?? `API ${res.status}: ${path}`
@@ -16,10 +31,13 @@ async function request(path, options = {}) {
/** Returns null when the resource does not exist yet (HTTP 404 or empty optional payload). */
async function requestOptional(path) {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json' },
headers: authHeaders(),
})
const envelope = await res.json()
if (res.status === 404) return null
if (res.status === 401) {
throw new Error('Session expired — please log in again.')
}
const envelope = await res.json()
if (!res.ok || !envelope.success) {
const msg = envelope.error?.message ?? `API ${res.status}: ${path}`
throw new Error(msg)
@@ -34,4 +52,4 @@ export const api = {
method: 'POST',
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
}
}
@@ -2,7 +2,6 @@
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useAlertStore } from '@/stores/alerts'
import { useSettingsStore } from '@/stores/settings'
import { usePolling } from '@/composables/usePolling'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
@@ -18,7 +17,6 @@ const props = defineProps({
const emit = defineEmits(['select'])
const alertStore = useAlertStore()
const settings = useSettingsStore()
const { alerts, loading } = storeToRefs(alertStore)
function loadEncounterAlerts() {
@@ -36,7 +34,7 @@ function severityVariant(severity) {
}
async function acknowledge(alertId) {
await alertStore.acknowledge(alertId, settings.clinicianId)
await alertStore.acknowledge(alertId)
await loadEncounterAlerts()
}
+8 -1
View File
@@ -3,5 +3,12 @@ import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import router from './router'
import { useAuthStore } from '@/stores/auth'
createApp(App).use(createPinia()).use(router).mount('#app')
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
useAuthStore(pinia).hydrate()
app.use(router)
app.mount('#app')
+16 -1
View File
@@ -1,10 +1,17 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/',
redirect: '/ward',
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginView.vue'),
meta: { title: 'Sign In', public: true },
},
{
path: '/ward',
name: 'WardDashboard',
@@ -38,6 +45,14 @@ const router = createRouter({
router.beforeEach((to) => {
document.title = `${to.meta.title ?? 'VigilCare'} — VigilCare`
const auth = useAuthStore()
if (!to.meta.public && !auth.isAuthenticated) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (to.path === '/login' && auth.isAuthenticated) {
return { path: '/ward' }
}
})
export default router
export default router
+2 -2
View File
@@ -35,8 +35,8 @@ export const useAlertStore = defineStore('alerts', () => {
}
}
async function acknowledge(alertId, clinicianId, note) {
await alertsApi.acknowledgeAlert(alertId, clinicianId, note)
async function acknowledge(alertId, note) {
await alertsApi.acknowledgeAlert(alertId, note)
const alert = alerts.value.find(a => a.id === alertId)
if (alert) alert.status = 'Acknowledged'
}
+42
View File
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { api, setAuthToken } from '@/api/client'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('vigilcare_token') ?? null,
user: JSON.parse(localStorage.getItem('vigilcare_user') ?? 'null'),
}),
getters: {
isAuthenticated: (state) => !!state.token,
role: (state) => state.user?.role ?? null,
},
actions: {
async login(username, password) {
const data = await api.post('/api/v1/auth/login', { username, password })
this.token = data.accessToken
this.user = {
userId: data.userId,
username: data.username,
displayName: data.displayName,
role: data.role,
}
localStorage.setItem('vigilcare_token', this.token)
localStorage.setItem('vigilcare_user', JSON.stringify(this.user))
setAuthToken(this.token)
},
logout() {
this.token = null
this.user = null
localStorage.removeItem('vigilcare_token')
localStorage.removeItem('vigilcare_user')
setAuthToken(null)
},
hydrate() {
if (this.token) setAuthToken(this.token)
},
},
})
@@ -2,7 +2,6 @@
import { ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useAlertStore } from '@/stores/alerts'
import { useSettingsStore } from '@/stores/settings'
import { alertStatusToApiFilter } from '@/api/normalize'
import AlertCard from '@/components/alerts/AlertCard.vue'
import AlertFilters from '@/components/alerts/AlertFilters.vue'
@@ -11,7 +10,6 @@ import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
const alertStore = useAlertStore()
const settings = useSettingsStore()
const { alerts, loading } = storeToRefs(alertStore)
const activeFilter = ref('Open')
@@ -23,7 +21,7 @@ watch(activeFilter, (status) => {
async function handleAcknowledge() {
if (!confirmingAlert.value) return
await alertStore.acknowledge(confirmingAlert.value.id, settings.clinicianId)
await alertStore.acknowledge(confirmingAlert.value.id)
confirmingAlert.value = null
alertStore.loadGlobalAlerts(alertStatusToApiFilter(activeFilter.value))
}
@@ -0,0 +1,77 @@
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Button from '@/components/ui/Button.vue'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function submit() {
error.value = ''
loading.value = true
try {
await auth.login(username.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/ward'
await router.push(redirect)
} catch (e) {
error.value = e.message ?? 'Login failed'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
<form
class="w-full max-w-sm space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900"
@submit.prevent="submit"
>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">VigilCare</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Sign in to continue</p>
</div>
<label class="block">
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Username
</span>
<input
v-model="username"
type="text"
autocomplete="username"
required
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
</label>
<label class="block">
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Password
</span>
<input
v-model="password"
type="password"
autocomplete="current-password"
required
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-700 dark:bg-gray-950 dark:text-white"
/>
</label>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400" role="alert">
{{ error }}
</p>
<Button type="submit" variant="primary" class="w-full" :disabled="loading">
{{ loading ? 'Signing in…' : 'Sign in' }}
</Button>
</form>
</div>
</template>