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