feat(auth,prompt-rule): add password change with session revocation and scope prompt rules by brand
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:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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: "展开导航",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
},
|
||||
|
||||
@@ -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: '登录请求正在处理中',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user