diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 502bf151..32ef07e0 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -102,6 +102,11 @@ export async function bindEmailIdentity(payload: { return data } +export async function unbindAuthIdentity(provider: BindableOAuthProvider): Promise { + const { data } = await apiClient.delete(`/user/account-bindings/${provider}`) + return data +} + export type BindableOAuthProvider = Exclude interface BuildOAuthBindingStartURLOptions { @@ -173,6 +178,7 @@ export const userAPI = { toggleNotifyEmail, sendEmailBindingCode, bindEmailIdentity, + unbindAuthIdentity, buildOAuthBindingStartURL, startOAuthBinding } diff --git a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue index 96ad7a1c..848789d9 100644 --- a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue +++ b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue @@ -21,14 +21,11 @@ {{ t('profile.authBindings.description') }}

+
@@ -45,7 +42,7 @@ {{ providerInitial(item.provider) }}
-
+

{{ item.label }} @@ -64,14 +61,36 @@

{{ providerSummary(item.provider) }}

+

+ {{ item.details.display_name }} +

+

+ {{ item.details.subject_hint }} +

+

+ {{ bindingCountLabel(item.details) }} +

+

+ {{ item.details.note }} +

+
+ +
-
+
+ +

@@ -155,11 +201,18 @@ import { resolveWeChatOAuthStartStrict, type WeChatOAuthPublicSettings, } from '@/api/auth' -import { bindEmailIdentity, sendEmailBindingCode, startOAuthBinding } from '@/api/user' +import { + bindEmailIdentity, + sendEmailBindingCode, + startOAuthBinding, + unbindAuthIdentity, +} from '@/api/user' import Icon from '@/components/icons/Icon.vue' import { useAppStore, useAuthStore } from '@/stores' import type { User, UserAuthBindingStatus, UserAuthProvider } from '@/types' +type BindableProvider = Exclude + const props = withDefaults( defineProps<{ user: User | null @@ -170,6 +223,7 @@ const props = withDefaults( wechatOpenEnabled?: boolean wechatMpEnabled?: boolean embedded?: boolean + compact?: boolean }>(), { linuxdoEnabled: false, @@ -179,6 +233,7 @@ const props = withDefaults( wechatOpenEnabled: undefined, wechatMpEnabled: undefined, embedded: false, + compact: false, } ) @@ -190,6 +245,8 @@ const authStore = useAuthStore() const localUser = ref(null) const isSendingEmailCode = ref(false) const isBindingEmail = ref(false) +const isEmailFormExpanded = ref(!props.compact) +const unbindingProvider = ref(null) const emailBindingForm = reactive({ email: '', verifyCode: '', @@ -210,8 +267,27 @@ watch( { immediate: true } ) +watch( + () => props.compact, + (value) => { + if (!value) { + isEmailFormExpanded.value = true + } + }, + { immediate: true } +) + const currentUser = computed(() => localUser.value ?? props.user) +const compact = computed(() => props.compact) +const rowClass = computed(() => + props.embedded + ? compact.value + ? 'rounded-2xl border border-gray-100 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-900/40' + : 'rounded-2xl border border-gray-100 bg-gray-50/70 p-4 dark:border-dark-700 dark:bg-dark-900/30' + : 'px-6 py-5' +) const emailBound = computed(() => getBindingStatus('email')) +const showEmailForm = computed(() => !compact.value || isEmailFormExpanded.value) const emailPasswordPlaceholder = computed(() => emailBound.value ? t('profile.authBindings.replaceEmailPasswordPlaceholder') @@ -278,30 +354,46 @@ function getBindingStatusForUser(user: User | null | undefined, provider: UserAu return normalized ?? false } +function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus | null { + const binding = currentUser.value?.auth_bindings?.[provider] ?? currentUser.value?.identity_bindings?.[provider] + if (!binding || typeof binding === 'boolean') { + return null + } + return binding +} + const providerItems = computed(() => [ { provider: 'email' as const, label: t('profile.authBindings.providers.email'), bound: getBindingStatus('email'), canBind: false, + canUnbind: false, + details: getBindingDetails('email'), }, { provider: 'linuxdo' as const, label: t('profile.authBindings.providers.linuxdo'), bound: getBindingStatus('linuxdo'), - canBind: props.linuxdoEnabled && !getBindingStatus('linuxdo'), + canBind: getBindingDetails('linuxdo')?.can_bind ?? (props.linuxdoEnabled && !getBindingStatus('linuxdo')), + canUnbind: Boolean(getBindingStatus('linuxdo') && getBindingDetails('linuxdo')?.can_unbind), + details: getBindingDetails('linuxdo'), }, { provider: 'oidc' as const, label: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }), bound: getBindingStatus('oidc'), - canBind: props.oidcEnabled && !getBindingStatus('oidc'), + canBind: getBindingDetails('oidc')?.can_bind ?? (props.oidcEnabled && !getBindingStatus('oidc')), + canUnbind: Boolean(getBindingStatus('oidc') && getBindingDetails('oidc')?.can_unbind), + details: getBindingDetails('oidc'), }, { provider: 'wechat' as const, label: t('profile.authBindings.providers.wechat'), bound: getBindingStatus('wechat'), - canBind: resolvedWeChatBinding.value.mode !== null && !getBindingStatus('wechat'), + canBind: getBindingDetails('wechat')?.can_bind ?? (resolvedWeChatBinding.value.mode !== null && !getBindingStatus('wechat')), + canUnbind: Boolean(getBindingStatus('wechat') && getBindingDetails('wechat')?.can_unbind), + details: getBindingDetails('wechat'), }, ]) @@ -338,6 +430,17 @@ function providerSummary(provider: UserAuthProvider): string { return '' } +function bindingCountLabel(details: UserAuthBindingStatus | null): string { + if (!details || typeof details.bound_count !== 'number' || details.bound_count <= 1) { + return '' + } + return t('profile.authBindings.boundCount', { count: details.bound_count }) +} + +function toggleEmailForm(): void { + isEmailFormExpanded.value = !isEmailFormExpanded.value +} + function startBinding(provider: UserAuthProvider): void { if (provider === 'email') { return @@ -353,6 +456,26 @@ function applyUpdatedUser(user: User): void { authStore.user = user } +async function handleUnbind(provider: BindableProvider, providerLabel: string): Promise { + unbindingProvider.value = provider + try { + const user = await unbindAuthIdentity(provider) + applyUpdatedUser(user) + appStore.showSuccess(t('profile.authBindings.unbindSuccess', { providerName: providerLabel })) + } catch (error) { + appStore.showError((error as { message?: string }).message || t('common.tryAgain')) + } finally { + unbindingProvider.value = null + } +} + +function handleUnbindForItem(provider: UserAuthProvider, providerLabel: string): void { + if (provider === 'email') { + return + } + void handleUnbind(provider, providerLabel) +} + function validateEmailBindingForm(requireCode: boolean): boolean { if (!emailBindingForm.email) { appStore.showError(t('auth.emailRequired')) @@ -409,6 +532,9 @@ async function bindEmail(): Promise { applyUpdatedUser(user) emailBindingForm.verifyCode = '' emailBindingForm.password = '' + if (compact.value) { + isEmailFormExpanded.value = false + } appStore.showSuccess( replacingBoundEmail ? t('profile.authBindings.replaceSuccess') diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index 206a5ff3..175944b3 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -1,135 +1,207 @@ @@ -141,7 +213,6 @@ import Icon from '@/components/icons/Icon.vue' import ProfileAvatarCard from '@/components/user/profile/ProfileAvatarCard.vue' import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue' import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue' -import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue' import type { User, UserAuthProvider, UserProfileSourceContext } from '@/types' const props = withDefaults(defineProps<{ @@ -166,6 +237,22 @@ const { t } = useI18n() const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '') const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user')) const avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U') +const memberSinceLabel = computed(() => { + const raw = props.user?.created_at?.trim() + if (!raw) { + return '-' + } + + const date = new Date(raw) + if (Number.isNaN(date.getTime())) { + return '-' + } + + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + }).format(date) +}) const providerLabels = computed>(() => ({ email: t('profile.authBindings.providers.email'), @@ -174,6 +261,10 @@ const providerLabels = computed>(() => ({ wechat: t('profile.authBindings.providers.wechat') })) +function formatCurrency(value: number): string { + return `$${value.toFixed(2)}` +} + function normalizeProvider(value: string): UserAuthProvider | null { const normalized = value.trim().toLowerCase() if (normalized === 'email' || normalized === 'linuxdo' || normalized === 'wechat') { diff --git a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts index 8821cdc5..345e0209 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts @@ -18,6 +18,7 @@ let pinia: ReturnType const userApiMocks = vi.hoisted(() => ({ sendEmailBindingCode: vi.fn(), bindEmailIdentity: vi.fn(), + unbindAuthIdentity: vi.fn(), })) vi.mock('vue-router', () => ({ @@ -30,6 +31,7 @@ vi.mock('@/api/user', async (importOriginal) => { ...actual, sendEmailBindingCode: (...args: any[]) => userApiMocks.sendEmailBindingCode(...args), bindEmailIdentity: (...args: any[]) => userApiMocks.bindEmailIdentity(...args), + unbindAuthIdentity: (...args: any[]) => userApiMocks.unbindAuthIdentity(...args), } }) @@ -54,6 +56,9 @@ vi.mock('vue-i18n', async (importOriginal) => { if (key === 'profile.authBindings.replaceEmailPasswordPlaceholder') return 'Current password' if (key === 'profile.authBindings.sendCodeAction') return 'Send code' + if (key === 'profile.authBindings.unbindAction') return 'Unbind' + if (key === 'profile.authBindings.manageEmailAction') return 'Manage email' + if (key === 'profile.authBindings.hideEmailFormAction') return 'Hide email form' if (key === 'profile.authBindings.confirmEmailBindAction') return 'Bind email' if (key === 'profile.authBindings.confirmEmailReplaceAction') return 'Replace primary email' if (key === 'profile.authBindings.codeSentTo') return `Code sent to ${params?.email || ''}`.trim() @@ -103,6 +108,7 @@ describe('ProfileIdentityBindingsSection', () => { appStore.publicSettingsLoaded = false userApiMocks.sendEmailBindingCode.mockReset() userApiMocks.bindEmailIdentity.mockReset() + userApiMocks.unbindAuthIdentity.mockReset() }) afterEach(() => { @@ -392,4 +398,80 @@ describe('ProfileIdentityBindingsSection', () => { expect(authStore.user?.email).toBe('new@example.com') expect(showSuccessSpy).toHaveBeenCalledWith('Primary email updated') }) + + it('collapses the email binding form in compact mode until the user expands it', async () => { + const wrapper = mount(ProfileIdentityBindingsSection, { + global: { + plugins: [pinia], + }, + props: { + user: createUser({ + email: 'legacy@example.com', + email_bound: false, + auth_bindings: { + email: { bound: false }, + }, + }), + compact: true, + linuxdoEnabled: false, + oidcEnabled: false, + wechatEnabled: false, + }, + }) + + expect(wrapper.find('[data-testid="profile-binding-email-input"]').exists()).toBe(false) + expect(wrapper.get('[data-testid="profile-binding-email-toggle"]').text()).toBe('Manage email') + + await wrapper.get('[data-testid="profile-binding-email-toggle"]').trigger('click') + + expect(wrapper.get('[data-testid="profile-binding-email-input"]').exists()).toBe(true) + }) + + it('shows third-party binding details and unbinds a connected provider', async () => { + userApiMocks.unbindAuthIdentity.mockResolvedValue( + createUser({ + email_bound: true, + linuxdo_bound: false, + auth_bindings: { + email: { bound: true }, + linuxdo: { bound: false, can_unbind: false }, + }, + }) + ) + + const wrapper = mount(ProfileIdentityBindingsSection, { + global: { + plugins: [pinia], + }, + props: { + user: createUser({ + email_bound: true, + linuxdo_bound: true, + auth_bindings: { + email: { bound: true }, + linuxdo: { + bound: true, + display_name: 'linuxdo-handle', + subject_hint: 'lin***3456', + note: 'Linked from LinuxDo', + can_unbind: true, + }, + }, + }), + compact: true, + linuxdoEnabled: true, + oidcEnabled: false, + wechatEnabled: false, + }, + }) + + expect(wrapper.text()).toContain('linuxdo-handle') + expect(wrapper.text()).toContain('lin***3456') + expect(wrapper.text()).toContain('Linked from LinuxDo') + + await wrapper.get('[data-testid="profile-binding-linuxdo-unbind"]').trigger('click') + + expect(userApiMocks.unbindAuthIdentity).toHaveBeenCalledWith('linuxdo') + expect(wrapper.get('[data-testid="profile-binding-linuxdo-status"]').text()).toBe('Not bound') + }) }) diff --git a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts index 87b070a7..229c27cb 100644 --- a/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts +++ b/frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts @@ -3,6 +3,12 @@ import { describe, expect, it, vi } from 'vitest' import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue' import type { User } from '@/types' +vi.mock('vue-router', () => ({ + useRoute: () => ({ + fullPath: '/profile' + }) +})) + vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ user: null @@ -22,6 +28,9 @@ vi.mock('vue-i18n', async (importOriginal) => { ...actual, useI18n: () => ({ t: (key: string, params?: Record) => { + if (key === 'profile.accountBalance') return 'Account Balance' + if (key === 'profile.concurrencyLimit') return 'Concurrency Limit' + if (key === 'profile.memberSince') return 'Member Since' if (key === 'profile.administrator') return 'Administrator' if (key === 'profile.user') return 'User' if (key === 'profile.authBindings.providers.email') return 'Email' @@ -61,7 +70,7 @@ function createUser(overrides: Partial = {}): User { } describe('ProfileInfoCard', () => { - it('renders basic account information without avatar or bindings actions', () => { + it('renders basic account information inside the new overview shell', () => { const wrapper = mount(ProfileInfoCard, { props: { user: createUser() @@ -76,8 +85,8 @@ describe('ProfileInfoCard', () => { expect(wrapper.text()).toContain('alice@example.com') expect(wrapper.text()).toContain('alice') expect(wrapper.text()).toContain('User') - expect(wrapper.find('[data-testid="profile-avatar-save"]').exists()).toBe(false) - expect(wrapper.find('[data-testid="profile-binding-email-status"]').exists()).toBe(false) + expect(wrapper.get('[data-testid="profile-basics-panel"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="profile-auth-bindings-panel"]').exists()).toBe(true) }) it('renders third-party source hints from profile sources', () => { @@ -101,4 +110,27 @@ describe('ProfileInfoCard', () => { expect(wrapper.text()).toContain('Avatar synced from LinuxDo') expect(wrapper.text()).toContain('Username synced from LinuxDo') }) + + it('renders the approved overview hero and two-column content shell', () => { + const wrapper = mount(ProfileInfoCard, { + props: { + user: createUser() + }, + global: { + stubs: { + Icon: true + } + } + }) + + expect(wrapper.get('[data-testid="profile-overview-hero"]').text()).toContain('alice@example.com') + expect(wrapper.get('[data-testid="profile-overview-metric-balance"]').text()).toContain('Account Balance') + expect(wrapper.get('[data-testid="profile-overview-metric-concurrency"]').text()).toContain('Concurrency Limit') + expect(wrapper.get('[data-testid="profile-overview-metric-member-since"]').text()).toContain('Member Since') + expect(wrapper.find('[data-testid="profile-info-summary-grid"]').exists()).toBe(false) + expect(wrapper.get('[data-testid="profile-main-column"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="profile-side-column"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="profile-basics-panel"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="profile-auth-bindings-panel"]').exists()).toBe(true) + }) }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 48c7bc4f..1d2f336d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -38,12 +38,20 @@ export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' export interface UserAuthBindingStatus { bound?: boolean + bound_count?: number provider?: UserAuthProvider | string provider_key?: string | null provider_subject?: string | null issuer?: string | null label?: string | null provider_label?: string | null + display_name?: string | null + subject_hint?: string | null + verified_at?: string | null + bind_start_path?: string | null + can_bind?: boolean + can_unbind?: boolean + note?: string | null metadata?: Record } diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue index 21f677d2..550a3d13 100644 --- a/frontend/src/views/user/ProfileView.vue +++ b/frontend/src/views/user/ProfileView.vue @@ -1,79 +1,76 @@ diff --git a/frontend/src/views/user/__tests__/ProfileView.spec.ts b/frontend/src/views/user/__tests__/ProfileView.spec.ts index 1dcd6c8d..ca37dbe9 100644 --- a/frontend/src/views/user/__tests__/ProfileView.spec.ts +++ b/frontend/src/views/user/__tests__/ProfileView.spec.ts @@ -73,19 +73,16 @@ describe('ProfileView', () => { }) }) - it('renders info, avatar, and account binding cards as separate sections', async () => { + it('renders the approved two-column profile shell without separate stat cards', async () => { const wrapper = mount(ProfileView, { global: { stubs: { AppLayout: { template: '
' }, StatCard: { template: '
' }, ProfileInfoCard: { template: '
' }, - ProfileAvatarCard: { template: '
' }, - ProfileAccountBindingsCard: { template: '
' }, - ProfileEditForm: true, - ProfileBalanceNotifyCard: true, - ProfilePasswordForm: true, - ProfileTotpCard: true, + ProfileBalanceNotifyCard: { template: '
' }, + ProfilePasswordForm: { template: '
' }, + ProfileTotpCard: { template: '
' }, Icon: true } } @@ -93,9 +90,12 @@ describe('ProfileView', () => { await flushPromises() - const html = wrapper.html() - expect(html.indexOf('profile-info-card')).toBeGreaterThan(-1) - expect(html.indexOf('profile-avatar-card')).toBeGreaterThan(html.indexOf('profile-info-card')) - expect(html.indexOf('profile-account-bindings-card')).toBeGreaterThan(html.indexOf('profile-avatar-card')) + expect(wrapper.findAll('.stat-card')).toHaveLength(0) + expect(wrapper.get('[data-testid="profile-shell"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="profile-primary-column"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="profile-secondary-column"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="profile-primary-column"]').html()).toContain('profile-info-card') + expect(wrapper.get('[data-testid="profile-secondary-column"]').html()).toContain('profile-password-form') + expect(wrapper.get('[data-testid="profile-secondary-column"]').html()).toContain('profile-totp-card') }) })