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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user