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
+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 {