feat(auth,prompt-rule): add password change with session revocation and scope prompt rules by brand
Frontend CI / Frontend (push) Successful in 3m8s
Backend CI / Backend (push) Successful in 14m48s

Add self-service password change that re-hashes the credential and bumps a
per-user session version so all existing access/refresh tokens are rejected.
Session version is tracked in Redis with an in-memory fallback and enforced in
middleware and refresh.

Scope prompt rules and rule groups to a brand: new brand_id column (migrated to
a "未归属" fallback brand for existing rows), brand-filtered queries/indexes, and
brand-aware admin-web tabs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 18:09:53 +08:00
parent 9f9e4f8e19
commit e045e00fbf
41 changed files with 1135 additions and 167 deletions
@@ -57,7 +57,8 @@ const publishModalOpen = ref(false)
const selectedPublishArticleId = ref<number | null>(null)
const rulesQuery = useQuery({
queryKey: ['promptRules', 'simple'],
queryKey: computed(() => ['promptRules', 'simple', companyStore.currentBrandId]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => promptRulesApi.listSimple(),
})
@@ -246,6 +247,15 @@ watch(
{ immediate: true },
)
watch(
() => companyStore.currentBrandId,
() => {
draftFilters.prompt_rule_id = undefined
appliedFilters.prompt_rule_id = undefined
page.value = 1
},
)
onBeforeUnmount(() => {
stopPolling()
})
@@ -59,9 +59,11 @@ const form = reactive({
})
const isSchedule = computed(() => props.mode === 'schedule')
const currentBrandId = computed(() => companyStore.currentBrandId)
const rulesQuery = useQuery({
queryKey: ['promptRules', 'simple'],
queryKey: computed(() => ['promptRules', 'simple', currentBrandId.value]),
enabled: computed(() => props.open && Boolean(currentBrandId.value)),
queryFn: () => promptRulesApi.listSimple(),
})
@@ -110,7 +112,6 @@ const selectedSchedulePublishPlatformIds = computed(() => {
const scheduleCoverRequired = computed(() =>
coverUploadRequired(selectedSchedulePublishPlatformIds.value),
)
const currentBrandId = computed(() => companyStore.currentBrandId)
const effectiveScheduleCoverEnabled = computed(
() => scheduleCoverRequired.value || form.coverEnabled,
)
@@ -148,6 +149,10 @@ watch(
},
)
watch(currentBrandId, () => {
form.promptRuleId = undefined
})
const createScheduleMutation = useMutation({
mutationFn: () => schedulesApi.create(buildSchedulePayload()),
onSuccess: async () => {
@@ -41,7 +41,8 @@ const appliedFilters = reactive<{
})
const rulesQuery = useQuery({
queryKey: ['promptRules', 'simple'],
queryKey: computed(() => ['promptRules', 'simple', companyStore.currentBrandId]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => promptRulesApi.listSimple(),
})
@@ -175,6 +176,15 @@ watch(
{ immediate: true },
)
watch(
() => companyStore.currentBrandId,
() => {
draftFilters.prompt_rule_id = undefined
appliedFilters.prompt_rule_id = undefined
page.value = 1
},
)
onBeforeUnmount(() => {
stopPolling()
})
@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
import KnowledgeGroupSelect from '@/components/KnowledgeGroupSelect.vue'
import { promptRulesApi } from '@/lib/api'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const props = defineProps<{
open: boolean
@@ -22,6 +23,7 @@ const emit = defineEmits<{
const queryClient = useQueryClient()
const { t } = useI18n()
const companyStore = useCompanyStore()
const form = reactive({
name: '',
@@ -34,7 +36,8 @@ const form = reactive({
})
const groupsQuery = useQuery({
queryKey: ['promptRules', 'groups'],
queryKey: computed(() => ['promptRules', 'groups', companyStore.currentBrandId]),
enabled: computed(() => props.open && Boolean(companyStore.currentBrandId)),
queryFn: () => promptRulesApi.listGroups(),
})
@@ -56,6 +59,15 @@ watch(
},
)
watch(
() => companyStore.currentBrandId,
() => {
if (props.open) {
emit('update:open', false)
}
},
)
const createMutation = useMutation({
mutationFn: () =>
promptRulesApi.create({
@@ -16,9 +16,11 @@ import PromptRuleModal from '@/components/PromptRuleModal.vue'
import { promptRulesApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const queryClient = useQueryClient()
const { t } = useI18n()
const companyStore = useCompanyStore()
const selectedGroupId = ref<number | 'all' | 'ungrouped'>('all')
const ruleModalOpen = ref(false)
@@ -30,7 +32,8 @@ const page = ref(1)
const pageSize = ref(20)
const groupsQuery = useQuery({
queryKey: ['promptRules', 'groups'],
queryKey: computed(() => ['promptRules', 'groups', companyStore.currentBrandId]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => promptRulesApi.listGroups(),
})
@@ -48,7 +51,8 @@ const ruleListParams = computed(() => {
})
const rulesQuery = useQuery({
queryKey: computed(() => ['promptRules', 'list', ruleListParams.value]),
queryKey: computed(() => ['promptRules', 'list', companyStore.currentBrandId, ruleListParams.value]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => promptRulesApi.list(ruleListParams.value),
})
@@ -169,6 +173,17 @@ function handleTableChange(nextPage: number, nextPageSize: number): void {
watch(selectedGroupId, () => {
page.value = 1
})
watch(
() => companyStore.currentBrandId,
() => {
selectedGroupId.value = 'all'
editingRule.value = null
ruleModalOpen.value = false
groupModalOpen.value = false
page.value = 1
},
)
</script>
<template>
@@ -53,7 +53,8 @@ const appliedFilters = reactive<{
})
const rulesQuery = useQuery({
queryKey: ['promptRules', 'simple'],
queryKey: computed(() => ['promptRules', 'simple', companyStore.currentBrandId]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () => promptRulesApi.listSimple(),
})
@@ -214,6 +215,17 @@ watch(
{ immediate: true },
)
watch(
() => companyStore.currentBrandId,
() => {
draftFilters.prompt_rule_id = undefined
appliedFilters.prompt_rule_id = undefined
editingTask.value = null
modalOpen.value = false
page.value = 1
},
)
onBeforeUnmount(() => {
stopPolling()
})
+10
View File
@@ -140,6 +140,16 @@ const enUS = {
currentCompany: 'Company',
currentCompanyPlaceholder: 'Select company',
createCompany: 'Create company',
changePassword: 'Change password',
changePasswordTitle: 'Change password',
oldPassword: 'Current password',
newPassword: 'New password',
confirmNewPassword: 'Confirm new password',
passwordMinHint: 'At least 8 characters',
passwordValidation:
'Enter your current password and set the new password to at least 8 characters.',
passwordMismatch: 'The new passwords do not match.',
passwordChanged: 'Password updated. Please sign in again with the new password.',
openNavigation: 'Open navigation',
collapseNavigation: 'Collapse navigation',
expandNavigation: 'Expand navigation',
@@ -137,6 +137,15 @@ const zhCN = {
currentCompany: "当前公司",
currentCompanyPlaceholder: "选择公司",
createCompany: "创建公司",
changePassword: "修改密码",
changePasswordTitle: "修改密码",
oldPassword: "原密码",
newPassword: "新密码",
confirmNewPassword: "确认新密码",
passwordMinHint: "至少 8 位",
passwordValidation: "请输入原密码,并将新密码设为至少 8 位",
passwordMismatch: "两次输入的新密码不一致",
passwordChanged: "密码已更新,请使用新密码重新登录",
openNavigation: "打开导航",
collapseNavigation: "收起导航",
expandNavigation: "展开导航",
+121 -7
View File
@@ -9,6 +9,7 @@ import {
FileTextOutlined,
GlobalOutlined,
LineChartOutlined,
LockOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
@@ -24,16 +25,18 @@ import {
UserOutlined,
WalletOutlined,
} from '@ant-design/icons-vue'
import type { BrandLibrarySummary } from '@geo/shared-types'
import { ApiClientError } from '@geo/http-client'
import type { BrandLibrarySummary, ChangePasswordRequest } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, onBeforeUnmount, onMounted, reactive, ref, type Component, watch } from 'vue'
import { message } from 'ant-design-vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { brandsApi, workspaceApi } from '@/lib/api'
import { authApi, brandsApi, workspaceApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { browserSupportsPasswordCipher, encryptPassword } from '@/lib/password-cipher'
import { useAuthStore } from '@/stores/auth'
import { useCompanyStore } from '@/stores/company'
@@ -86,11 +89,17 @@ const popoverVisible = ref(false)
const searchQuery = ref('')
const currentFilter = ref<'created' | 'joined'>('created')
const brandModalOpen = ref(false)
const changePasswordModalOpen = ref(false)
const brandForm = reactive({
name: '',
website: '',
description: '',
})
const changePasswordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: '',
})
const brandLibrarySummary = computed<BrandLibrarySummary | null>(
() => brandLibrarySummaryQuery.data.value ?? null,
)
@@ -147,6 +156,7 @@ async function refreshBrandScopedData(): Promise<void> {
queryClient.invalidateQueries({ queryKey: ['tracking'] }),
queryClient.invalidateQueries({ queryKey: ['schedules'] }),
queryClient.invalidateQueries({ queryKey: ['instantTasks'] }),
queryClient.invalidateQueries({ queryKey: ['promptRules'] }),
])
}
@@ -203,6 +213,70 @@ async function submitBrand(): Promise<void> {
}
await createBrandMutation.mutateAsync()
}
function resetChangePasswordForm(): void {
changePasswordForm.oldPassword = ''
changePasswordForm.newPassword = ''
changePasswordForm.confirmPassword = ''
}
function openChangePasswordModal(): void {
resetChangePasswordForm()
changePasswordModalOpen.value = true
}
const changePasswordMutation = useMutation({
mutationFn: async () => authApi.changePassword(await buildChangePasswordPayload()),
onSuccess: async () => {
message.success(t('shell.passwordChanged'))
changePasswordModalOpen.value = false
resetChangePasswordForm()
await handleLogout()
},
onError: (error) => message.error(formatError(error)),
})
async function submitChangePassword(): Promise<void> {
if (!changePasswordForm.oldPassword || changePasswordForm.newPassword.length < 8) {
message.warning(t('shell.passwordValidation'))
return
}
if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) {
message.warning(t('shell.passwordMismatch'))
return
}
await changePasswordMutation.mutateAsync()
}
async function buildChangePasswordPayload(): Promise<ChangePasswordRequest> {
if (!browserSupportsPasswordCipher()) {
return {
old_password: changePasswordForm.oldPassword,
new_password: changePasswordForm.newPassword,
}
}
try {
const key = await authApi.passwordPublicKey()
const [encryptedOldPassword, encryptedNewPassword] = await Promise.all([
encryptPassword(changePasswordForm.oldPassword, key),
encryptPassword(changePasswordForm.newPassword, key),
])
return {
encrypted_old_password: encryptedOldPassword,
encrypted_new_password: encryptedNewPassword,
password_key_id: key.key_id,
}
} catch (error) {
if (error instanceof ApiClientError && error.status === 404) {
return {
old_password: changePasswordForm.oldPassword,
new_password: changePasswordForm.newPassword,
}
}
throw error
}
}
const membershipExpiryText = computed(() => {
const value = authStore.membership?.end_at
if (!value) {
@@ -673,12 +747,20 @@ onBeforeUnmount(() => {
>
{{ t('nav.workspace') }}
</a-menu-item>
<a-menu-item
key="change-password"
class="user-dropdown-item"
@click="openChangePasswordModal"
>
<LockOutlined class="user-dropdown-item-icon" />
{{ t('shell.changePassword') }}
</a-menu-item>
<a-menu-item
key="logout"
class="user-dropdown-item user-dropdown-logout"
@click="handleLogout"
>
<LogoutOutlined class="user-dropdown-logout-icon" />
<LogoutOutlined class="user-dropdown-item-icon user-dropdown-logout-icon" />
{{ t('shell.logout') }}
</a-menu-item>
</a-menu>
@@ -756,6 +838,38 @@ onBeforeUnmount(() => {
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="changePasswordModalOpen"
:title="t('shell.changePasswordTitle')"
centered
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
:confirm-loading="changePasswordMutation.isPending.value"
@ok="submitChangePassword"
@cancel="resetChangePasswordForm"
>
<a-form layout="vertical" :model="changePasswordForm" @submit.prevent="submitChangePassword">
<a-form-item :label="t('shell.oldPassword')" required>
<a-input-password
v-model:value="changePasswordForm.oldPassword"
autocomplete="current-password"
/>
</a-form-item>
<a-form-item :label="t('shell.newPassword')" :help="t('shell.passwordMinHint')" required>
<a-input-password
v-model:value="changePasswordForm.newPassword"
autocomplete="new-password"
/>
</a-form-item>
<a-form-item :label="t('shell.confirmNewPassword')" required>
<a-input-password
v-model:value="changePasswordForm.confirmPassword"
autocomplete="new-password"
/>
</a-form-item>
</a-form>
</a-modal>
</a-layout>
</template>
@@ -1337,12 +1451,12 @@ onBeforeUnmount(() => {
margin-bottom: 4px;
}
.user-dropdown-logout {
color: #ff4d4f;
.user-dropdown-item-icon {
margin-right: 8px;
}
.user-dropdown-logout-icon {
margin-right: 8px;
.user-dropdown-logout {
color: #ff4d4f;
}
.admin-content {
+5
View File
@@ -11,6 +11,7 @@ import type {
Brand,
BrandLibrarySummary,
BrandRequest,
ChangePasswordRequest,
Competitor,
CompetitorRequest,
ComplianceAckRecord,
@@ -322,6 +323,7 @@ const currentBrandScopedPathPatterns = [
/^\/api\/tenant\/instant-tasks(?:\/|$)/,
/^\/api\/tenant\/publish-jobs(?:\/|$)/,
/^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/,
/^\/api\/tenant\/prompt-rules(?:\/|$)/,
]
function shouldAttachCurrentBrandHeader(url: unknown): boolean {
@@ -406,6 +408,9 @@ export const authApi = {
me() {
return apiClient.get<UserInfo>('/api/auth/me')
},
changePassword(payload: ChangePasswordRequest) {
return apiClient.post<{ ok: boolean }, ChangePasswordRequest>('/api/auth/password', payload)
},
logout() {
return apiClient.post<null>('/api/auth/logout')
},
+2
View File
@@ -15,6 +15,8 @@ const authSessionErrorMessages = new Set([
const errorMessageMap: Record<string, string> = {
invalid_credentials: '邮箱或密码错误',
invalid_old_password: '原密码错误',
weak_password: '新密码至少 8 位',
login_rate_limited: '登录请求过于频繁',
login_locked: '登录已被临时保护',
login_in_progress: '登录请求正在处理中',
+11 -4
View File
@@ -10,16 +10,23 @@ export async function encryptPasswordForLogin(
password: string,
key: PasswordPublicKeyResponse,
): Promise<{ encrypted_password: string; password_key_id: string }> {
return {
encrypted_password: await encryptPassword(password, key),
password_key_id: key.key_id,
}
}
export async function encryptPassword(
password: string,
key: PasswordPublicKeyResponse,
): Promise<string> {
const cryptoKey = await importRSAOAEPKey(key.public_key)
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
cryptoKey,
textEncoder.encode(password),
)
return {
encrypted_password: arrayBufferToBase64(encrypted),
password_key_id: key.key_id,
}
return arrayBufferToBase64(encrypted)
}
async function importRSAOAEPKey(publicKeyPEM: string): Promise<CryptoKey> {
+5 -1
View File
@@ -191,7 +191,11 @@ export const useAuthStore = defineStore('auth', () => {
async function logout(): Promise<void> {
try {
if (accessToken.value) {
await authApi.logout()
try {
await authApi.logout()
} catch {
// Local logout must still complete when the server has already revoked the token.
}
}
} finally {
clearStoredSession()