feat: harden auth and study workflows

- replace plaintext login and unlock requests with RSA-OAEP/AES-GCM encrypted payloads

- add login challenge replay protection, production RSA key validation, and auth tests

- wire compose to environment-driven dev/prod settings without committing local secrets

- update setup-config smoke scripts and Postman docs for encrypted login

- add visit schedule migrations/tests and update study/subject setup workflows
This commit is contained in:
Cheng Zhou
2026-05-08 22:13:12 +08:00
parent a7bbcaa5dc
commit 74feca4467
47 changed files with 2423 additions and 534 deletions
+100 -38
View File
@@ -26,7 +26,7 @@
</div>
<el-card class="unified-shell subject-shell" v-loading="loading">
<el-descriptions :column="2" border>
<el-descriptions class="subject-overview-descriptions" :column="2" border>
<el-descriptions-item :label="TEXT.modules.subjectManagement.screeningNo">{{ detail.subject_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.site">{{ siteMap[detail.site_id] || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
@@ -59,6 +59,16 @@
/>
<span v-else>{{ displayDate(detail.enrollment_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.baselineDate">
<el-date-picker
v-if="subjectEditing"
v-model="subjectForm.baseline_date"
type="date"
value-format="YYYY-MM-DD"
size="small"
/>
<span v-else>{{ displayDate(detail.baseline_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.completionDate">
<el-date-picker
v-if="subjectEditing"
@@ -76,14 +86,14 @@
</el-descriptions>
</el-card>
<el-card class="unified-shell subject-shell">
<el-card class="unified-shell subject-shell subject-tabs-shell">
<div class="subject-tabs-action">
<el-button type="primary" :disabled="isReadOnly" @click="currentTabAction.onClick">
{{ currentTabAction.label }}
</el-button>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.history" name="history">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openHistoryDialog()">
{{ TEXT.common.actions.newHistory }}
</el-button>
</div>
<el-table :data="historyItems" v-loading="loadingHistory" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="record_date" :label="TEXT.common.fields.recordDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.record_date) }}</template>
@@ -118,11 +128,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.visits" name="visits">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openVisitDialog()">
{{ TEXT.common.actions.newVisit }}
</el-button>
</div>
<el-table :data="visitItems" v-loading="loadingVisits" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="visit_code" :label="TEXT.common.fields.visitCode" show-overflow-tooltip />
<el-table-column prop="planned_date" :label="TEXT.common.fields.plannedDate" show-overflow-tooltip>
@@ -201,11 +206,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.ae" name="ae">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openAeDialog()">
{{ TEXT.common.actions.newAe }}
</el-button>
</div>
<el-table :data="aeItems" v-loading="loadingAes" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="onset_date" :label="TEXT.common.fields.occurDate" show-overflow-tooltip>
<template #default="scope">{{ displayDate(scope.row.onset_date) }}</template>
@@ -259,11 +259,6 @@
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.subjectDetail.tabs.pd" name="pd">
<div class="tab-actions">
<el-button type="primary" :disabled="isReadOnly" @click="openPdDialog()">
{{ TEXT.modules.subjectDetail.newPd }}
</el-button>
</div>
<el-table :data="pdItems" v-loading="loadingPds" style="width: 100%" class="subject-detail-table" table-layout="fixed">
<el-table-column prop="pd_no" :label="TEXT.common.fields.pdNo" show-overflow-tooltip />
<el-table-column prop="pd_type" :label="TEXT.common.fields.pdType" show-overflow-tooltip />
@@ -455,6 +450,7 @@ const detail = reactive<any>({
screening_date: "",
consent_date: "",
enrollment_date: "",
baseline_date: "",
completion_date: "",
drop_reason: "",
});
@@ -464,6 +460,7 @@ const subjectSaving = ref(false);
const subjectForm = reactive({
status: "SCREENING",
enrollment_date: "",
baseline_date: "",
consent_date: "",
completion_date: "",
drop_reason: "",
@@ -615,6 +612,7 @@ const startSubjectEdit = () => {
subjectForm.status = detail.status || "SCREENING";
subjectForm.consent_date = detail.consent_date || "";
subjectForm.enrollment_date = detail.enrollment_date || "";
subjectForm.baseline_date = detail.baseline_date || "";
subjectForm.completion_date = detail.completion_date || "";
subjectForm.drop_reason = detail.drop_reason || "";
subjectEditing.value = true;
@@ -636,13 +634,15 @@ const saveSubjectEdit = async () => {
status: subjectForm.status || null,
consent_date: subjectForm.consent_date || null,
enrollment_date: subjectForm.enrollment_date || null,
baseline_date: subjectForm.baseline_date || null,
completion_date: subjectForm.completion_date || null,
drop_reason: subjectForm.drop_reason || null,
};
await updateSubject(studyId, subjectId, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
subjectEditing.value = false;
loadSubject();
await loadSubject();
await loadVisits();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
@@ -720,15 +720,7 @@ const loadVisits = async () => {
try {
const { data } = (await fetchVisits(studyId, subjectId)) as any;
const items = Array.isArray(data) ? data : data.items || [];
visitItems.value = items.sort((a: any, b: any) => {
const getNum = (code: any) => {
const text = String(code || "");
if (!text.startsWith("V")) return Number.POSITIVE_INFINITY;
const num = Number(text.slice(1));
return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY;
};
return getNum(a?.visit_code) - getNum(b?.visit_code);
});
visitItems.value = items;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
@@ -1036,6 +1028,20 @@ const openPdDialog = (row?: any) => {
pdDialogVisible.value = true;
};
const currentTabAction = computed(() => {
switch (activeTab.value) {
case "visits":
return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() };
case "ae":
return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() };
case "pd":
return { label: TEXT.modules.subjectDetail.newPd, onClick: () => openPdDialog() };
case "history":
default:
return { label: TEXT.common.actions.newHistory, onClick: () => openHistoryDialog() };
}
});
const savePd = async () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
@@ -1123,6 +1129,68 @@ onMounted(async () => {
padding: 0;
}
.subject-tabs-shell {
margin-top: 12px;
border-top: 8px solid #f3f6fb;
}
.subject-tabs-shell {
position: relative;
}
.subject-tabs-shell :deep(.el-tabs__header) {
margin: 0;
padding: 0 160px 0 0;
background: #ffffff;
border-top: 1px solid #e5edf6;
border-bottom: 1px solid #e5edf6;
}
.subject-tabs-shell :deep(.el-tabs__nav-wrap) {
padding-left: 0;
}
.subject-tabs-shell :deep(.el-tabs__item) {
height: 48px;
line-height: 48px;
font-weight: 700;
}
.subject-tabs-shell :deep(.el-tabs__content) {
padding-top: 0;
background: #ffffff;
}
.subject-tabs-action {
position: absolute;
top: 8px;
right: 0;
z-index: 2;
display: flex;
align-items: center;
}
.subject-overview-descriptions :deep(.el-descriptions__table) {
table-layout: fixed;
}
.subject-overview-descriptions :deep(.el-descriptions__label) {
width: 140px;
min-width: 140px;
color: #344258;
font-weight: 700;
}
.subject-overview-descriptions :deep(.el-descriptions__content) {
width: calc(50% - 140px);
min-width: 0;
}
.subject-overview-descriptions :deep(.el-descriptions__cell) {
height: 42px;
vertical-align: middle;
}
.page-title {
margin: 0;
font-size: 22px;
@@ -1140,12 +1208,6 @@ onMounted(async () => {
gap: 8px;
}
.tab-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.subject-detail-table :deep(.el-table__inner-wrapper::before) {
display: none;
}