Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
0f4a8d7b
Commit
0f4a8d7b
authored
Apr 22, 2026
by
IanShaw027
Browse files
feat(profile): redesign profile center layout
parent
d4c0a991
Changes
8
Hide whitespace changes
Inline
Side-by-side
frontend/src/api/user.ts
View file @
0f4a8d7b
...
@@ -102,6 +102,11 @@ export async function bindEmailIdentity(payload: {
...
@@ -102,6 +102,11 @@ export async function bindEmailIdentity(payload: {
return
data
return
data
}
}
export
async
function
unbindAuthIdentity
(
provider
:
BindableOAuthProvider
):
Promise
<
User
>
{
const
{
data
}
=
await
apiClient
.
delete
<
User
>
(
`/user/account-bindings/
${
provider
}
`
)
return
data
}
export
type
BindableOAuthProvider
=
Exclude
<
UserAuthProvider
,
'
email
'
>
export
type
BindableOAuthProvider
=
Exclude
<
UserAuthProvider
,
'
email
'
>
interface
BuildOAuthBindingStartURLOptions
{
interface
BuildOAuthBindingStartURLOptions
{
...
@@ -173,6 +178,7 @@ export const userAPI = {
...
@@ -173,6 +178,7 @@ export const userAPI = {
toggleNotifyEmail
,
toggleNotifyEmail
,
sendEmailBindingCode
,
sendEmailBindingCode
,
bindEmailIdentity
,
bindEmailIdentity
,
unbindAuthIdentity
,
buildOAuthBindingStartURL
,
buildOAuthBindingStartURL
,
startOAuthBinding
startOAuthBinding
}
}
...
...
frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue
View file @
0f4a8d7b
...
@@ -21,14 +21,11 @@
...
@@ -21,14 +21,11 @@
{{
t
(
'
profile.authBindings.description
'
)
}}
{{
t
(
'
profile.authBindings.description
'
)
}}
</p>
</p>
</div>
</div>
<div
<div
v-for=
"item in providerItems"
v-for=
"item in providerItems"
:key=
"item.provider"
:key=
"item.provider"
:class=
"
:class=
"rowClass"
props.embedded
? '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'
"
>
>
<div
class=
"flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div
class=
"flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div
class=
"flex min-w-0 flex-1 items-start gap-4"
>
<div
class=
"flex min-w-0 flex-1 items-start gap-4"
>
...
@@ -45,7 +42,7 @@
...
@@ -45,7 +42,7 @@
<span
v-else
>
{{
providerInitial
(
item
.
provider
)
}}
</span>
<span
v-else
>
{{
providerInitial
(
item
.
provider
)
}}
</span>
</div>
</div>
<div
class=
"min-w-0 flex-1"
>
<div
class=
"min-w-0 flex-1
space-y-3
"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
<div
class=
"flex flex-wrap items-center gap-2"
>
<h3
class=
"font-medium text-gray-900 dark:text-white"
>
<h3
class=
"font-medium text-gray-900 dark:text-white"
>
{{
item
.
label
}}
{{
item
.
label
}}
...
@@ -64,14 +61,36 @@
...
@@ -64,14 +61,36 @@
<p
<p
v-if=
"providerSummary(item.provider)"
v-if=
"providerSummary(item.provider)"
class=
"
mt-1
text-sm text-gray-600 dark:text-gray-300"
class=
"text-sm text-gray-600 dark:text-gray-300"
>
>
{{
providerSummary
(
item
.
provider
)
}}
{{
providerSummary
(
item
.
provider
)
}}
</p>
</p>
<div
<div
v-if=
"item.provider === 'email'"
v-if=
"item.details && (item.details.display_name || item.details.subject_hint || bindingCountLabel(item.details) || item.details.note)"
class=
"mt-4 grid gap-2 sm:grid-cols-[minmax(0,1.4fr)_auto]"
class=
"grid gap-1 text-sm text-gray-500 dark:text-gray-400"
>
<p
v-if=
"item.details.display_name"
class=
"font-medium text-gray-700 dark:text-gray-200"
>
{{
item
.
details
.
display_name
}}
</p>
<p
v-if=
"item.details.subject_hint"
>
{{
item
.
details
.
subject_hint
}}
</p>
<p
v-if=
"bindingCountLabel(item.details)"
>
{{
bindingCountLabel
(
item
.
details
)
}}
</p>
<p
v-if=
"item.details.note"
>
{{
item
.
details
.
note
}}
</p>
</div>
<div
v-if=
"item.provider === 'email' && showEmailForm"
data-testid=
"profile-binding-email-form"
class=
"grid gap-2 sm:grid-cols-[minmax(0,1.4fr)_auto]"
>
>
<input
<input
v-model.trim=
"emailBindingForm.email"
v-model.trim=
"emailBindingForm.email"
...
@@ -129,7 +148,20 @@
...
@@ -129,7 +148,20 @@
</div>
</div>
</div>
</div>
<div
class=
"flex shrink-0 items-center gap-3"
>
<div
class=
"flex shrink-0 flex-wrap items-center gap-3"
>
<button
v-if=
"item.provider === 'email' && compact"
data-testid=
"profile-binding-email-toggle"
type=
"button"
class=
"btn btn-secondary btn-sm"
@
click=
"toggleEmailForm"
>
{{
showEmailForm
?
t
(
'
profile.authBindings.hideEmailFormAction
'
)
:
t
(
'
profile.authBindings.manageEmailAction
'
)
}}
</button>
<button
<button
v-if=
"item.canBind"
v-if=
"item.canBind"
:data-testid=
"`profile-binding-$
{item.provider}-action`"
:data-testid=
"`profile-binding-$
{item.provider}-action`"
...
@@ -139,6 +171,20 @@
...
@@ -139,6 +171,20 @@
>
>
{{
t
(
'
profile.authBindings.bindAction
'
,
{
providerName
:
item
.
label
}
)
}}
{{
t
(
'
profile.authBindings.bindAction
'
,
{
providerName
:
item
.
label
}
)
}}
<
/button
>
<
/button
>
<
button
v
-
if
=
"
item.canUnbind
"
:
data
-
testid
=
"
`profile-binding-${item.provider
}
-unbind`
"
type
=
"
button
"
class
=
"
btn btn-secondary btn-sm
"
:
disabled
=
"
unbindingProvider === item.provider
"
@
click
=
"
handleUnbindForItem(item.provider, item.label)
"
>
{{
unbindingProvider
===
item
.
provider
?
t
(
'
common.loading
'
)
:
t
(
'
profile.authBindings.unbindAction
'
)
}}
<
/button
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
<
/div
>
...
@@ -155,11 +201,18 @@ import {
...
@@ -155,11 +201,18 @@ import {
resolveWeChatOAuthStartStrict
,
resolveWeChatOAuthStartStrict
,
type
WeChatOAuthPublicSettings
,
type
WeChatOAuthPublicSettings
,
}
from
'
@/api/auth
'
}
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
Icon
from
'
@/components/icons/Icon.vue
'
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
import
{
useAppStore
,
useAuthStore
}
from
'
@/stores
'
import
type
{
User
,
UserAuthBindingStatus
,
UserAuthProvider
}
from
'
@/types
'
import
type
{
User
,
UserAuthBindingStatus
,
UserAuthProvider
}
from
'
@/types
'
type
BindableProvider
=
Exclude
<
UserAuthProvider
,
'
email
'
>
const
props
=
withDefaults
(
const
props
=
withDefaults
(
defineProps
<
{
defineProps
<
{
user
:
User
|
null
user
:
User
|
null
...
@@ -170,6 +223,7 @@ const props = withDefaults(
...
@@ -170,6 +223,7 @@ const props = withDefaults(
wechatOpenEnabled
?:
boolean
wechatOpenEnabled
?:
boolean
wechatMpEnabled
?:
boolean
wechatMpEnabled
?:
boolean
embedded
?:
boolean
embedded
?:
boolean
compact
?:
boolean
}
>
(),
}
>
(),
{
{
linuxdoEnabled
:
false
,
linuxdoEnabled
:
false
,
...
@@ -179,6 +233,7 @@ const props = withDefaults(
...
@@ -179,6 +233,7 @@ const props = withDefaults(
wechatOpenEnabled
:
undefined
,
wechatOpenEnabled
:
undefined
,
wechatMpEnabled
:
undefined
,
wechatMpEnabled
:
undefined
,
embedded
:
false
,
embedded
:
false
,
compact
:
false
,
}
}
)
)
...
@@ -190,6 +245,8 @@ const authStore = useAuthStore()
...
@@ -190,6 +245,8 @@ const authStore = useAuthStore()
const
localUser
=
ref
<
User
|
null
>
(
null
)
const
localUser
=
ref
<
User
|
null
>
(
null
)
const
isSendingEmailCode
=
ref
(
false
)
const
isSendingEmailCode
=
ref
(
false
)
const
isBindingEmail
=
ref
(
false
)
const
isBindingEmail
=
ref
(
false
)
const
isEmailFormExpanded
=
ref
(
!
props
.
compact
)
const
unbindingProvider
=
ref
<
BindableProvider
|
null
>
(
null
)
const
emailBindingForm
=
reactive
({
const
emailBindingForm
=
reactive
({
email
:
''
,
email
:
''
,
verifyCode
:
''
,
verifyCode
:
''
,
...
@@ -210,8 +267,27 @@ watch(
...
@@ -210,8 +267,27 @@ watch(
{
immediate
:
true
}
{
immediate
:
true
}
)
)
watch
(
()
=>
props
.
compact
,
(
value
)
=>
{
if
(
!
value
)
{
isEmailFormExpanded
.
value
=
true
}
}
,
{
immediate
:
true
}
)
const
currentUser
=
computed
(()
=>
localUser
.
value
??
props
.
user
)
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
emailBound
=
computed
(()
=>
getBindingStatus
(
'
email
'
))
const
showEmailForm
=
computed
(()
=>
!
compact
.
value
||
isEmailFormExpanded
.
value
)
const
emailPasswordPlaceholder
=
computed
(()
=>
const
emailPasswordPlaceholder
=
computed
(()
=>
emailBound
.
value
emailBound
.
value
?
t
(
'
profile.authBindings.replaceEmailPasswordPlaceholder
'
)
?
t
(
'
profile.authBindings.replaceEmailPasswordPlaceholder
'
)
...
@@ -278,30 +354,46 @@ function getBindingStatusForUser(user: User | null | undefined, provider: UserAu
...
@@ -278,30 +354,46 @@ function getBindingStatusForUser(user: User | null | undefined, provider: UserAu
return
normalized
??
false
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
(()
=>
[
const
providerItems
=
computed
(()
=>
[
{
{
provider
:
'
email
'
as
const
,
provider
:
'
email
'
as
const
,
label
:
t
(
'
profile.authBindings.providers.email
'
),
label
:
t
(
'
profile.authBindings.providers.email
'
),
bound
:
getBindingStatus
(
'
email
'
),
bound
:
getBindingStatus
(
'
email
'
),
canBind
:
false
,
canBind
:
false
,
canUnbind
:
false
,
details
:
getBindingDetails
(
'
email
'
),
}
,
}
,
{
{
provider
:
'
linuxdo
'
as
const
,
provider
:
'
linuxdo
'
as
const
,
label
:
t
(
'
profile.authBindings.providers.linuxdo
'
),
label
:
t
(
'
profile.authBindings.providers.linuxdo
'
),
bound
:
getBindingStatus
(
'
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
,
provider
:
'
oidc
'
as
const
,
label
:
t
(
'
profile.authBindings.providers.oidc
'
,
{
providerName
:
props
.
oidcProviderName
}
),
label
:
t
(
'
profile.authBindings.providers.oidc
'
,
{
providerName
:
props
.
oidcProviderName
}
),
bound
:
getBindingStatus
(
'
oidc
'
),
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
,
provider
:
'
wechat
'
as
const
,
label
:
t
(
'
profile.authBindings.providers.wechat
'
),
label
:
t
(
'
profile.authBindings.providers.wechat
'
),
bound
:
getBindingStatus
(
'
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 {
...
@@ -338,6 +430,17 @@ function providerSummary(provider: UserAuthProvider): string {
return
''
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
{
function
startBinding
(
provider
:
UserAuthProvider
):
void
{
if
(
provider
===
'
email
'
)
{
if
(
provider
===
'
email
'
)
{
return
return
...
@@ -353,6 +456,26 @@ function applyUpdatedUser(user: User): void {
...
@@ -353,6 +456,26 @@ function applyUpdatedUser(user: User): void {
authStore
.
user
=
user
authStore
.
user
=
user
}
}
async
function
handleUnbind
(
provider
:
BindableProvider
,
providerLabel
:
string
):
Promise
<
void
>
{
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
{
function
validateEmailBindingForm
(
requireCode
:
boolean
):
boolean
{
if
(
!
emailBindingForm
.
email
)
{
if
(
!
emailBindingForm
.
email
)
{
appStore
.
showError
(
t
(
'
auth.emailRequired
'
))
appStore
.
showError
(
t
(
'
auth.emailRequired
'
))
...
@@ -409,6 +532,9 @@ async function bindEmail(): Promise<void> {
...
@@ -409,6 +532,9 @@ async function bindEmail(): Promise<void> {
applyUpdatedUser
(
user
)
applyUpdatedUser
(
user
)
emailBindingForm
.
verifyCode
=
''
emailBindingForm
.
verifyCode
=
''
emailBindingForm
.
password
=
''
emailBindingForm
.
password
=
''
if
(
compact
.
value
)
{
isEmailFormExpanded
.
value
=
false
}
appStore
.
showSuccess
(
appStore
.
showSuccess
(
replacingBoundEmail
replacingBoundEmail
?
t
(
'
profile.authBindings.replaceSuccess
'
)
?
t
(
'
profile.authBindings.replaceSuccess
'
)
...
...
frontend/src/components/user/profile/ProfileInfoCard.vue
View file @
0f4a8d7b
<
template
>
<
template
>
<div
class=
"card overflow-hidden"
>
<div
class=
"space-y-6"
>
<div
<section
class=
"border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
data-testid=
"profile-overview-hero"
class=
"card overflow-hidden border border-primary-100/80 bg-gradient-to-br from-primary-50 via-white to-amber-50/70 dark:border-primary-900/40 dark:from-primary-950/40 dark:via-dark-900 dark:to-dark-950"
>
>
<div
class=
"flex flex-col gap-5 lg:flex-row lg:items-start"
>
<div
class=
"px-6 py-6 md:px-8"
>
<div
<div
class=
"flex flex-col gap-6 lg:flex-row lg:items-start"
>
class=
"flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-[1.5rem] bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
<div
>
class=
"flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-[1.75rem] bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
<img
v-if=
"avatarUrl"
:src=
"avatarUrl"
:alt=
"displayName"
class=
"h-full w-full object-cover"
>
>
<span
v-else
>
{{
avatarInitial
}}
</span>
<img
</div>
v-if=
"avatarUrl"
<div
class=
"min-w-0 flex-1 space-y-5"
>
:src=
"avatarUrl"
<div
class=
"space-y-2"
>
:alt=
"displayName"
<div
class=
"flex flex-wrap items-center gap-2"
>
class=
"h-full w-full object-cover"
<h2
class=
"truncate text-xl font-semibold text-gray-900 dark:text-white"
>
>
{{
displayName
}}
<span
v-else
>
{{
avatarInitial
}}
</span>
</h2>
</div>
<span
:class=
"['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']"
>
{{
user
?.
role
===
'
admin
'
?
t
(
'
profile.administrator
'
)
:
t
(
'
profile.user
'
)
}}
<div
class=
"min-w-0 flex-1 space-y-5"
>
</span>
<div
class=
"space-y-3"
>
<span
<div
class=
"flex flex-wrap items-center gap-2"
>
:class=
"['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
<h2
class=
"truncate text-2xl font-semibold text-gray-900 dark:text-white"
>
{{
displayName
}}
</h2>
<span
:class=
"['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']"
>
{{
user
?.
role
===
'
admin
'
?
t
(
'
profile.administrator
'
)
:
t
(
'
profile.user
'
)
}}
</span>
<span
:class=
"['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
>
{{
user
?.
status
===
'
active
'
?
t
(
'
common.active
'
)
:
t
(
'
common.disabled
'
)
}}
</span>
</div>
<div
class=
"space-y-1"
>
<p
class=
"truncate text-sm text-gray-600 dark:text-gray-300"
>
{{
user
?.
email
}}
</p>
<div
v-if=
"sourceHints.length"
class=
"flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<span
v-for=
"hint in sourceHints"
:key=
"hint.key"
class=
"inline-flex items-center gap-1 rounded-full bg-white/80 px-3 py-1 ring-1 ring-primary-100 dark:bg-dark-900/70 dark:ring-primary-900/40"
>
<Icon
name=
"link"
size=
"sm"
/>
{{
hint
.
text
}}
</span>
</div>
</div>
</div>
<div
class=
"grid gap-3 sm:grid-cols-3"
>
<div
data-testid=
"profile-overview-metric-balance"
class=
"rounded-2xl bg-white/85 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/60 dark:ring-dark-700"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.accountBalance
'
)
}}
</p>
<p
class=
"mt-1 text-lg font-semibold text-gray-900 dark:text-white"
>
{{
formatCurrency
(
user
?.
balance
||
0
)
}}
</p>
</div>
<div
data-testid=
"profile-overview-metric-concurrency"
class=
"rounded-2xl bg-white/85 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/60 dark:ring-dark-700"
>
>
{{
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
user
?.
status
===
'
active
'
{{
t
(
'
profile.concurrencyLimit
'
)
}}
?
t
(
'
common.active
'
)
</p>
:
t
(
'
common.disabled
'
)
<p
class=
"mt-1 text-lg font-semibold text-gray-900 dark:text-white"
>
}}
{{
user
?.
concurrency
||
0
}}
</span>
</p>
</div>
<div
data-testid=
"profile-overview-metric-member-since"
class=
"rounded-2xl bg-white/85 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/60 dark:ring-dark-700"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.memberSince
'
)
}}
</p>
<p
class=
"mt-1 text-lg font-semibold text-gray-900 dark:text-white"
>
{{
memberSinceLabel
}}
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<div
class=
"grid gap-6 xl:grid-cols-[minmax(0,1.15fr)_320px]"
>
<div
data-testid=
"profile-main-column"
class=
"space-y-6"
>
<section
data-testid=
"profile-basics-panel"
class=
"card border border-gray-100 bg-white/90 p-6 dark:border-dark-700 dark:bg-dark-900/50"
>
<div
class=
"mb-5 flex items-start justify-between gap-4"
>
<div>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.basicsTitle
'
)
}}
</h3>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.basicsDescription
'
)
}}
</p>
</div>
</div>
<p
class=
"truncate text-sm text-gray-500 dark:text-gray-400"
>
{{
user
?.
email
}}
</p>
</div>
</div>
<div
class=
"grid gap-3 sm:grid-cols-2 xl:grid-cols-4"
>
<div
class=
"grid gap-6 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]"
>
<div
class=
"rounded-2xl bg-white/75 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/40 dark:ring-dark-700"
>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/80 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<ProfileAvatarCard
:user=
"user"
embedded
/>
</div>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/80 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<ProfileEditForm
:initial-username=
"user?.username || ''"
embedded
/>
</div>
</div>
</section>
<section
data-testid=
"profile-auth-bindings-panel"
class=
"card border border-gray-100 bg-white/90 p-6 dark:border-dark-700 dark:bg-dark-900/50"
>
<ProfileIdentityBindingsSection
:user=
"user"
:linuxdo-enabled=
"linuxdoEnabled"
:oidc-enabled=
"oidcEnabled"
:oidc-provider-name=
"oidcProviderName"
:wechat-enabled=
"wechatEnabled"
:wechat-open-enabled=
"wechatOpenEnabled"
:wechat-mp-enabled=
"wechatMpEnabled"
embedded
compact
/>
</section>
</div>
<aside
data-testid=
"profile-side-column"
class=
"space-y-6"
>
<section
class=
"card border border-gray-100 bg-white/90 p-6 dark:border-dark-700 dark:bg-dark-900/50"
>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
profile.overviewTitle
'
)
}}
</h3>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.overviewDescription
'
)
}}
</p>
<div
class=
"mt-5 grid gap-3"
>
<div
class=
"rounded-2xl border border-gray-100 bg-gray-50/80 px-4 py-3 dark:border-dark-700 dark:bg-dark-900/30"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.username
'
)
}}
{{
t
(
'
profile.username
'
)
}}
</p>
</p>
<p
class=
"mt-1
truncate
text-sm font-
medium
text-gray-900 dark:text-white"
>
<p
class=
"mt-1 text-sm font-
semibold
text-gray-900 dark:text-white"
>
{{
user
?.
username
||
displayName
}}
{{
user
?.
username
||
displayName
}}
</p>
</p>
</div>
</div>
<div
class=
"rounded-2xl b
g-white/75 px-4 py-3 shadow-sm ring-1 ring-white/70
dark:b
g
-dark-
9
00
/40
dark:
rin
g-dark-
70
0"
>
<div
class=
"rounded-2xl b
order border-gray-100 bg-gray-50/80 px-4 py-3
dark:b
order
-dark-
7
00 dark:
b
g-dark-
900/3
0"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.email
'
)
}}
{{
t
(
'
profile.email
'
)
}}
</p>
</p>
<p
class=
"mt-1
truncate
text-sm font-
medium
text-gray-900 dark:text-white"
>
<p
class=
"mt-1 text-sm font-
semibold
text-gray-900 dark:text-white"
>
{{
user
?.
email
||
'
-
'
}}
{{
user
?.
email
||
'
-
'
}}
</p>
</p>
</div>
</div>
<div
class=
"rounded-2xl bg-white/75 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/40 dark:ring-dark-700"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.status
'
)
}}
</p>
<p
class=
"mt-1 text-sm font-medium text-gray-900 dark:text-white"
>
{{
user
?.
status
===
'
active
'
?
t
(
'
common.active
'
)
:
user
?.
status
?
t
(
'
common.disabled
'
)
:
'
-
'
}}
</p>
</div>
<div
class=
"rounded-2xl bg-white/75 px-4 py-3 shadow-sm ring-1 ring-white/70 dark:bg-dark-900/40 dark:ring-dark-700"
>
<p
class=
"text-xs font-medium uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500"
>
{{
t
(
'
profile.role
'
)
}}
</p>
<p
class=
"mt-1 text-sm font-medium text-gray-900 dark:text-white"
>
{{
user
?.
role
===
'
admin
'
?
t
(
'
profile.administrator
'
)
:
t
(
'
profile.user
'
)
}}
</p>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<div
class=
"space-y-6 px-6 py-6"
>
<section
<div
v-if=
"sourceHints.length"
v-if=
"sourceHints.length"
class=
"card border border-gray-100 bg-white/90 p-6 dark:border-dark-700 dark:bg-dark-900/50"
class=
"grid gap-2 rounded-2xl border border-gray-100 bg-gray-50/80 p-4 text-xs text-gray-500 dark:border-dark-700 dark:bg-dark-900/30 dark:text-gray-400"
>
<div
v-for=
"hint in sourceHints"
:key=
"hint.key"
class=
"flex items-start gap-2"
>
>
<Icon
name=
"link"
size=
"sm"
class=
"mt-0.5 text-gray-400 dark:text-gray-500"
/>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
<span>
{{
hint
.
text
}}
</span>
{{
t
(
'
profile.linkedProfileSources
'
)
}}
</div>
</h3>
</div>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
profile.linkedProfileSourcesDescription
'
)
}}
<div
class=
"grid gap-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]"
>
</p>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/70 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<ProfileAvatarCard
:user=
"user"
embedded
/>
</div>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/70 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<div
class=
"mt-5 grid gap-3"
>
<ProfileEditForm
<div
:initial-username=
"user?.username || ''"
v-for=
"hint in sourceHints"
embedded
:key=
"hint.key"
/>
class=
"flex items-start gap-3 rounded-2xl border border-gray-100 bg-gray-50/80 px-4 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-900/30 dark:text-gray-300"
</div>
>
</div>
<Icon
name=
"link"
size=
"sm"
class=
"mt-0.5 text-gray-400 dark:text-gray-500"
/>
<span>
{{
hint
.
text
}}
</span>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/70 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
</div>
<ProfileIdentityBindingsSection
</div>
:user=
"user"
</section>
:linuxdo-enabled=
"linuxdoEnabled"
</aside>
:oidc-enabled=
"oidcEnabled"
:oidc-provider-name=
"oidcProviderName"
:wechat-enabled=
"wechatEnabled"
:wechat-open-enabled=
"wechatOpenEnabled"
:wechat-mp-enabled=
"wechatMpEnabled"
embedded
/>
</div>
<div
class=
"rounded-3xl border border-gray-100 bg-gray-50/70 p-5 dark:border-dark-700 dark:bg-dark-900/30"
>
<ProfilePasswordForm
embedded
/>
</div>
</div>
</div>
</div>
</div>
</
template
>
</
template
>
...
@@ -141,7 +213,6 @@ import Icon from '@/components/icons/Icon.vue'
...
@@ -141,7 +213,6 @@ import Icon from '@/components/icons/Icon.vue'
import
ProfileAvatarCard
from
'
@/components/user/profile/ProfileAvatarCard.vue
'
import
ProfileAvatarCard
from
'
@/components/user/profile/ProfileAvatarCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfileIdentityBindingsSection
from
'
@/components/user/profile/ProfileIdentityBindingsSection.vue
'
import
ProfileIdentityBindingsSection
from
'
@/components/user/profile/ProfileIdentityBindingsSection.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
import
type
{
User
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
import
type
{
User
,
UserAuthProvider
,
UserProfileSourceContext
}
from
'
@/types
'
const
props
=
withDefaults
(
defineProps
<
{
const
props
=
withDefaults
(
defineProps
<
{
...
@@ -166,6 +237,22 @@ const { t } = useI18n()
...
@@ -166,6 +237,22 @@ const { t } = useI18n()
const
avatarUrl
=
computed
(()
=>
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
avatarUrl
=
computed
(()
=>
props
.
user
?.
avatar_url
?.
trim
()
||
''
)
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
t
(
'
profile.user
'
))
const
displayName
=
computed
(()
=>
props
.
user
?.
username
?.
trim
()
||
props
.
user
?.
email
?.
trim
()
||
t
(
'
profile.user
'
))
const
avatarInitial
=
computed
(()
=>
displayName
.
value
.
charAt
(
0
).
toUpperCase
()
||
'
U
'
)
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
<
Record
<
UserAuthProvider
,
string
>>
(()
=>
({
const
providerLabels
=
computed
<
Record
<
UserAuthProvider
,
string
>>
(()
=>
({
email
:
t
(
'
profile.authBindings.providers.email
'
),
email
:
t
(
'
profile.authBindings.providers.email
'
),
...
@@ -174,6 +261,10 @@ const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
...
@@ -174,6 +261,10 @@ const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
wechat
:
t
(
'
profile.authBindings.providers.wechat
'
)
wechat
:
t
(
'
profile.authBindings.providers.wechat
'
)
}))
}))
function
formatCurrency
(
value
:
number
):
string
{
return
`$
${
value
.
toFixed
(
2
)}
`
}
function
normalizeProvider
(
value
:
string
):
UserAuthProvider
|
null
{
function
normalizeProvider
(
value
:
string
):
UserAuthProvider
|
null
{
const
normalized
=
value
.
trim
().
toLowerCase
()
const
normalized
=
value
.
trim
().
toLowerCase
()
if
(
normalized
===
'
email
'
||
normalized
===
'
linuxdo
'
||
normalized
===
'
wechat
'
)
{
if
(
normalized
===
'
email
'
||
normalized
===
'
linuxdo
'
||
normalized
===
'
wechat
'
)
{
...
...
frontend/src/components/user/profile/__tests__/ProfileIdentityBindingsSection.spec.ts
View file @
0f4a8d7b
...
@@ -18,6 +18,7 @@ let pinia: ReturnType<typeof createPinia>
...
@@ -18,6 +18,7 @@ let pinia: ReturnType<typeof createPinia>
const
userApiMocks
=
vi
.
hoisted
(()
=>
({
const
userApiMocks
=
vi
.
hoisted
(()
=>
({
sendEmailBindingCode
:
vi
.
fn
(),
sendEmailBindingCode
:
vi
.
fn
(),
bindEmailIdentity
:
vi
.
fn
(),
bindEmailIdentity
:
vi
.
fn
(),
unbindAuthIdentity
:
vi
.
fn
(),
}))
}))
vi
.
mock
(
'
vue-router
'
,
()
=>
({
vi
.
mock
(
'
vue-router
'
,
()
=>
({
...
@@ -30,6 +31,7 @@ vi.mock('@/api/user', async (importOriginal) => {
...
@@ -30,6 +31,7 @@ vi.mock('@/api/user', async (importOriginal) => {
...
actual
,
...
actual
,
sendEmailBindingCode
:
(...
args
:
any
[])
=>
userApiMocks
.
sendEmailBindingCode
(...
args
),
sendEmailBindingCode
:
(...
args
:
any
[])
=>
userApiMocks
.
sendEmailBindingCode
(...
args
),
bindEmailIdentity
:
(...
args
:
any
[])
=>
userApiMocks
.
bindEmailIdentity
(...
args
),
bindEmailIdentity
:
(...
args
:
any
[])
=>
userApiMocks
.
bindEmailIdentity
(...
args
),
unbindAuthIdentity
:
(...
args
:
any
[])
=>
userApiMocks
.
unbindAuthIdentity
(...
args
),
}
}
})
})
...
@@ -54,6 +56,9 @@ vi.mock('vue-i18n', async (importOriginal) => {
...
@@ -54,6 +56,9 @@ vi.mock('vue-i18n', async (importOriginal) => {
if
(
key
===
'
profile.authBindings.replaceEmailPasswordPlaceholder
'
)
if
(
key
===
'
profile.authBindings.replaceEmailPasswordPlaceholder
'
)
return
'
Current password
'
return
'
Current password
'
if
(
key
===
'
profile.authBindings.sendCodeAction
'
)
return
'
Send code
'
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.confirmEmailBindAction
'
)
return
'
Bind email
'
if
(
key
===
'
profile.authBindings.confirmEmailReplaceAction
'
)
return
'
Replace primary email
'
if
(
key
===
'
profile.authBindings.confirmEmailReplaceAction
'
)
return
'
Replace primary email
'
if
(
key
===
'
profile.authBindings.codeSentTo
'
)
return
`Code sent to
${
params
?.
email
||
''
}
`
.
trim
()
if
(
key
===
'
profile.authBindings.codeSentTo
'
)
return
`Code sent to
${
params
?.
email
||
''
}
`
.
trim
()
...
@@ -103,6 +108,7 @@ describe('ProfileIdentityBindingsSection', () => {
...
@@ -103,6 +108,7 @@ describe('ProfileIdentityBindingsSection', () => {
appStore
.
publicSettingsLoaded
=
false
appStore
.
publicSettingsLoaded
=
false
userApiMocks
.
sendEmailBindingCode
.
mockReset
()
userApiMocks
.
sendEmailBindingCode
.
mockReset
()
userApiMocks
.
bindEmailIdentity
.
mockReset
()
userApiMocks
.
bindEmailIdentity
.
mockReset
()
userApiMocks
.
unbindAuthIdentity
.
mockReset
()
})
})
afterEach
(()
=>
{
afterEach
(()
=>
{
...
@@ -392,4 +398,80 @@ describe('ProfileIdentityBindingsSection', () => {
...
@@ -392,4 +398,80 @@ describe('ProfileIdentityBindingsSection', () => {
expect
(
authStore
.
user
?.
email
).
toBe
(
'
new@example.com
'
)
expect
(
authStore
.
user
?.
email
).
toBe
(
'
new@example.com
'
)
expect
(
showSuccessSpy
).
toHaveBeenCalledWith
(
'
Primary email updated
'
)
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
'
)
})
})
})
frontend/src/components/user/profile/__tests__/ProfileInfoCard.spec.ts
View file @
0f4a8d7b
...
@@ -3,6 +3,12 @@ import { describe, expect, it, vi } from 'vitest'
...
@@ -3,6 +3,12 @@ import { describe, expect, it, vi } from 'vitest'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
type
{
User
}
from
'
@/types
'
import
type
{
User
}
from
'
@/types
'
vi
.
mock
(
'
vue-router
'
,
()
=>
({
useRoute
:
()
=>
({
fullPath
:
'
/profile
'
})
}))
vi
.
mock
(
'
@/stores/auth
'
,
()
=>
({
vi
.
mock
(
'
@/stores/auth
'
,
()
=>
({
useAuthStore
:
()
=>
({
useAuthStore
:
()
=>
({
user
:
null
user
:
null
...
@@ -22,6 +28,9 @@ vi.mock('vue-i18n', async (importOriginal) => {
...
@@ -22,6 +28,9 @@ vi.mock('vue-i18n', async (importOriginal) => {
...
actual
,
...
actual
,
useI18n
:
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
{
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.administrator
'
)
return
'
Administrator
'
if
(
key
===
'
profile.user
'
)
return
'
User
'
if
(
key
===
'
profile.user
'
)
return
'
User
'
if
(
key
===
'
profile.authBindings.providers.email
'
)
return
'
Email
'
if
(
key
===
'
profile.authBindings.providers.email
'
)
return
'
Email
'
...
@@ -61,7 +70,7 @@ function createUser(overrides: Partial<User> = {}): User {
...
@@ -61,7 +70,7 @@ function createUser(overrides: Partial<User> = {}): User {
}
}
describe
(
'
ProfileInfoCard
'
,
()
=>
{
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
,
{
const
wrapper
=
mount
(
ProfileInfoCard
,
{
props
:
{
props
:
{
user
:
createUser
()
user
:
createUser
()
...
@@ -76,8 +85,8 @@ describe('ProfileInfoCard', () => {
...
@@ -76,8 +85,8 @@ describe('ProfileInfoCard', () => {
expect
(
wrapper
.
text
()).
toContain
(
'
alice@example.com
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
alice@example.com
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
alice
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
alice
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
User
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
User
'
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-
avatar-save
"]
'
).
exists
()).
toBe
(
fals
e
)
expect
(
wrapper
.
get
(
'
[data-testid="profile-
basics-panel
"]
'
).
exists
()).
toBe
(
tru
e
)
expect
(
wrapper
.
find
(
'
[data-testid="profile-binding
-email-status
"]
'
).
exists
()).
toBe
(
fals
e
)
expect
(
wrapper
.
get
(
'
[data-testid="profile-
auth-
binding
s-panel
"]
'
).
exists
()).
toBe
(
tru
e
)
})
})
it
(
'
renders third-party source hints from profile sources
'
,
()
=>
{
it
(
'
renders third-party source hints from profile sources
'
,
()
=>
{
...
@@ -101,4 +110,27 @@ describe('ProfileInfoCard', () => {
...
@@ -101,4 +110,27 @@ describe('ProfileInfoCard', () => {
expect
(
wrapper
.
text
()).
toContain
(
'
Avatar synced from LinuxDo
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Avatar synced from LinuxDo
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
Username 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
)
})
})
})
frontend/src/types/index.ts
View file @
0f4a8d7b
...
@@ -38,12 +38,20 @@ export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat'
...
@@ -38,12 +38,20 @@ export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat'
export
interface
UserAuthBindingStatus
{
export
interface
UserAuthBindingStatus
{
bound
?:
boolean
bound
?:
boolean
bound_count
?:
number
provider
?:
UserAuthProvider
|
string
provider
?:
UserAuthProvider
|
string
provider_key
?:
string
|
null
provider_key
?:
string
|
null
provider_subject
?:
string
|
null
provider_subject
?:
string
|
null
issuer
?:
string
|
null
issuer
?:
string
|
null
label
?:
string
|
null
label
?:
string
|
null
provider_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
<
string
,
unknown
>
metadata
?:
Record
<
string
,
unknown
>
}
}
...
...
frontend/src/views/user/ProfileView.vue
View file @
0f4a8d7b
<
template
>
<
template
>
<AppLayout>
<AppLayout>
<div
class=
"mx-auto max-w-4xl space-y-6"
>
<div
<div
class=
"grid grid-cols-1 gap-6 sm:grid-cols-3"
>
data-testid=
"profile-shell"
<StatCard
class=
"mx-auto max-w-6xl space-y-6"
:title=
"t('profile.accountBalance')"
>
:value=
"formatCurrency(user?.balance || 0)"
<div
class=
"grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_360px]"
>
:icon=
"WalletIcon"
<div
icon-variant=
"success"
data-testid=
"profile-primary-column"
/>
class=
"space-y-6"
<StatCard
>
:title=
"t('profile.concurrencyLimit')"
<ProfileInfoCard
:value=
"user?.concurrency || 0"
:user=
"user"
:icon=
"BoltIcon"
:linuxdo-enabled=
"linuxdoOAuthEnabled"
icon-variant=
"warning"
:oidc-enabled=
"oidcOAuthEnabled"
/>
:oidc-provider-name=
"oidcOAuthProviderName"
<StatCard
:wechat-enabled=
"wechatOAuthEnabled"
:title=
"t('profile.memberSince')"
:wechat-open-enabled=
"wechatOAuthOpenEnabled"
:value=
"formatDate(user?.created_at || '',
{ year: 'numeric', month: 'long' })"
:wechat-mp-enabled=
"wechatOAuthMPEnabled"
:icon="CalendarIcon"
/>
icon-variant="primary"
</div>
/>
</div>
<ProfileInfoCard
:user=
"user"
:linuxdo-enabled=
"linuxdoOAuthEnabled"
:oidc-enabled=
"oidcOAuthEnabled"
:oidc-provider-name=
"oidcOAuthProviderName"
:wechat-enabled=
"wechatOAuthEnabled"
:wechat-open-enabled=
"wechatOAuthOpenEnabled"
:wechat-mp-enabled=
"wechatOAuthMPEnabled"
/>
<div
<aside
v-if=
"contactInfo"
data-testid=
"profile-secondary-column"
class=
"card border-primary-200 bg-primary-50 p-6 dark:bg-primary-900/20"
class=
"space-y-6"
>
>
<div
class=
"flex items-center gap-4"
>
<div
<div
class=
"rounded-xl bg-primary-100 p-3 text-primary-600"
>
v-if=
"contactInfo"
<Icon
name=
"chat"
size=
"lg"
/>
class=
"card border-primary-200 bg-primary-50 p-6 dark:bg-primary-900/20"
>
<div
class=
"flex items-center gap-4"
>
<div
class=
"rounded-xl bg-primary-100 p-3 text-primary-600"
>
<Icon
name=
"chat"
size=
"lg"
/>
</div>
<div>
<h3
class=
"font-semibold text-primary-800 dark:text-primary-200"
>
{{
t
(
'
common.contactSupport
'
)
}}
</h3>
<p
class=
"text-sm font-medium"
>
{{
contactInfo
}}
</p>
</div>
</div>
</div>
</div>
<div>
<h3
class=
"font-semibold text-primary-800 dark:text-primary-200"
>
<ProfilePasswordForm
/>
{{
t
(
'
common.contactSupport
'
)
}}
</h3>
<ProfileBalanceNotifyCard
<p
class=
"text-sm font-medium"
>
{{
contactInfo
}}
</p>
v-if=
"user && balanceLowNotifyEnabled"
</div>
:enabled=
"user.balance_notify_enabled ?? true"
</div>
:threshold=
"user.balance_notify_threshold"
:extra-emails=
"user.balance_notify_extra_emails ?? []"
:system-default-threshold=
"systemDefaultThreshold"
:user-email=
"user.email"
/>
<ProfileTotpCard
/>
</aside>
</div>
</div>
<ProfileBalanceNotifyCard
v-if=
"user && balanceLowNotifyEnabled"
:enabled=
"user.balance_notify_enabled ?? true"
:threshold=
"user.balance_notify_threshold"
:extra-emails=
"user.balance_notify_extra_emails ?? []"
:system-default-threshold=
"systemDefaultThreshold"
:user-email=
"user.email"
/>
<ProfileTotpCard
/>
</div>
</div>
</AppLayout>
</AppLayout>
</
template
>
</
template
>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
h
,
onMounted
,
ref
}
from
'
vue
'
import
{
computed
,
onMounted
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
Icon
}
from
'
@/components/icons
'
import
{
Icon
}
from
'
@/components/icons
'
import
StatCard
from
'
@/components/common/StatCard.vue
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
ProfileBalanceNotifyCard
from
'
@/components/user/profile/ProfileBalanceNotifyCard.vue
'
import
ProfileBalanceNotifyCard
from
'
@/components/user/profile/ProfileBalanceNotifyCard.vue
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
import
ProfileTotpCard
from
'
@/components/user/profile/ProfileTotpCard.vue
'
import
ProfileTotpCard
from
'
@/components/user/profile/ProfileTotpCard.vue
'
import
{
isWeChatWebOAuthEnabled
}
from
'
@/api/auth
'
import
{
isWeChatWebOAuthEnabled
}
from
'
@/api/auth
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
formatDate
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
appStore
=
useAppStore
()
...
@@ -90,31 +87,6 @@ const wechatOAuthMPEnabled = ref<boolean | undefined>(undefined)
...
@@ -90,31 +87,6 @@ const wechatOAuthMPEnabled = ref<boolean | undefined>(undefined)
const
oidcOAuthEnabled
=
ref
(
false
)
const
oidcOAuthEnabled
=
ref
(
false
)
const
oidcOAuthProviderName
=
ref
(
'
OIDC
'
)
const
oidcOAuthProviderName
=
ref
(
'
OIDC
'
)
const
WalletIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
d
:
'
M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12
'
})]
)
}
const
BoltIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
d
:
'
m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z
'
})]
)
}
const
CalendarIcon
=
{
render
:
()
=>
h
(
'
svg
'
,
{
fill
:
'
none
'
,
viewBox
:
'
0 0 24 24
'
,
stroke
:
'
currentColor
'
,
'
stroke-width
'
:
'
1.5
'
},
[
h
(
'
path
'
,
{
d
:
'
M6.75 3v2.25M17.25 3v2.25
'
})]
)
}
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
const
profileRefresh
=
authStore
.
refreshUser
().
catch
((
error
)
=>
{
const
profileRefresh
=
authStore
.
refreshUser
().
catch
((
error
)
=>
{
console
.
error
(
'
Failed to refresh profile:
'
,
error
)
console
.
error
(
'
Failed to refresh profile:
'
,
error
)
...
@@ -145,6 +117,4 @@ onMounted(async () => {
...
@@ -145,6 +117,4 @@ onMounted(async () => {
await
Promise
.
all
([
profileRefresh
,
settingsLoad
])
await
Promise
.
all
([
profileRefresh
,
settingsLoad
])
})
})
const
formatCurrency
=
(
value
:
number
)
=>
`$
${
value
.
toFixed
(
2
)}
`
</
script
>
</
script
>
frontend/src/views/user/__tests__/ProfileView.spec.ts
View file @
0f4a8d7b
...
@@ -73,19 +73,16 @@ describe('ProfileView', () => {
...
@@ -73,19 +73,16 @@ describe('ProfileView', () => {
})
})
})
})
it
(
'
renders
info, avatar, and account binding cards as
separate s
ection
s
'
,
async
()
=>
{
it
(
'
renders
the approved two-column profile shell without
separate s
tat card
s
'
,
async
()
=>
{
const
wrapper
=
mount
(
ProfileView
,
{
const
wrapper
=
mount
(
ProfileView
,
{
global
:
{
global
:
{
stubs
:
{
stubs
:
{
AppLayout
:
{
template
:
'
<div><slot /></div>
'
},
AppLayout
:
{
template
:
'
<div><slot /></div>
'
},
StatCard
:
{
template
:
'
<div class="stat-card" />
'
},
StatCard
:
{
template
:
'
<div class="stat-card" />
'
},
ProfileInfoCard
:
{
template
:
'
<div data-testid="profile-info-card" />
'
},
ProfileInfoCard
:
{
template
:
'
<div data-testid="profile-info-card" />
'
},
ProfileAvatarCard
:
{
template
:
'
<div data-testid="profile-avatar-card" />
'
},
ProfileBalanceNotifyCard
:
{
template
:
'
<div data-testid="profile-balance-notify-card" />
'
},
ProfileAccountBindingsCard
:
{
template
:
'
<div data-testid="profile-account-bindings-card" />
'
},
ProfilePasswordForm
:
{
template
:
'
<div data-testid="profile-password-form" />
'
},
ProfileEditForm
:
true
,
ProfileTotpCard
:
{
template
:
'
<div data-testid="profile-totp-card" />
'
},
ProfileBalanceNotifyCard
:
true
,
ProfilePasswordForm
:
true
,
ProfileTotpCard
:
true
,
Icon
:
true
Icon
:
true
}
}
}
}
...
@@ -93,9 +90,12 @@ describe('ProfileView', () => {
...
@@ -93,9 +90,12 @@ describe('ProfileView', () => {
await
flushPromises
()
await
flushPromises
()
const
html
=
wrapper
.
html
()
expect
(
wrapper
.
findAll
(
'
.stat-card
'
)).
toHaveLength
(
0
)
expect
(
html
.
indexOf
(
'
profile-info-card
'
)).
toBeGreaterThan
(
-
1
)
expect
(
wrapper
.
get
(
'
[data-testid="profile-shell"]
'
).
exists
()).
toBe
(
true
)
expect
(
html
.
indexOf
(
'
profile-avatar-card
'
)).
toBeGreaterThan
(
html
.
indexOf
(
'
profile-info-card
'
))
expect
(
wrapper
.
get
(
'
[data-testid="profile-primary-column"]
'
).
exists
()).
toBe
(
true
)
expect
(
html
.
indexOf
(
'
profile-account-bindings-card
'
)).
toBeGreaterThan
(
html
.
indexOf
(
'
profile-avatar-card
'
))
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
'
)
})
})
})
})
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment