From 862328fa856a6e6fac85aab47ce937b2865eeb98 Mon Sep 17 00:00:00 2001 From: Kavinda dewmith Date: Wed, 6 May 2026 15:11:02 +0530 Subject: [PATCH 1/9] Enhance authentication handling for AsgardeoV2 by adjusting client_secret usage and adding support for Basic Auth in token requests --- packages/javascript/src/__legacy__/client.ts | 22 +++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/javascript/src/__legacy__/client.ts b/packages/javascript/src/__legacy__/client.ts index 113867f19..bf9169861 100644 --- a/packages/javascript/src/__legacy__/client.ts +++ b/packages/javascript/src/__legacy__/client.ts @@ -377,7 +377,12 @@ export class AsgardeoAuthClient { body.set('client_id', configData.clientId); - if (configData.clientSecret && configData.clientSecret.trim().length > 0) { + // AsgardeoV2 (Thunder) requires client_secret_basic: credentials in the Authorization header. + // All other platforms use client_secret_post: credentials in the request body. + const hasSecret: boolean = Boolean(configData.clientSecret && configData.clientSecret.trim().length > 0); + const useBasicAuth: boolean = hasSecret && (configData as any).platform === Platform.AsgardeoV2; + + if (hasSecret && !useBasicAuth) { body.set('client_secret', configData.clientSecret); } @@ -403,16 +408,23 @@ export class AsgardeoAuthClient { await this.storageManager.removeTemporaryDataParameter(extractPkceStorageKeyFromState(state), userId); } + const tokenRequestHeaders: Record = { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + if (useBasicAuth) { + tokenRequestHeaders['Authorization'] = + `Basic ${btoa(`${configData.clientId}:${configData.clientSecret}`)}`; + } + let tokenResponse: Response; try { tokenResponse = await fetch(tokenEndpoint, { body, credentials: configData.sendCookiesInRequests ? 'include' : 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: tokenRequestHeaders, method: 'POST', }); } catch (error: any) { From 646b437f26e31b439ec377fa4e6454d209791737 Mon Sep 17 00:00:00 2001 From: Kavinda dewmith Date: Wed, 6 May 2026 15:14:15 +0530 Subject: [PATCH 2/9] Enhance UserProfile component with modern redesign and new features with support for AsgardeoV2 platform - Updated UserProfile.css.ts to implement a modern redesign with improved styles and new elements. - Added compact modifier for reduced field padding in modal/dropdown embedding. - Introduced avatar size variants (sm, md, lg) for better customization. - Enhanced UserProfile.ts to support profile updates and error handling. - Created new User component to expose current user via scoped slots. - Updated AsgardeoProvider to manage user profile state and updates. - Added utility functions getDisplayName and getMappedUserProfileValue for better user data handling. --- packages/vue/src/api/updateMeProfile.ts | 59 ++ .../sign-in/AuthOptionFactory.ts | 0 .../sign-in/AuthOptionFactoryCore.ts | 0 .../sign-in/BaseSignIn.ts | 0 .../{presentation => auth}/sign-in/SignIn.ts | 0 .../sign-in/v1/BaseSignIn.ts | 0 .../sign-in/v1/SignIn.ts | 0 .../sign-in/v1/options/SignInOptionFactory.ts | 0 .../sign-in/v2/AuthOptionFactory.ts | 0 .../sign-in/v2/BaseSignIn.ts | 0 .../sign-in/v2/SignIn.ts | 0 .../sign-up/BaseSignUp.ts | 0 .../{presentation => auth}/sign-up/SignUp.ts | 0 .../sign-up/v1/BaseSignUp.ts | 0 .../sign-up/v1/SignUp.ts | 0 .../sign-up/v1/options/SignUpOptionFactory.ts | 0 .../sign-up/v2/BaseSignUp.ts | 0 .../sign-up/v2/SignUp.ts | 0 .../accept-invite/BaseAcceptInvite.ts | 2 +- .../invite-user/BaseInviteUser.ts | 2 +- .../organization/Organization.ts | 2 +- .../user-dropdown/BaseUserDropdown.ts | 402 +++++++--- .../user-dropdown/UserDropdown.css.ts | 280 +++++-- .../user-dropdown/UserDropdown.ts | 78 +- .../user-profile/BaseUserProfile.ts | 693 ++++++++++-------- .../user-profile/UserProfile.css.ts | 177 ++++- .../presentation/user-profile/UserProfile.ts | 70 +- .../{control => presentation}/user/User.ts | 4 +- packages/vue/src/index.ts | 24 +- packages/vue/src/models/contexts.ts | 2 + .../vue/src/providers/AsgardeoProvider.ts | 48 +- packages/vue/src/providers/UserProvider.ts | 16 +- packages/vue/src/utils/getDisplayName.ts | 53 ++ .../src/utils/getMappedUserProfileValue.ts | 46 ++ 34 files changed, 1421 insertions(+), 537 deletions(-) create mode 100644 packages/vue/src/api/updateMeProfile.ts rename packages/vue/src/components/{presentation => auth}/sign-in/AuthOptionFactory.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-in/AuthOptionFactoryCore.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-in/BaseSignIn.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-in/SignIn.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-in/v1/BaseSignIn.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-in/v1/SignIn.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-in/v1/options/SignInOptionFactory.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-in/v2/AuthOptionFactory.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-in/v2/BaseSignIn.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-in/v2/SignIn.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-up/BaseSignUp.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-up/SignUp.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-up/v1/BaseSignUp.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-up/v1/SignUp.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-up/v1/options/SignUpOptionFactory.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-up/v2/BaseSignUp.ts (100%) rename packages/vue/src/components/{presentation => auth}/sign-up/v2/SignUp.ts (100%) rename packages/vue/src/components/{control => presentation}/organization/Organization.ts (95%) rename packages/vue/src/components/{control => presentation}/user/User.ts (93%) create mode 100644 packages/vue/src/utils/getDisplayName.ts create mode 100644 packages/vue/src/utils/getMappedUserProfileValue.ts diff --git a/packages/vue/src/api/updateMeProfile.ts b/packages/vue/src/api/updateMeProfile.ts new file mode 100644 index 000000000..66f170ffe --- /dev/null +++ b/packages/vue/src/api/updateMeProfile.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + AsgardeoSPAClient, + HttpRequestConfig, + HttpResponse, + User, + UpdateMeProfileConfig as BaseUpdateMeProfileConfig, + updateMeProfile as baseUpdateMeProfile, +} from '@asgardeo/browser'; + +export interface UpdateMeProfileConfig extends Omit { + fetcher?: (url: string, config: RequestInit) => Promise; + instanceId?: number; +} + +const updateMeProfile = async ({fetcher, instanceId = 0, ...requestConfig}: UpdateMeProfileConfig): Promise => { + const defaultFetcher = async (url: string, config: RequestInit): Promise => { + const client: AsgardeoSPAClient = AsgardeoSPAClient.getInstance(instanceId); + const httpClient: (config: HttpRequestConfig) => Promise> = client.httpRequest.bind(client); + const response: HttpResponse = await httpClient({ + data: config.body ? JSON.parse(config.body as string) : undefined, + headers: config.headers as Record, + method: config.method || 'PATCH', + url, + } as HttpRequestConfig); + + return { + json: () => Promise.resolve(response.data), + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.statusText || '', + text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)), + } as Response; + }; + + return baseUpdateMeProfile({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default updateMeProfile; diff --git a/packages/vue/src/components/presentation/sign-in/AuthOptionFactory.ts b/packages/vue/src/components/auth/sign-in/AuthOptionFactory.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/AuthOptionFactory.ts rename to packages/vue/src/components/auth/sign-in/AuthOptionFactory.ts diff --git a/packages/vue/src/components/presentation/sign-in/AuthOptionFactoryCore.ts b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/AuthOptionFactoryCore.ts rename to packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts diff --git a/packages/vue/src/components/presentation/sign-in/BaseSignIn.ts b/packages/vue/src/components/auth/sign-in/BaseSignIn.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/BaseSignIn.ts rename to packages/vue/src/components/auth/sign-in/BaseSignIn.ts diff --git a/packages/vue/src/components/presentation/sign-in/SignIn.ts b/packages/vue/src/components/auth/sign-in/SignIn.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/SignIn.ts rename to packages/vue/src/components/auth/sign-in/SignIn.ts diff --git a/packages/vue/src/components/presentation/sign-in/v1/BaseSignIn.ts b/packages/vue/src/components/auth/sign-in/v1/BaseSignIn.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/v1/BaseSignIn.ts rename to packages/vue/src/components/auth/sign-in/v1/BaseSignIn.ts diff --git a/packages/vue/src/components/presentation/sign-in/v1/SignIn.ts b/packages/vue/src/components/auth/sign-in/v1/SignIn.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/v1/SignIn.ts rename to packages/vue/src/components/auth/sign-in/v1/SignIn.ts diff --git a/packages/vue/src/components/presentation/sign-in/v1/options/SignInOptionFactory.ts b/packages/vue/src/components/auth/sign-in/v1/options/SignInOptionFactory.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/v1/options/SignInOptionFactory.ts rename to packages/vue/src/components/auth/sign-in/v1/options/SignInOptionFactory.ts diff --git a/packages/vue/src/components/presentation/sign-in/v2/AuthOptionFactory.ts b/packages/vue/src/components/auth/sign-in/v2/AuthOptionFactory.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/v2/AuthOptionFactory.ts rename to packages/vue/src/components/auth/sign-in/v2/AuthOptionFactory.ts diff --git a/packages/vue/src/components/presentation/sign-in/v2/BaseSignIn.ts b/packages/vue/src/components/auth/sign-in/v2/BaseSignIn.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/v2/BaseSignIn.ts rename to packages/vue/src/components/auth/sign-in/v2/BaseSignIn.ts diff --git a/packages/vue/src/components/presentation/sign-in/v2/SignIn.ts b/packages/vue/src/components/auth/sign-in/v2/SignIn.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-in/v2/SignIn.ts rename to packages/vue/src/components/auth/sign-in/v2/SignIn.ts diff --git a/packages/vue/src/components/presentation/sign-up/BaseSignUp.ts b/packages/vue/src/components/auth/sign-up/BaseSignUp.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-up/BaseSignUp.ts rename to packages/vue/src/components/auth/sign-up/BaseSignUp.ts diff --git a/packages/vue/src/components/presentation/sign-up/SignUp.ts b/packages/vue/src/components/auth/sign-up/SignUp.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-up/SignUp.ts rename to packages/vue/src/components/auth/sign-up/SignUp.ts diff --git a/packages/vue/src/components/presentation/sign-up/v1/BaseSignUp.ts b/packages/vue/src/components/auth/sign-up/v1/BaseSignUp.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-up/v1/BaseSignUp.ts rename to packages/vue/src/components/auth/sign-up/v1/BaseSignUp.ts diff --git a/packages/vue/src/components/presentation/sign-up/v1/SignUp.ts b/packages/vue/src/components/auth/sign-up/v1/SignUp.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-up/v1/SignUp.ts rename to packages/vue/src/components/auth/sign-up/v1/SignUp.ts diff --git a/packages/vue/src/components/presentation/sign-up/v1/options/SignUpOptionFactory.ts b/packages/vue/src/components/auth/sign-up/v1/options/SignUpOptionFactory.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-up/v1/options/SignUpOptionFactory.ts rename to packages/vue/src/components/auth/sign-up/v1/options/SignUpOptionFactory.ts diff --git a/packages/vue/src/components/presentation/sign-up/v2/BaseSignUp.ts b/packages/vue/src/components/auth/sign-up/v2/BaseSignUp.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-up/v2/BaseSignUp.ts rename to packages/vue/src/components/auth/sign-up/v2/BaseSignUp.ts diff --git a/packages/vue/src/components/presentation/sign-up/v2/SignUp.ts b/packages/vue/src/components/auth/sign-up/v2/SignUp.ts similarity index 100% rename from packages/vue/src/components/presentation/sign-up/v2/SignUp.ts rename to packages/vue/src/components/auth/sign-up/v2/SignUp.ts diff --git a/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts b/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts index fb0d871fe..966a82b21 100644 --- a/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts +++ b/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts @@ -38,7 +38,7 @@ import Button from '../../primitives/Button'; import Card from '../../primitives/Card'; import Spinner from '../../primitives/Spinner'; import Typography from '../../primitives/Typography'; -import {renderInviteUserComponents} from '../sign-in/AuthOptionFactory'; +import {renderInviteUserComponents} from '../../auth/sign-in/AuthOptionFactory'; /** * Flow response from the accept-invite backend. diff --git a/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts b/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts index 6d3fccf3f..67ebf2599 100644 --- a/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts +++ b/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts @@ -36,7 +36,7 @@ import Button from '../../primitives/Button'; import Card from '../../primitives/Card'; import Spinner from '../../primitives/Spinner'; import Typography from '../../primitives/Typography'; -import {renderInviteUserComponents} from '../sign-in/AuthOptionFactory'; +import {renderInviteUserComponents} from '../../auth/sign-in/AuthOptionFactory'; /** * Flow response from the invite-user backend. diff --git a/packages/vue/src/components/control/organization/Organization.ts b/packages/vue/src/components/presentation/organization/Organization.ts similarity index 95% rename from packages/vue/src/components/control/organization/Organization.ts rename to packages/vue/src/components/presentation/organization/Organization.ts index e80d806b6..c21dc8b21 100644 --- a/packages/vue/src/components/control/organization/Organization.ts +++ b/packages/vue/src/components/presentation/organization/Organization.ts @@ -20,7 +20,7 @@ import {type Component, type VNode, defineComponent, h, Fragment} from 'vue'; import useOrganization from '../../../composables/useOrganization'; /** - * Organization — control component that exposes the current organization via a scoped slot. + * Organization — presentation component that exposes the current organization via a scoped slot. * * Renders the `default` slot with `{ organization }` when a current organization is available, * or the `fallback` slot when none is set. diff --git a/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts b/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts index 0c47c67ff..bd12f7e69 100644 --- a/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts +++ b/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts @@ -17,154 +17,382 @@ */ import {type User, withVendorCSSClassPrefix} from '@asgardeo/browser'; -import {type Component, type PropType, type Ref, type VNode, defineComponent, h, ref} from 'vue'; +import { + type Component, + type PropType, + type Ref, + type VNode, + defineComponent, + h, + onMounted, + onUnmounted, + ref, +} from 'vue'; import {ChevronDownIcon, LogOutIcon, UserIcon, XIcon} from '../../primitives/Icons'; -import Typography from '../../primitives/Typography'; +import getDisplayName from '../../../utils/getDisplayName'; +import getMappedUserProfileValue from '../../../utils/getMappedUserProfileValue'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** + * A single item in the dropdown menu. + * + * @example + * ```ts + * const items: DropdownMenuItem[] = [ + * { label: 'Settings', icon: h(SettingsIcon, { size: 15 }), onClick: () => router.push('/settings') }, + * { label: 'Help', onClick: openHelp, separatorBefore: true }, + * { label: 'Delete account', onClick: deleteAccount, danger: true, separatorBefore: true }, + * ]; + * ``` + */ +export interface DropdownMenuItem { + /** Renders with red text and red hover background. Use for destructive actions. */ + danger?: boolean; + /** Optional icon VNode rendered to the left of the label. */ + icon?: VNode | null; + /** The visible text label. */ + label: string; + /** Called when the item is clicked (menu closes first). */ + onClick: () => void; + /** When `true`, a thin divider is rendered immediately before this item. */ + separatorBefore?: boolean; +} export interface BaseUserDropdownProps { className?: string; isProfileModalOpen?: boolean; + menuAlign?: 'auto' | 'left' | 'right'; + menuItems?: DropdownMenuItem[]; onProfileClick?: () => void; onProfileModalClose?: () => void; onSignOut?: () => void; profileContent?: VNode | null; + showChevron?: boolean; + size?: 'sm' | 'md' | 'lg'; user?: User | null; } -/** - * BaseUserDropdown — unstyled user dropdown with avatar, profile link, sign-out. - */ +// ─── Constants ─────────────────────────────────────────────────────────────── + +const DEFAULT_ATTRIBUTE_MAPPINGS: Record = { + email: ['emails', 'email'], + firstName: ['name.givenName', 'given_name'], + lastName: ['name.familyName', 'family_name'], + username: ['userName', 'username', 'user_name'], +}; + +/** Approximate min-width for each size, used for auto-alignment decisions. */ +const MENU_MIN_WIDTHS: Record = {lg: 280, md: 220, sm: 180}; + +const AVATAR_GRADIENTS: string[] = [ + 'linear-gradient(135deg, #4b6ef5 0%, #7c3aed 100%)', + 'linear-gradient(135deg, #0ea5e9 0%, #4b6ef5 100%)', + 'linear-gradient(135deg, #10b981 0%, #0ea5e9 100%)', + 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)', + 'linear-gradient(135deg, #ec4899 0%, #7c3aed 100%)', + 'linear-gradient(135deg, #8b5cf6 0%, #4b6ef5 100%)', + 'linear-gradient(135deg, #14b8a6 0%, #0ea5e9 100%)', + 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)', +]; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getAvatarGradient(seed: string): string { + if (!seed) return AVATAR_GRADIENTS[0]; + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + // eslint-disable-next-line no-bitwise + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + return AVATAR_GRADIENTS[Math.abs(hash) % AVATAR_GRADIENTS.length]; +} + +function resolveUserInfo(user: User | null): { + displayName: string; + gradient: string; + initials: string; + subtitle: string; +} { + if (!user) { + return {displayName: 'User', gradient: AVATAR_GRADIENTS[0], initials: '?', subtitle: ''}; + } + + const displayName = getDisplayName(DEFAULT_ATTRIBUTE_MAPPINGS, user) || 'User'; + const initials = + displayName + .split(' ') + .map((w: string) => w.charAt(0)) + .slice(0, 2) + .join('') + .toUpperCase() || '?'; + + const seed = String( + getMappedUserProfileValue('username', DEFAULT_ATTRIBUTE_MAPPINGS, user) || + getMappedUserProfileValue('email', DEFAULT_ATTRIBUTE_MAPPINGS, user) || + displayName, + ); + + const subtitle = String( + getMappedUserProfileValue('email', DEFAULT_ATTRIBUTE_MAPPINGS, user) || + getMappedUserProfileValue('username', DEFAULT_ATTRIBUTE_MAPPINGS, user) || + '', + ); + + return {displayName, gradient: getAvatarGradient(seed), initials, subtitle}; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + const BaseUserDropdown: Component = defineComponent({ inheritAttrs: false, name: 'BaseUserDropdown', props: { className: {default: '', type: String}, isProfileModalOpen: {default: false, type: Boolean}, + /** + * How to align the dropdown panel relative to the trigger. + * - `'auto'` (default) — opens toward the side with more viewport space. + * - `'left'` — panel left edge aligns with trigger left edge. + * - `'right'` — panel right edge aligns with trigger right edge. + */ + menuAlign: {default: 'auto', type: String as PropType<'auto' | 'left' | 'right'>}, + /** + * Extra items rendered between the Profile link and Sign Out. + * Each item can carry an icon, a danger flag, and a separatorBefore flag. + */ + menuItems: {default: undefined, type: Array as PropType}, onProfileClick: {default: undefined, type: Function as PropType<() => void>}, onProfileModalClose: {default: undefined, type: Function as PropType<() => void>}, onSignOut: {default: undefined, type: Function as PropType<() => void>}, profileContent: {default: null, type: Object as PropType}, + /** Show the animated chevron on the trigger. Default `false`. */ + showChevron: {default: false, type: Boolean}, + /** Controls avatar size on the trigger and spacing density of the menu. */ + size: {default: 'md', type: String as PropType<'sm' | 'md' | 'lg'>}, user: {default: null, type: Object as PropType}, }, - setup( - props: { - className: string; - isProfileModalOpen: boolean; - onProfileClick?: () => void; - onProfileModalClose?: () => void; - onSignOut?: () => void; - profileContent: VNode | null; - user: User | null; - }, - {slots}: {slots: any}, - ): () => VNode | VNode[] | null { + setup(props: BaseUserDropdownProps, {slots}: {slots: any}): () => VNode | VNode[] | null { const isOpen: Ref = ref(false); - const prefix: typeof withVendorCSSClassPrefix = withVendorCSSClassPrefix; + const containerRef: Ref = ref(null); + const px = withVendorCSSClassPrefix; + + // ── Click-outside / Escape ──────────────────────────────────────────────── + + function handleClickOutside(event: MouseEvent): void { + if (containerRef.value && !containerRef.value.contains(event.target as Node)) { + isOpen.value = false; + } + } + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === 'Escape') isOpen.value = false; + } + + onMounted((): void => { + document.addEventListener('click', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + }); + + onUnmounted((): void => { + document.removeEventListener('click', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + }); + + // ── Auto-alignment ──────────────────────────────────────────────────────── + + function resolveMenuAlign(): 'left' | 'right' { + if (props.menuAlign !== 'auto') return props.menuAlign as 'left' | 'right'; + if (!containerRef.value) return 'right'; + const rect: DOMRect = containerRef.value.getBoundingClientRect(); + const menuWidth: number = MENU_MIN_WIDTHS[props.size ?? 'md'] ?? 220; + // Open toward whichever side has enough room; prefer right. + return window.innerWidth - rect.right >= menuWidth ? 'right' : 'left'; + } + + // ── Render ──────────────────────────────────────────────────────────────── return (): VNode | VNode[] | null => { if (slots['default']) { return slots['default']({ isOpen: isOpen.value, - toggle: () => { + toggle: (): void => { isOpen.value = !isOpen.value; }, user: props.user, }); } - const resolveDisplayName = (): string | undefined => { - if (!props.user) return undefined; - const {displayName, name, email, username, sub} = props.user as Record; - if (typeof displayName === 'string') return displayName; - if (typeof name === 'string') return name; - if (typeof name === 'object' && name) { - const parts: string[] = [(name as any).givenName, (name as any).familyName].filter(Boolean); - if (parts.length > 0) return parts.join(' '); - } - if (typeof email === 'string') return email; - if (typeof username === 'string') return username; - if (typeof sub === 'string') return sub; - return undefined; - }; - const displayName: string | undefined = resolveDisplayName(); - - const children: VNode[] = []; - - // Trigger button - children.push( - h( - 'button', - { - class: prefix('user-dropdown__trigger'), - onClick: () => { - isOpen.value = !isOpen.value; - }, - type: 'button', + const {displayName, initials, gradient, subtitle} = resolveUserInfo(props.user ?? null); + const size = props.size ?? 'md'; + + // ── Trigger ──────────────────────────────────────────────────────────── + + const avatarSizeClass = size !== 'md' ? px(`user-dropdown__avatar--${size}`) : ''; + const triggerClass = [px('user-dropdown__trigger'), isOpen.value ? px('user-dropdown__trigger--open') : ''] + .filter(Boolean) + .join(' '); + + const trigger: VNode = h( + 'button', + { + 'aria-expanded': isOpen.value, + 'aria-haspopup': 'true', + class: triggerClass, + onClick: (e: MouseEvent): void => { + e.stopPropagation(); + isOpen.value = !isOpen.value; }, - [ - h('span', {class: prefix('user-dropdown__avatar')}, [h(UserIcon, {size: 20})]), - displayName - ? h(Typography, {class: prefix('user-dropdown__name'), variant: 'body2'}, () => displayName) - : null, - h(ChevronDownIcon, {size: 16}), - ], - ), + type: 'button', + }, + [ + h( + 'span', + {class: [px('user-dropdown__avatar'), avatarSizeClass].filter(Boolean).join(' '), style: {background: gradient}}, + initials, + ), + props.showChevron + ? h('span', {class: px('user-dropdown__chevron')}, [h(ChevronDownIcon, {size: 14})]) + : null, + ], ); - // Dropdown menu + // ── Menu ─────────────────────────────────────────────────────────────── + + let menu: VNode | null = null; + if (isOpen.value) { - const menuItems: VNode[] = []; + const resolvedAlign = resolveMenuAlign(); + const alignClass = resolvedAlign === 'left' ? px('user-dropdown__menu--align-left') : ''; + const sizeClass = size !== 'md' ? px(`user-dropdown__menu--size-${size}`) : ''; + const menuClass = [px('user-dropdown__menu'), alignClass, sizeClass].filter(Boolean).join(' '); - if (props.onProfileClick) { - menuItems.push( - h('button', {class: prefix('user-dropdown__item'), onClick: props.onProfileClick, type: 'button'}, [ - h(UserIcon, {size: 16}), - h('span', null, 'Profile'), + // Build menu contents + const menuChildren: (VNode | null)[] = []; + + // Header + menuChildren.push( + h('div', {class: px('user-dropdown__menu-header')}, [ + h('div', {class: px('user-dropdown__menu-header-avatar'), style: {background: gradient}}, initials), + h('div', {class: px('user-dropdown__menu-header-info')}, [ + h('span', {class: px('user-dropdown__menu-header-name')}, displayName), + subtitle ? h('span', {class: px('user-dropdown__menu-header-subtitle')}, subtitle) : null, ]), + ]), + ); + + menuChildren.push(h('div', {class: px('user-dropdown__menu-divider')})); + + // Default Profile item + if (props.onProfileClick) { + menuChildren.push( + h( + 'button', + { + class: px('user-dropdown__item'), + onClick: (): void => { + isOpen.value = false; + props.onProfileClick!(); + }, + type: 'button', + }, + [h(UserIcon, {size: 15}), h('span', null, 'Profile')], + ), ); } + // Custom items from prop (with optional separatorBefore per item) + if (props.menuItems && props.menuItems.length > 0) { + props.menuItems.forEach((item: DropdownMenuItem, idx: number): void => { + if (item.separatorBefore) { + menuChildren.push(h('div', {class: px('user-dropdown__menu-divider'), key: `sep-${idx}`})); + } + menuChildren.push( + h( + 'button', + { + class: [px('user-dropdown__item'), item.danger ? px('user-dropdown__item--danger') : ''] + .filter(Boolean) + .join(' '), + key: `item-${idx}`, + onClick: (): void => { + isOpen.value = false; + item.onClick(); + }, + type: 'button', + }, + [item.icon ?? null, h('span', null, item.label)], + ), + ); + }); + } + + // Legacy slot items (backward compat) if (slots['items']) { - menuItems.push(...(slots['items']() ?? [])); + menuChildren.push(...(slots['items']() ?? [])); } + // Default Sign Out item (always last, always separated) if (props.onSignOut) { - menuItems.push( - h('button', {class: prefix('user-dropdown__item'), onClick: props.onSignOut, type: 'button'}, [ - h(LogOutIcon, {size: 16}), - h('span', null, 'Sign Out'), - ]), + menuChildren.push(h('div', {class: px('user-dropdown__menu-divider')})); + menuChildren.push( + h( + 'button', + { + class: [px('user-dropdown__item'), px('user-dropdown__item--danger')].join(' '), + onClick: (): void => { + isOpen.value = false; + props.onSignOut!(); + }, + type: 'button', + }, + [h(LogOutIcon, {size: 15}), h('span', null, 'Sign Out')], + ), ); } - children.push(h('div', {class: prefix('user-dropdown__menu')}, menuItems)); + menu = h('div', {class: menuClass}, menuChildren.filter(Boolean)); } + // ── Container ───────────────────────────────────────────────────────── + const container: VNode = h( 'div', - {class: [prefix('user-dropdown'), props.className].filter(Boolean).join(' ')}, - children, + {class: [px('user-dropdown'), props.className].filter(Boolean).join(' '), ref: containerRef}, + [trigger, menu], ); - // If profile modal is open, render modal overlay + // ── Profile modal ────────────────────────────────────────────────────── + if (props.isProfileModalOpen) { return h('div', [ container, - h('div', {class: prefix('user-dropdown__modal-overlay')}, [ - h('div', {class: prefix('user-dropdown__modal-content')}, [ - h( - 'button', - { - 'aria-label': 'Close profile modal', - class: prefix('user-dropdown__modal-close'), - onClick: props.onProfileModalClose, - type: 'button', - }, - [h(XIcon, {size: 24})], - ), - props.profileContent, - ]), - ]), + h( + 'div', + { + class: px('user-dropdown__modal-overlay'), + onClick: (e: MouseEvent): void => { + if ((e.target as HTMLElement).classList.contains(px('user-dropdown__modal-overlay'))) { + props.onProfileModalClose?.(); + } + }, + }, + [ + h('div', {class: px('user-dropdown__modal-content')}, [ + h( + 'button', + { + 'aria-label': 'Close profile', + class: px('user-dropdown__modal-close'), + onClick: props.onProfileModalClose, + type: 'button', + }, + [h(XIcon, {size: 18})], + ), + props.profileContent, + ]), + ], + ), ]); } diff --git a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts index 23cf6e01a..03e9a3471 100644 --- a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts +++ b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts @@ -21,16 +21,26 @@ * * BEM block: `.asgardeo-user-dropdown` * - * The root element is a plain `div` (no Card wrapper), so this file - * is responsible for all layout. The dropdown is absolute-positioned - * relative to the root using `position: relative`. + * Trigger modifiers: + * __trigger--open – ring + border while menu is visible + * __avatar--sm / --lg – trigger avatar size variants (default is 32 px) + * + * Menu modifiers: + * __menu--align-left – panel opens to the left of the trigger + * __menu--size-sm – compact menu (180 px min-width, tighter padding) + * __menu--size-lg – spacious menu (280 px min-width, more padding) + * + * Item modifiers: + * __item--danger – destructive action (red text/hover) * * Elements: - * __trigger – pill-shaped trigger button (avatar + name + chevron) - * __avatar – circular icon container inside the trigger - * __name – display-name Typography inside the trigger - * __menu – absolute-positioned dropdown panel - * __item – each action row inside the menu + * __chevron – rotates 180° when menu is open + * __menu-header – user identity section at top of menu + * __menu-header-avatar – gradient avatar circle in header + * __menu-header-info – name + subtitle column + * __menu-header-name – bold display name + * __menu-header-subtitle – muted email / username + * __menu-divider – thin horizontal separator */ const USER_DROPDOWN_CSS: string = ` /* ============================================================ @@ -43,68 +53,90 @@ const USER_DROPDOWN_CSS: string = ` font-family: var(--asgardeo-typography-fontFamily); } -/* Trigger ---------------------------------------------------- */ +/* ── Trigger ─────────────────────────────────────────────────── */ .asgardeo-user-dropdown__trigger { display: inline-flex; align-items: center; - gap: calc(var(--asgardeo-spacing-unit) * 0.75); - padding: calc(var(--asgardeo-spacing-unit) * 0.5) calc(var(--asgardeo-spacing-unit) * 1) - calc(var(--asgardeo-spacing-unit) * 0.5) calc(var(--asgardeo-spacing-unit) * 0.5); + gap: calc(var(--asgardeo-spacing-unit) * 0.5); + padding: 3px; background: none; - border: 1px solid var(--asgardeo-color-border); - border-radius: 9999px; + border: 2px solid transparent; + border-radius: var(--asgardeo-border-radius-full); cursor: pointer; color: var(--asgardeo-color-text-primary); - font-family: var(--asgardeo-typography-fontFamily); - font-size: var(--asgardeo-typography-fontSize-md); transition: - background-color var(--asgardeo-transition-fast), border-color var(--asgardeo-transition-fast), box-shadow var(--asgardeo-transition-fast); box-sizing: border-box; + outline: none; } .asgardeo-user-dropdown__trigger:hover { - background-color: var(--asgardeo-color-action-hover); border-color: var(--asgardeo-color-primary-main); } +.asgardeo-user-dropdown__trigger--open { + border-color: var(--asgardeo-color-primary-main); + box-shadow: 0 0 0 3px var(--asgardeo-focus-ring-color); +} + .asgardeo-user-dropdown__trigger:focus-visible { - outline: none; + border-color: var(--asgardeo-color-primary-main); box-shadow: 0 0 0 var(--asgardeo-focus-ring-width) var(--asgardeo-focus-ring-color); } -/* Avatar ---------------------------------------------------- */ +/* ── Trigger avatar ──────────────────────────────────────────── */ .asgardeo-user-dropdown__avatar { display: flex; align-items: center; justify-content: center; - width: calc(var(--asgardeo-spacing-unit) * 3); - height: calc(var(--asgardeo-spacing-unit) * 3); + width: 32px; + height: 32px; border-radius: 50%; - background-color: var(--asgardeo-color-primary-main); - color: var(--asgardeo-color-primary-contrastText); + color: #ffffff; flex-shrink: 0; font-size: var(--asgardeo-typography-fontSize-sm); - font-weight: var(--asgardeo-typography-fontWeight-medium); + font-weight: var(--asgardeo-typography-fontWeight-semibold); + line-height: 1; + user-select: none; + pointer-events: none; } -/* Name ------------------------------------------------------ */ +/* sm — 28 px */ +.asgardeo-user-dropdown__avatar--sm { + width: 28px; + height: 28px; + font-size: var(--asgardeo-typography-fontSize-xs); +} -.asgardeo-user-dropdown__name { - max-width: 140px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +/* lg — 38 px */ +.asgardeo-user-dropdown__avatar--lg { + width: 38px; + height: 38px; + font-size: var(--asgardeo-typography-fontSize-md); +} + +/* ── Chevron ─────────────────────────────────────────────────── */ + +.asgardeo-user-dropdown__chevron { + display: inline-flex; + align-items: center; + color: var(--asgardeo-color-text-secondary); + transition: transform var(--asgardeo-transition-normal); + padding-right: calc(var(--asgardeo-spacing-unit) * 0.25); +} + +.asgardeo-user-dropdown__trigger--open .asgardeo-user-dropdown__chevron { + transform: rotate(180deg); } -/* Dropdown menu --------------------------------------------- */ +/* ── Dropdown menu ───────────────────────────────────────────── */ .asgardeo-user-dropdown__menu { position: absolute; - top: calc(100% + calc(var(--asgardeo-spacing-unit) * 0.5)); + top: calc(100% + calc(var(--asgardeo-spacing-unit) * 0.75)); right: 0; z-index: 1000; background-color: var(--asgardeo-color-background-surface); @@ -112,20 +144,141 @@ const USER_DROPDOWN_CSS: string = ` border-radius: var(--asgardeo-dropdown-borderRadius); box-shadow: var(--asgardeo-dropdown-shadow); overflow: hidden; - min-width: 160px; + min-width: 220px; + display: flex; + flex-direction: column; + animation: asgardeo-dropdown-enter var(--asgardeo-transition-fast) ease; +} + +/* Alignment */ + +.asgardeo-user-dropdown__menu--align-left { + right: auto; + left: 0; +} + +/* Size: sm */ + +.asgardeo-user-dropdown__menu--size-sm { + min-width: 180px; +} + +.asgardeo-user-dropdown__menu--size-sm .asgardeo-user-dropdown__menu-header { + padding: calc(var(--asgardeo-spacing-unit) * 1.25) calc(var(--asgardeo-spacing-unit) * 1.5); + gap: calc(var(--asgardeo-spacing-unit) * 1); +} + +.asgardeo-user-dropdown__menu--size-sm .asgardeo-user-dropdown__menu-header-avatar { + width: 30px; + height: 30px; + font-size: var(--asgardeo-typography-fontSize-sm); +} + +.asgardeo-user-dropdown__menu--size-sm .asgardeo-user-dropdown__item { + padding: calc(var(--asgardeo-spacing-unit) * 0.75) calc(var(--asgardeo-spacing-unit) * 1.5); + font-size: var(--asgardeo-typography-fontSize-xs); +} + +/* Size: lg */ + +.asgardeo-user-dropdown__menu--size-lg { + min-width: 280px; +} + +.asgardeo-user-dropdown__menu--size-lg .asgardeo-user-dropdown__menu-header { + padding: calc(var(--asgardeo-spacing-unit) * 2) calc(var(--asgardeo-spacing-unit) * 2); + gap: calc(var(--asgardeo-spacing-unit) * 1.5); +} + +.asgardeo-user-dropdown__menu--size-lg .asgardeo-user-dropdown__menu-header-avatar { + width: 42px; + height: 42px; + font-size: var(--asgardeo-typography-fontSize-lg); +} + +.asgardeo-user-dropdown__menu--size-lg .asgardeo-user-dropdown__item { + padding: calc(var(--asgardeo-spacing-unit) * 1.25) calc(var(--asgardeo-spacing-unit) * 2); + font-size: var(--asgardeo-typography-fontSize-md); +} + +@keyframes asgardeo-dropdown-enter { + from { + opacity: 0; + transform: translateY(-6px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* ── Menu header (user identity) ─────────────────────────────── */ + +.asgardeo-user-dropdown__menu-header { + display: flex; + align-items: center; + gap: calc(var(--asgardeo-spacing-unit) * 1.25); + padding: calc(var(--asgardeo-spacing-unit) * 1.5) calc(var(--asgardeo-spacing-unit) * 1.75); +} + +.asgardeo-user-dropdown__menu-header-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + color: #ffffff; + flex-shrink: 0; + font-size: var(--asgardeo-typography-fontSize-md); + font-weight: var(--asgardeo-typography-fontWeight-semibold); + line-height: 1; + user-select: none; +} + +.asgardeo-user-dropdown__menu-header-info { display: flex; flex-direction: column; - padding: calc(var(--asgardeo-spacing-unit) * 0.5) 0; + gap: 2px; + min-width: 0; +} + +.asgardeo-user-dropdown__menu-header-name { + font-size: var(--asgardeo-typography-fontSize-sm); + font-weight: var(--asgardeo-typography-fontWeight-semibold); + color: var(--asgardeo-color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: var(--asgardeo-typography-lineHeight-tight); +} + +.asgardeo-user-dropdown__menu-header-subtitle { + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +/* ── Menu divider ────────────────────────────────────────────── */ + +.asgardeo-user-dropdown__menu-divider { + height: 1px; + background-color: var(--asgardeo-color-border); + margin: calc(var(--asgardeo-spacing-unit) * 0.5) 0; + flex-shrink: 0; } -/* Menu items ------------------------------------------------ */ +/* ── Menu items ──────────────────────────────────────────────── */ .asgardeo-user-dropdown__item { display: flex; align-items: center; - gap: calc(var(--asgardeo-spacing-unit) * 0.75); + gap: calc(var(--asgardeo-spacing-unit) * 1); width: 100%; - padding: var(--asgardeo-dropdown-itemPaddingY) var(--asgardeo-dropdown-itemPaddingX); + padding: calc(var(--asgardeo-spacing-unit) * 1) calc(var(--asgardeo-spacing-unit) * 1.75); background: none; border: none; cursor: pointer; @@ -146,36 +299,61 @@ const USER_DROPDOWN_CSS: string = ` background-color: var(--asgardeo-color-action-focus); } -/* Modal overlay ------------------------------------------------ */ +/* Danger variant (sign-out) */ + +.asgardeo-user-dropdown__item--danger { + color: var(--asgardeo-color-error-main); +} + +.asgardeo-user-dropdown__item--danger:hover { + background-color: var(--asgardeo-color-error-light); +} + +/* ── Modal overlay ───────────────────────────────────────────── */ .asgardeo-user-dropdown__modal-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.4); + inset: 0; + background-color: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; z-index: 9999; - backdrop-filter: blur(2px); + backdrop-filter: blur(3px); + animation: asgardeo-overlay-enter var(--asgardeo-transition-fast) ease; +} + +@keyframes asgardeo-overlay-enter { + from { opacity: 0; } + to { opacity: 1; } } -/* Modal content ------------------------------------------------ */ +/* ── Modal content ───────────────────────────────────────────── */ .asgardeo-user-dropdown__modal-content { background: var(--asgardeo-color-background-surface); - border-radius: var(--asgardeo-border-radius-medium); + border-radius: var(--asgardeo-border-radius-large); box-shadow: var(--asgardeo-shadow-large); - max-width: 460px; - width: 90%; + max-width: 480px; + width: 92%; max-height: 90vh; overflow-y: auto; position: relative; + animation: asgardeo-modal-enter var(--asgardeo-transition-normal) ease; +} + +@keyframes asgardeo-modal-enter { + from { + opacity: 0; + transform: translateY(12px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } } -/* Modal close button ------------------------------------------ */ +/* ── Modal close button ──────────────────────────────────────── */ .asgardeo-user-dropdown__modal-close { position: absolute; @@ -188,7 +366,7 @@ const USER_DROPDOWN_CSS: string = ` display: flex; align-items: center; justify-content: center; - padding: calc(var(--asgardeo-spacing-unit) * 0.5); + padding: calc(var(--asgardeo-spacing-unit) * 0.625); border-radius: var(--asgardeo-border-radius-small); z-index: 10001; transition: diff --git a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts index eb35e5273..05b70754f 100644 --- a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts +++ b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts @@ -17,26 +17,83 @@ */ import {withVendorCSSClassPrefix} from '@asgardeo/browser'; -import {type Component, type Ref, type VNode, defineComponent, h, ref} from 'vue'; -import BaseUserDropdown from './BaseUserDropdown'; +import {type Component, type PropType, type Ref, type VNode, defineComponent, h, ref} from 'vue'; +import BaseUserDropdown, {type DropdownMenuItem} from './BaseUserDropdown'; import useAsgardeo from '../../../composables/useAsgardeo'; import UserProfileComponent from '../user-profile/UserProfile'; /** - * UserDropdown — styled user dropdown component. + * UserDropdown — avatar button that opens a user identity menu. * - * Retrieves user and signOut from context and delegates to BaseUserDropdown. + * @example Default usage + * ```vue + * + * ``` + * + * @example With custom menu items and a separator + * ```vue + * + * ``` + * + * @example Small, left-aligned, no chevron + * ```vue + * + * ``` */ const UserDropdown: Component = defineComponent({ emits: ['profileClick'], name: 'UserDropdown', props: { - className: { - default: '', - type: String, + /** Extra CSS class added to the root element. */ + className: {default: '', type: String}, + /** + * How to align the dropdown panel relative to the trigger button. + * - `'auto'` (default) — picks the side with more available viewport space at open time. + * - `'left'` — panel left edge aligns with trigger left edge. + * - `'right'` — panel right edge aligns with trigger right edge. + */ + menuAlign: { + default: 'auto', + type: String as PropType<'auto' | 'left' | 'right'>, + }, + /** + * Extra items inserted between the Profile link and Sign Out. + * Set `separatorBefore: true` on any item to add a divider line before it. + * Set `danger: true` for destructive actions (red styling). + * Pass an `icon` VNode to render an icon to the left of the label. + */ + menuItems: { + default: undefined, + type: Array as PropType, + }, + /** Whether to show the animated down-chevron beside the avatar. Default `false`. */ + showChevron: {default: false, type: Boolean}, + /** + * Overall density / avatar size of the component. + * - `'sm'` — 28 px avatar, compact menu (180 px min-width). + * - `'md'` (default) — 32 px avatar, standard menu (220 px min-width). + * - `'lg'` — 38 px avatar, spacious menu (280 px min-width). + */ + size: { + default: 'md', + type: String as PropType<'sm' | 'md' | 'lg'>, }, }, - setup(props: {className: string}, {slots, emit}: {emit: any; slots: any}): () => VNode | VNode[] | null { + setup( + props: { + className: string; + menuAlign: 'auto' | 'left' | 'right'; + menuItems?: DropdownMenuItem[]; + showChevron: boolean; + size: 'sm' | 'md' | 'lg'; + }, + {slots, emit}: {emit: any; slots: any}, + ): () => VNode | VNode[] | null { const {user, signOut} = useAsgardeo(); const isProfileModalOpen: Ref = ref(false); @@ -47,6 +104,8 @@ const UserDropdown: Component = defineComponent({ class: withVendorCSSClassPrefix('user-dropdown--styled'), className: props.className, isProfileModalOpen: isProfileModalOpen.value, + menuAlign: props.menuAlign, + menuItems: props.menuItems, onProfileClick: (): void => { isProfileModalOpen.value = true; emit('profileClick'); @@ -60,9 +119,12 @@ const UserDropdown: Component = defineComponent({ profileContent: isProfileModalOpen.value ? h(UserProfileComponent, { cardLayout: false, + compact: true, editable: true, }) : null, + showChevron: props.showChevron, + size: props.size, user: user.value, }, slots, diff --git a/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts b/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts index cbeb20c85..f5002ec51 100644 --- a/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts +++ b/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts @@ -19,8 +19,6 @@ import { type User, type Schema, - type SchemaAttribute, - type UpdateMeProfileConfig, WellKnownSchemaIds, withVendorCSSClassPrefix, } from '@asgardeo/browser'; @@ -28,108 +26,119 @@ import {type Component, type PropType, type Ref, type SetupContext, type VNode, import Alert from '../../primitives/Alert'; import Button from '../../primitives/Button'; import Card from '../../primitives/Card'; +import Checkbox from '../../primitives/Checkbox'; +import DatePicker from '../../primitives/DatePicker'; import Divider from '../../primitives/Divider'; import {PencilIcon} from '../../primitives/Icons'; import Spinner from '../../primitives/Spinner'; import TextField from '../../primitives/TextField'; import Typography from '../../primitives/Typography'; - -/** - * Runtime shape produced by `flattenUserSchema` — a `SchemaAttribute` with the - * owning schema's URN attached. Modelled locally because the published - * `FlattenedSchema` type incorrectly extends `Schema` instead of - * `SchemaAttribute` and is therefore missing fields like `multiValued`. - */ -type FlatSchemaEntry = SchemaAttribute & {schemaId: string}; +import getDisplayName from '../../../utils/getDisplayName'; +import getMappedUserProfileValue from '../../../utils/getMappedUserProfileValue'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface ExtendedSchema { + description?: string; + displayName?: string; + displayOrder?: string; + multiValued?: boolean; + mutability?: string; + name?: string; + required?: boolean; + schemaId?: string; + subAttributes?: ExtendedSchema[]; + type?: string; + value?: any; +} export interface BaseUserProfileProps { + avatarSize?: 'sm' | 'md' | 'lg'; cardLayout?: boolean; + cardVariant?: 'elevated' | 'outlined' | 'flat'; className?: string; + compact?: boolean; editable?: boolean; error?: string | null; - flattenedProfile?: User; + flattenedProfile?: User | null; hideFields?: string[]; isLoading?: boolean; - onUpdate?: ( - requestConfig: UpdateMeProfileConfig, - sessionId?: string, - ) => Promise<{data: {user: User}; error: string; success: boolean}>; - profile?: User; - schemas?: Schema[]; + onUpdate?: (payload: any) => Promise; + profile?: User | null; + schemas?: Schema[] | null; + showAvatar?: boolean; showFields?: string[]; title?: string; } -/** - * Ordered list of fields to display. Each entry specifies candidate key names - * (first match in the profile data wins), a human-readable label, and whether - * the field is read-only. - */ -type ProfileFieldDescriptor = {keys: string[]; label: string; readonly: boolean}; +// ─── Constants ─────────────────────────────────────────────────────────────── + +const FIELDS_TO_SKIP: string[] = [ + 'roles.default', + 'active', + 'groups', + 'accountLocked', + 'accountDisabled', + 'oneTimePassword', + 'userSourceId', + 'idpType', + 'localCredentialExists', + 'ResourceType', + 'ExternalID', + 'MetaData', + 'verifiedMobileNumbers', + 'verifiedEmailAddresses', + 'phoneNumbers.mobile', + 'emailAddresses', + 'preferredMFAOption', +]; -/** - * Each entry's `keys` lists candidate flattened paths produced by - * `generateFlattenedUserProfile`. The first key that exists in the profile - * data is used both for display lookup and as the SCIM2 attribute path - * `buildScimPatchValue` translates back into a proper PATCH payload. - */ -const PROFILE_FIELD_DESCRIPTORS: ProfileFieldDescriptor[] = [ - {keys: ['userName', 'username'], label: 'Username', readonly: true}, - {keys: ['name.givenName', 'firstName', 'givenName'], label: 'First Name', readonly: false}, - {keys: ['name.familyName', 'lastName', 'familyName'], label: 'Last Name', readonly: false}, - {keys: ['emails', 'email'], label: 'Email', readonly: true}, - {keys: ['country'], label: 'Country', readonly: false}, - {keys: ['dateOfBirth', 'birthdate', 'birthDate'], label: 'Birth Date', readonly: false}, - {keys: ['phoneNumbers.mobile', 'mobile', 'mobileNumbers'], label: 'Mobile', readonly: false}, +const READONLY_FIELDS: string[] = ['username', 'userName', 'user_name']; + +const DEFAULT_ATTRIBUTE_MAPPINGS: Record = { + email: ['emails', 'email'], + firstName: ['name.givenName', 'given_name'], + lastName: ['name.familyName', 'family_name'], + picture: ['profile', 'profileUrl', 'picture', 'URL'], + username: ['userName', 'username', 'user_name'], +}; + +const AVATAR_GRADIENTS: string[] = [ + 'linear-gradient(135deg, #4b6ef5 0%, #7c3aed 100%)', + 'linear-gradient(135deg, #0ea5e9 0%, #4b6ef5 100%)', + 'linear-gradient(135deg, #10b981 0%, #0ea5e9 100%)', + 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)', + 'linear-gradient(135deg, #ec4899 0%, #7c3aed 100%)', + 'linear-gradient(135deg, #8b5cf6 0%, #4b6ef5 100%)', + 'linear-gradient(135deg, #14b8a6 0%, #0ea5e9 100%)', + 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)', ]; -const CORE_USER_SCHEMA_ID: string = WellKnownSchemaIds.User; +// ─── Helpers ───────────────────────────────────────────────────────────────── -const setNestedPath = (target: Record, segments: string[], value: unknown): void => { - let cursor: Record = target; - for (let i: number = 0; i < segments.length - 1; i += 1) { - const segment: string = segments[i]; - if (typeof cursor[segment] !== 'object' || cursor[segment] === null) { - cursor[segment] = {}; - } - cursor = cursor[segment] as Record; +function getAvatarGradient(seed: string): string { + if (!seed) return AVATAR_GRADIENTS[0]; + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + // eslint-disable-next-line no-bitwise + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; } - cursor[segments[segments.length - 1]] = value; -}; + return AVATAR_GRADIENTS[Math.abs(hash) % AVATAR_GRADIENTS.length]; +} -/** - * Build a SCIM2 PATCH `value` object for a flattened profile field update. - * - * Translates a flat key (e.g. `name.givenName`, `phoneNumbers.mobile`, - * `country`) plus a string value into the canonical SCIM2 structure expected - * by the `/scim2/Me` PATCH endpoint: - * - * - `name.givenName` → `{name: {givenName: value}}` - * - `phoneNumbers.mobile` → `{phoneNumbers: [{type: 'mobile', value}]}` - * - `emails.work` → `{emails: [{type: 'work', value}]}` - * - `country` (WSO2 ext) → `{"urn:scim:wso2:schema": {country: value}}` - * - `mobileNumbers` (multiV) → `{"urn:scim:wso2:schema": {mobileNumbers: [value]}}` - * - * Falls back to a plain `{[flatKey]: value}` when the schema is unknown so - * unrecognised fields still produce a syntactically valid (if possibly - * ineffective) PATCH instead of throwing. - */ -const buildScimPatchValue = ( +function formatLabel(key: string): string { + return key + .split(/(?=[A-Z])|[_.]/) + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); +} + +function buildScimPatchValue( flatKey: string, - rawValue: string, - schemas: Schema[] | null | undefined, -): Record => { - const list: FlatSchemaEntry[] = (schemas ?? []) as unknown as FlatSchemaEntry[]; - const entry: FlatSchemaEntry | undefined = list.find((s: FlatSchemaEntry) => s.name === flatKey); - - // Special case: in Asgardeo / WSO2 IS the user's mobile is stored across two - // independent SCIM2 attributes that map to two distinct userstore columns: - // - phoneNumbers[type=mobile].value → claim http://wso2.org/claims/mobile - // - urn:scim:wso2:schema.mobileNumbers → claim http://wso2.org/claims/mobileNumbers - // ID-token claims and the SCIM `phoneNumbers` array read from the first; - // the Asgardeo Console's User Details page and consumers that read the - // multi-valued aggregate read from the second. Update both in the same - // PATCH so the value is consistent everywhere. + rawValue: any, + schemaId: string | undefined, + multiValued: boolean | undefined, +): Record { if (flatKey === 'phoneNumbers.mobile') { return { phoneNumbers: [{type: 'mobile', value: rawValue}], @@ -137,291 +146,353 @@ const buildScimPatchValue = ( }; } - // SCIM multi-valued complex attributes (phoneNumbers, emails, ims, photos, ...) - // surface in flattenedProfile as `.` (e.g. phoneNumbers.mobile). - // The PATCH shape is an array of typed objects, NOT a nested object — - // sending `{phoneNumbers: {mobile: "..."}}` is invalid and silently dropped. - const complexMultiValued: Set = new Set([ - 'phoneNumbers', - 'emails', - 'ims', - 'photos', - 'addresses', - 'entitlements', - 'roles', - 'x509Certificates', + const complexMultiValued = new Set([ + 'phoneNumbers', 'emails', 'ims', 'photos', 'addresses', + 'entitlements', 'roles', 'x509Certificates', ]); - const dotIndex: number = flatKey.indexOf('.'); + + const dotIndex = flatKey.indexOf('.'); if (dotIndex > 0) { - const head: string = flatKey.slice(0, dotIndex); - const tail: string = flatKey.slice(dotIndex + 1); + const head = flatKey.slice(0, dotIndex); + const tail = flatKey.slice(dotIndex + 1); if (complexMultiValued.has(head)) { return {[head]: [{type: tail, value: rawValue}]}; } } - // Multi-valued simple attributes (e.g. mobileNumbers under WSO2 schema): - // wrap the value in an array. - const value: unknown = entry?.multiValued ? [rawValue] : rawValue; - - // Build the nested object for dotted attribute paths within the same - // schema (e.g. name.givenName → {name: {givenName: value}}). - const segments: string[] = flatKey.split('.'); - const nested: Record = {}; - setNestedPath(nested, segments, value); + const value: unknown = multiValued ? [rawValue] : rawValue; - // If the attribute belongs to an extension schema, wrap under its URN. - // Core attributes (urn:...:core:2.0:User) sit at the root. - const schemaId: string | undefined = entry?.schemaId; - if (schemaId && schemaId !== CORE_USER_SCHEMA_ID) { - return {[schemaId]: nested}; + if (schemaId && schemaId !== WellKnownSchemaIds.User) { + return {[schemaId]: {[flatKey]: value}}; } - return nested; -}; - -const AVATAR_GRADIENTS: string[] = [ - 'linear-gradient(135deg, #a855f7 0%, #ec4899 100%)', - 'linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%)', - 'linear-gradient(135deg, #22c55e 0%, #10b981 100%)', - 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)', - 'linear-gradient(135deg, #ec4899 0%, #f43f5e 100%)', - 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)', - 'linear-gradient(135deg, #14b8a6 0%, #0ea5e9 100%)', - 'linear-gradient(135deg, #f97316 0%, #eab308 100%)', -]; - -const getAvatarGradient = (seed: string): string => { - if (!seed) return AVATAR_GRADIENTS[0]; - let hash: number = 0; - for (let i: number = 0; i < seed.length; i += 1) { - const char: number = seed.charCodeAt(i); - // eslint-disable-next-line no-bitwise - hash = (hash * 31 + char) >>> 0; + const segments = flatKey.split('.'); + const nested: Record = {}; + let cursor: Record = nested; + for (let i = 0; i < segments.length - 1; i += 1) { + cursor[segments[i]] = {}; + cursor = cursor[segments[i]] as Record; } - return AVATAR_GRADIENTS[Math.abs(hash) % AVATAR_GRADIENTS.length]; -}; + cursor[segments[segments.length - 1]] = value; + return nested; +} -const getUserInitials = (user: Record | null): string => { - if (!user) return '?'; - const given: string = String(user['givenName'] || user['firstName'] || ''); - const family: string = String(user['familyName'] || user['lastName'] || ''); - if (given || family) return `${given.charAt(0)}${family.charAt(0)}`.toUpperCase(); - const fallback: string = String(user['username'] || user['userName'] || user['email'] || user['sub'] || ''); - return fallback.charAt(0).toUpperCase() || '?'; -}; +// ─── Component ─────────────────────────────────────────────────────────────── -/** - * BaseUserProfile — unstyled user profile component. - * - * Renders a profile card with avatar, title, and two-column field rows - * that support inline editing via a pencil-icon button. - */ const BaseUserProfile: Component = defineComponent({ inheritAttrs: false, name: 'BaseUserProfile', props: { + /** Avatar circle size. */ + avatarSize: { + default: 'lg', + type: String as PropType<'sm' | 'md' | 'lg'>, + }, cardLayout: {default: true, type: Boolean}, + /** Shadow / border style of the Card wrapper. */ + cardVariant: { + default: 'elevated', + type: String as PropType<'elevated' | 'outlined' | 'flat'>, + }, className: {default: '', type: String}, + /** Tighter field spacing for modal / dropdown contexts. */ + compact: {default: false, type: Boolean}, editable: {default: true, type: Boolean}, error: {default: null, type: String as PropType}, flattenedProfile: {default: null, type: Object as PropType}, hideFields: {default: () => [], type: Array as PropType}, isLoading: {default: false, type: Boolean}, - onUpdate: { - default: undefined, - type: Function as PropType< - ( - requestConfig: UpdateMeProfileConfig, - sessionId?: string, - ) => Promise<{data: {user: User}; error: string; success: boolean}> - >, - }, + onUpdate: {default: undefined, type: Function as PropType<(payload: any) => Promise>}, profile: {default: null, type: Object as PropType}, - schemas: {default: () => [], type: Array as PropType}, + schemas: {default: () => [], type: Array as PropType}, + /** Whether to render the avatar hero banner. */ + showAvatar: {default: true, type: Boolean}, showFields: {default: () => [], type: Array as PropType}, title: {default: 'Profile', type: String}, }, - setup(props: BaseUserProfileProps, {slots}: SetupContext): () => VNode | VNode[] { - const editingFields: Ref> = ref>({}); - const editedValues: Ref> = ref>({}); + setup(props: BaseUserProfileProps, {slots}: SetupContext): () => VNode | VNode[] | null { + const editingFields: Ref> = ref({}); + const editedValues: Ref> = ref({}); - return (): VNode | VNode[] => { - if (slots['default']) { - return slots['default']({ - error: props.error, - isLoading: props.isLoading, - profile: props.flattenedProfile || props.profile, - }); + const px = withVendorCSSClassPrefix; + + // ── Visibility ──────────────────────────────────────────────────────────── + + function shouldShowField(fieldName: string): boolean { + if (FIELDS_TO_SKIP.includes(fieldName)) return false; + if (props.hideFields && props.hideFields.length > 0 && props.hideFields.includes(fieldName)) return false; + if (props.showFields && props.showFields.length > 0) return props.showFields.includes(fieldName); + return true; + } + + // ── Edit state ──────────────────────────────────────────────────────────── + + function startEditing(fieldName: string, currentValue: any): void { + editedValues.value = {...editedValues.value, [fieldName]: currentValue ?? ''}; + editingFields.value = {...editingFields.value, [fieldName]: true}; + } + + function cancelEditing(fieldName: string): void { + const data = props.flattenedProfile || props.profile; + const originalValue = (data as Record)?.[fieldName] ?? ''; + editedValues.value = {...editedValues.value, [fieldName]: originalValue}; + editingFields.value = {...editingFields.value, [fieldName]: false}; + } + + function saveField(schema: ExtendedSchema): void { + if (!props.onUpdate || !schema.name) return; + const value = editedValues.value[schema.name] ?? ''; + const payload = buildScimPatchValue(schema.name, value, schema.schemaId, schema.multiValued); + props.onUpdate(payload); + editingFields.value = {...editingFields.value, [schema.name]: false}; + } + + // ── Input rendering per schema type ─────────────────────────────────────── + + function renderInput(schema: ExtendedSchema): VNode { + const fieldName = schema.name!; + const currentValue = editedValues.value[fieldName]; + + switch (schema.type) { + case 'DATE_TIME': + return h(DatePicker, { + modelValue: String(currentValue ?? ''), + 'onUpdate:modelValue': (v: string) => { + editedValues.value = {...editedValues.value, [fieldName]: v}; + }, + placeholder: `Enter your ${(schema.displayName || fieldName).toLowerCase()}`, + required: schema.required, + }); + + case 'BOOLEAN': + return h(Checkbox, { + label: schema.displayName || fieldName, + modelValue: Boolean(currentValue), + 'onUpdate:modelValue': (v: boolean) => { + editedValues.value = {...editedValues.value, [fieldName]: v}; + }, + }); + + default: + return h(TextField, { + modelValue: String(currentValue ?? ''), + 'onUpdate:modelValue': (v: string) => { + editedValues.value = {...editedValues.value, [fieldName]: v}; + }, + placeholder: `Enter your ${(schema.displayName || fieldName).toLowerCase()}`, + required: schema.required, + }); } + } - const prefix: (className: string) => string = withVendorCSSClassPrefix; - const data: User | null | undefined = props.flattenedProfile || props.profile; - const dataRecord: Record | null = data as Record | null; - const initials: string = getUserInitials(dataRecord); - const avatarSeed: string = String( - (dataRecord && - (dataRecord['username'] || dataRecord['userName'] || dataRecord['email'] || dataRecord['sub'])) ?? - initials, - ); - const avatarGradient: string = getAvatarGradient(avatarSeed); + // ── Schema-driven field row ─────────────────────────────────────────────── - const children: VNode[] = []; + function renderSchemaFieldRow(schema: ExtendedSchema): VNode | null { + const {name, displayName, description, mutability, value} = schema; + if (!name || !shouldShowField(name)) return null; - // Header: title - children.push( - h('div', {class: prefix('user-profile__header')}, [ - h(Typography, {class: prefix('user-profile__title'), variant: 'h5'}, () => props.title), + const label = displayName || description || formatLabel(name); + const isReadonly = mutability === 'READ_ONLY' || READONLY_FIELDS.includes(name); + const isEditable = props.editable && !isReadonly; + const isEditing = editingFields.value[name]; + const hasValue = value !== undefined && value !== null && value !== ''; + + if (!hasValue && !isEditing && !(isEditable && mutability === 'READ_WRITE')) return null; + + return h('div', {class: px('user-profile__field'), key: name}, [ + h('div', {class: px('user-profile__field-label-col')}, [ + h(Typography, {class: px('user-profile__field-label'), variant: 'body2'}, () => label), ]), - ); + h('div', {class: px('user-profile__field-value-col')}, [ + isEditing + ? h('div', {class: px('user-profile__field-edit')}, [ + renderInput(schema), + h('div', {class: px('user-profile__field-edit-actions')}, [ + h( + Button, + {onClick: () => saveField(schema), size: 'small' as const, variant: 'solid' as const}, + () => 'Save', + ), + h( + Button, + {onClick: () => cancelEditing(name), size: 'small' as const, variant: 'text' as const}, + () => 'Cancel', + ), + ]), + ]) + : h('div', {class: px('user-profile__field-display')}, [ + hasValue + ? h(Typography, {class: px('user-profile__field-value'), variant: 'body1'}, () => String(value)) + : isEditable + ? h( + 'span', + {class: px('user-profile__field-placeholder'), onClick: () => startEditing(name, value)}, + `Enter your ${label.toLowerCase()}`, + ) + : null, + isEditable + ? h( + 'button', + { + 'aria-label': `Edit ${label}`, + class: px('user-profile__field-edit-btn'), + onClick: () => startEditing(name, value), + type: 'button', + }, + [h(PencilIcon)], + ) + : null, + ]), + ]), + ]); + } + + // ── Fallback: no schemas ────────────────────────────────────────────────── + + function renderProfileWithoutSchemas(): VNode[] { + const data = (props.flattenedProfile || props.profile) as Record | null; + if (!data) return []; + + return Object.entries(data) + .filter(([key, value]: [string, any]) => { + if (!shouldShowField(key)) return false; + return value !== undefined && value !== null && value !== ''; + }) + .sort(([a]: [string, any], [b]: [string, any]) => a.localeCompare(b)) + .map(([key, value]: [string, any]) => + h('div', {class: px('user-profile__field'), key}, [ + h('div', {class: px('user-profile__field-label-col')}, [ + h(Typography, {class: px('user-profile__field-label'), variant: 'body2'}, () => formatLabel(key)), + ]), + h('div', {class: px('user-profile__field-value-col')}, [ + h(Typography, {class: px('user-profile__field-value'), variant: 'body1'}, () => + typeof value === 'object' ? JSON.stringify(value) : String(value), + ), + ]), + ]), + ); + } - children.push(h(Divider, {class: prefix('user-profile__header-divider')})); + // ── Hero section ────────────────────────────────────────────────────────── - // Avatar section - children.push( - h('div', {class: prefix('user-profile__avatar-section')}, [ + function renderHero(currentUser: Record): VNode { + const displayName = getDisplayName(DEFAULT_ATTRIBUTE_MAPPINGS, currentUser as User); + const email = + getMappedUserProfileValue('email', DEFAULT_ATTRIBUTE_MAPPINGS, currentUser as User) || + getMappedUserProfileValue('username', DEFAULT_ATTRIBUTE_MAPPINGS, currentUser as User); + + const avatarSeed = String( + currentUser['username'] || currentUser['userName'] || currentUser['email'] || currentUser['sub'] || displayName, + ); + const avatarGradient = getAvatarGradient(avatarSeed); + const initials = displayName + .split(' ') + .map((w: string) => w.charAt(0)) + .slice(0, 2) + .join('') + .toUpperCase() || '?'; + + const avatarSizeClass = px(`user-profile__avatar--${props.avatarSize ?? 'lg'}`); + + return h('div', {class: px('user-profile__hero')}, [ + h('div', {class: px('user-profile__avatar-wrapper')}, [ h( 'div', - { - class: prefix('user-profile__avatar'), - style: {background: avatarGradient}, - }, - [h('span', {class: prefix('user-profile__avatar-initials')}, initials)], + {class: [px('user-profile__avatar'), avatarSizeClass].join(' '), style: {background: avatarGradient}}, + [h('span', {class: px('user-profile__avatar-initials')}, initials)], ), ]), + h('div', {class: px('user-profile__hero-info')}, [ + h('span', {class: px('user-profile__hero-name')}, displayName), + email ? h('span', {class: px('user-profile__hero-subtitle')}, String(email)) : null, + ]), + ]); + } + + // ── Main render ─────────────────────────────────────────────────────────── + + return (): VNode | VNode[] | null => { + const data = props.flattenedProfile || props.profile; + + if (!data && !props.isLoading) { + return slots['default'] + ? slots['default']({error: props.error, isLoading: props.isLoading, profile: null}) + : null; + } + + if (slots['default']) { + return slots['default']({error: props.error, isLoading: props.isLoading, profile: data}); + } + + const currentUser = data as Record; + const schemas = (props.schemas ?? []) as ExtendedSchema[]; + const hasSchemas = schemas.length > 0; + + const rootClasses = [ + px('user-profile'), + props.compact ? px('user-profile--compact') : '', + props.className ?? '', + ] + .filter(Boolean) + .join(' '); + + const children: VNode[] = []; + + // Title header + children.push( + h('div', {class: px('user-profile__header')}, [ + h('span', {class: px('user-profile__title')}, props.title ?? 'Profile'), + ]), ); + children.push(h(Divider, {class: px('user-profile__header-divider')})); + + // Hero + if (props.showAvatar !== false && currentUser) { + children.push(renderHero(currentUser)); + } + // Error alert if (props.error) { - children.push(h(Alert, {class: prefix('user-profile__error'), severity: 'error' as const}, () => props.error)); + children.push( + h(Alert, {class: px('user-profile__error'), severity: 'error' as const}, () => props.error), + ); } + // Fields if (props.isLoading) { - children.push(h('div', {class: prefix('user-profile__loading')}, [h(Spinner)])); - } else if (data) { - const fieldDataRecord: Record = data as Record; - - // Always show all defined profile fields; honour hideFields/showFields overrides - const descriptors: ProfileFieldDescriptor[] = PROFILE_FIELD_DESCRIPTORS.filter((d: ProfileFieldDescriptor) => { - const activeKey: string | undefined = d.keys.find((k: string) => k in fieldDataRecord); - const matchKey: string = activeKey ?? d.keys[0]; - if (props.hideFields && props.hideFields.length > 0 && props.hideFields.includes(matchKey)) return false; - if ( - props.showFields && - props.showFields.length > 0 && - !props.showFields.some((f: string) => d.keys.includes(f)) - ) - return false; - return true; - }); - - const fieldRows: VNode[] = []; - - descriptors.forEach((descriptor: ProfileFieldDescriptor) => { - const key: string = descriptor.keys.find((k: string) => k in fieldDataRecord) ?? descriptor.keys[0]; - const value: unknown = fieldDataRecord[key]; - const isReadonly: boolean = descriptor.readonly; - const isEditing: boolean = editingFields.value[key]; - const isEmpty: boolean = value == null || value === ''; - const {label} = descriptor; - - fieldRows.push( - h('div', {class: prefix('user-profile__field'), key}, [ - // Label column - h('div', {class: prefix('user-profile__field-label-col')}, [ - h(Typography, {class: prefix('user-profile__field-label'), variant: 'body2'}, () => label), - ]), - // Value column - h('div', {class: prefix('user-profile__field-value-col')}, [ - isEditing - ? h('div', {class: prefix('user-profile__field-edit')}, [ - h(TextField, { - modelValue: editedValues.value[key] ?? String(value ?? ''), - 'onUpdate:modelValue': (v: string) => { - editedValues.value = {...editedValues.value, [key]: v}; - }, - }), - h('div', {class: prefix('user-profile__field-edit-actions')}, [ - h( - Button, - { - onClick: async (): Promise => { - if (props.onUpdate) { - await props.onUpdate({ - payload: buildScimPatchValue(key, editedValues.value[key] ?? '', props.schemas), - } as UpdateMeProfileConfig); - } - editingFields.value = {...editingFields.value, [key]: false}; - }, - size: 'small' as const, - variant: 'solid' as const, - }, - () => 'Save', - ), - h( - Button, - { - onClick: (): void => { - editingFields.value = {...editingFields.value, [key]: false}; - }, - size: 'small' as const, - variant: 'text' as const, - }, - () => 'Cancel', - ), - ]), - ]) - : h('div', {class: prefix('user-profile__field-display')}, [ - isEmpty - ? h( - 'span', - { - class: prefix('user-profile__field-placeholder'), - onClick: - props.editable && !isReadonly - ? (): void => { - editingFields.value = {...editingFields.value, [key]: true}; - editedValues.value = {...editedValues.value, [key]: ''}; - } - : undefined, - }, - `Enter your ${label.toLowerCase()}`, - ) - : h(Typography, {class: prefix('user-profile__field-value'), variant: 'body1'}, () => - String(value), - ), - props.editable && !isReadonly - ? h( - 'button', - { - 'aria-label': `Edit ${label}`, - class: prefix('user-profile__field-edit-btn'), - onClick: (): void => { - editingFields.value = {...editingFields.value, [key]: true}; - editedValues.value = {...editedValues.value, [key]: String(value ?? '')}; - }, - type: 'button', - }, - [h(PencilIcon)], - ) - : null, - ]), - ]), - ]), - ); - }); - - children.push(h('div', {class: prefix('user-profile__fields')}, fieldRows)); + children.push(h('div', {class: px('user-profile__loading')}, [h(Spinner)])); + } else if (hasSchemas) { + const fieldRows: VNode[] = schemas + .filter((s: ExtendedSchema) => s.name && shouldShowField(s.name)) + .sort((a: ExtendedSchema, b: ExtendedSchema) => { + const orderA = a.displayOrder ? parseInt(a.displayOrder, 10) : 999; + const orderB = b.displayOrder ? parseInt(b.displayOrder, 10) : 999; + return orderA - orderB; + }) + .map((schema: ExtendedSchema) => { + const value = currentUser && schema.name ? currentUser[schema.name] : undefined; + return renderSchemaFieldRow({...schema, value}); + }) + .filter((node): node is VNode => node !== null); + + children.push(h('div', {class: px('user-profile__fields')}, fieldRows)); + } else { + children.push(h('div', {class: px('user-profile__fields')}, renderProfileWithoutSchemas())); } if (slots['footer']) { - children.push(h('div', {class: prefix('user-profile__footer')}, slots['footer']())); + children.push(h('div', {class: px('user-profile__footer')}, slots['footer']())); } if (props.cardLayout) { - return h(Card, {class: [prefix('user-profile'), props.className].filter(Boolean).join(' ')}, () => children); + return h( + Card, + {class: rootClasses, variant: props.cardVariant ?? 'elevated'}, + () => children, + ); } - return h('div', {class: [prefix('user-profile'), props.className].filter(Boolean).join(' ')}, children); + return h('div', {class: rootClasses}, children); }; }, }); diff --git a/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts b/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts index 93f6e4adc..48ba6096d 100644 --- a/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts @@ -20,77 +20,166 @@ * Styles for the UserProfile presentation component. * * BEM block: `.asgardeo-user-profile` + * + * Modifiers: + * --compact – reduced field padding for modal / dropdown embedding + * + * New elements in this version: + * __hero – avatar + name + subtitle banner + * __avatar--sm/md/lg – avatar size variants + * __hero-name – prominent display name + * __hero-subtitle – secondary line (email / username) */ const USER_PROFILE_CSS: string = ` /* ============================================================ - UserProfile + UserProfile (modern redesign) ============================================================ */ .asgardeo-user-profile { display: flex; flex-direction: column; min-width: 320px; - padding: 0; overflow: hidden; + font-family: var(--asgardeo-typography-fontFamily); } -/* Header ---------------------------------------------------- */ +/* ── Header ─────────────────────────────────────────────────── */ .asgardeo-user-profile__header { - padding: calc(var(--asgardeo-spacing-unit) * 2) calc(var(--asgardeo-spacing-unit) * 2.5); - padding-bottom: calc(var(--asgardeo-spacing-unit) * 1.5); + display: flex; + align-items: center; + justify-content: space-between; + padding: calc(var(--asgardeo-spacing-unit) * 2) calc(var(--asgardeo-spacing-unit) * 2.5) + calc(var(--asgardeo-spacing-unit) * 1.75); } .asgardeo-user-profile__title { margin: 0; + font-size: var(--asgardeo-typography-fontSize-md); + font-weight: var(--asgardeo-typography-fontWeight-semibold); + color: var(--asgardeo-color-text-primary); + letter-spacing: var(--asgardeo-typography-letterSpacing-tight); } .asgardeo-user-profile__header-divider { margin: 0; } -/* Avatar ---------------------------------------------------- */ +/* ── Hero (avatar + name + subtitle) ────────────────────────── */ -.asgardeo-user-profile__avatar-section { +.asgardeo-user-profile__hero { display: flex; - justify-content: center; - padding: calc(var(--asgardeo-spacing-unit) * 2) 0 calc(var(--asgardeo-spacing-unit) * 1.25); + flex-direction: column; + align-items: center; + padding: calc(var(--asgardeo-spacing-unit) * 3) calc(var(--asgardeo-spacing-unit) * 2.5) + calc(var(--asgardeo-spacing-unit) * 2); + gap: calc(var(--asgardeo-spacing-unit) * 1.25); + background: linear-gradient( + 180deg, + var(--asgardeo-color-primary-light) 0%, + var(--asgardeo-color-background-surface) 100% + ); + border-bottom: 1px solid var(--asgardeo-color-border); +} + +.asgardeo-user-profile__avatar-wrapper { + position: relative; + border-radius: 50%; + padding: 3px; + background: linear-gradient( + 135deg, + var(--asgardeo-color-primary-main), + var(--asgardeo-color-primary-dark) + ); + box-shadow: 0 4px 14px rgba(75, 110, 245, 0.28); } .asgardeo-user-profile__avatar { - width: var(--asgardeo-avatar-size); - height: var(--asgardeo-avatar-size); + width: var(--asgardeo-avatar-size, 72px); + height: var(--asgardeo-avatar-size, 72px); border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; + border: 2px solid var(--asgardeo-color-background-surface); +} + +/* Avatar size variants */ + +.asgardeo-user-profile__avatar--sm { + width: 48px; + height: 48px; +} + +.asgardeo-user-profile__avatar--sm .asgardeo-user-profile__avatar-initials { + font-size: 1rem; +} + +.asgardeo-user-profile__avatar--md { + width: 64px; + height: 64px; +} + +.asgardeo-user-profile__avatar--md .asgardeo-user-profile__avatar-initials { + font-size: 1.25rem; +} + +.asgardeo-user-profile__avatar--lg { + width: 80px; + height: 80px; +} + +.asgardeo-user-profile__avatar--lg .asgardeo-user-profile__avatar-initials { + font-size: 1.625rem; } .asgardeo-user-profile__avatar-initials { color: #ffffff; - font-size: var(--asgardeo-avatar-fontSize); - font-weight: 600; + font-weight: var(--asgardeo-typography-fontWeight-semibold); line-height: 1; letter-spacing: 0.02em; pointer-events: none; user-select: none; } -/* Alerts & loading ------------------------------------------ */ +.asgardeo-user-profile__hero-info { + display: flex; + flex-direction: column; + align-items: center; + gap: calc(var(--asgardeo-spacing-unit) * 0.375); + text-align: center; +} + +.asgardeo-user-profile__hero-name { + font-size: var(--asgardeo-typography-fontSize-lg); + font-weight: var(--asgardeo-typography-fontWeight-semibold); + color: var(--asgardeo-color-text-primary); + line-height: var(--asgardeo-typography-lineHeight-tight); + letter-spacing: var(--asgardeo-typography-letterSpacing-tight); +} + +.asgardeo-user-profile__hero-subtitle { + font-size: var(--asgardeo-typography-fontSize-sm); + color: var(--asgardeo-color-text-secondary); + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +/* ── Alerts & loading ────────────────────────────────────────── */ .asgardeo-user-profile__error { - margin: 0 calc(var(--asgardeo-spacing-unit) * 2.5) calc(var(--asgardeo-spacing-unit) * 1.25); + margin: calc(var(--asgardeo-spacing-unit) * 1.5) calc(var(--asgardeo-spacing-unit) * 2.5) + calc(var(--asgardeo-spacing-unit) * 0.5); } .asgardeo-user-profile__loading { display: flex; align-items: center; justify-content: center; - padding: calc(var(--asgardeo-spacing-unit) * 3) 0; + padding: calc(var(--asgardeo-spacing-unit) * 3.5) 0; } -/* Fields ---------------------------------------------------- */ +/* ── Fields ──────────────────────────────────────────────────── */ .asgardeo-user-profile__fields { display: flex; @@ -99,9 +188,9 @@ const USER_PROFILE_CSS: string = ` .asgardeo-user-profile__field { display: grid; - grid-template-columns: 36% 64%; - align-items: center; - padding: calc(var(--asgardeo-spacing-unit) * 1.25) calc(var(--asgardeo-spacing-unit) * 2.5); + grid-template-columns: 38% 62%; + align-items: start; + padding: calc(var(--asgardeo-spacing-unit) * 1.5) calc(var(--asgardeo-spacing-unit) * 2.5); gap: calc(var(--asgardeo-spacing-unit) * 0.75); box-sizing: border-box; transition: background-color var(--asgardeo-transition-fast); @@ -115,17 +204,11 @@ const USER_PROFILE_CSS: string = ` border-top: 1px solid var(--asgardeo-color-border); } -.asgardeo-user-profile__field-label-col { - /* label column */ -} - .asgardeo-user-profile__field-label { color: var(--asgardeo-color-text-secondary); font-size: var(--asgardeo-typography-fontSize-sm); -} - -.asgardeo-user-profile__field-value-col { - /* value column */ + font-weight: var(--asgardeo-typography-fontWeight-medium); + padding-top: 2px; } .asgardeo-user-profile__field-display { @@ -145,16 +228,22 @@ const USER_PROFILE_CSS: string = ` .asgardeo-user-profile__field-placeholder { color: var(--asgardeo-color-primary-main); - font-style: italic; font-size: var(--asgardeo-typography-fontSize-sm); + font-style: italic; flex: 1; cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; + opacity: 0.8; + transition: opacity var(--asgardeo-transition-fast); } -/* Edit button (pencil icon) --------------------------------- */ +.asgardeo-user-profile__field-placeholder:hover { + opacity: 1; +} + +/* ── Edit button (pencil) ────────────────────────────────────── */ .asgardeo-user-profile__field-edit-btn { display: inline-flex; @@ -190,12 +279,13 @@ const USER_PROFILE_CSS: string = ` box-shadow: 0 0 0 var(--asgardeo-focus-ring-width) var(--asgardeo-focus-ring-color); } -/* Edit mode ------------------------------------------------- */ +/* ── Edit mode ───────────────────────────────────────────────── */ .asgardeo-user-profile__field-edit { display: flex; flex-direction: column; gap: calc(var(--asgardeo-spacing-unit) * 0.75); + padding: calc(var(--asgardeo-spacing-unit) * 0.25) 0; } .asgardeo-user-profile__field-edit-actions { @@ -204,12 +294,35 @@ const USER_PROFILE_CSS: string = ` gap: calc(var(--asgardeo-spacing-unit) * 0.75); } -/* Footer slot ----------------------------------------------- */ +/* ── Footer slot ─────────────────────────────────────────────── */ .asgardeo-user-profile__footer { padding: calc(var(--asgardeo-spacing-unit) * 1.5) calc(var(--asgardeo-spacing-unit) * 2.5); border-top: 1px solid var(--asgardeo-color-border); } + +/* ── Compact modifier ────────────────────────────────────────── */ + +.asgardeo-user-profile--compact .asgardeo-user-profile__hero { + padding: calc(var(--asgardeo-spacing-unit) * 2) calc(var(--asgardeo-spacing-unit) * 2); +} + +.asgardeo-user-profile--compact .asgardeo-user-profile__avatar--lg { + width: 56px; + height: 56px; +} + +.asgardeo-user-profile--compact .asgardeo-user-profile__avatar--lg .asgardeo-user-profile__avatar-initials { + font-size: 1.125rem; +} + +.asgardeo-user-profile--compact .asgardeo-user-profile__field { + padding: calc(var(--asgardeo-spacing-unit) * 1) calc(var(--asgardeo-spacing-unit) * 2); +} + +.asgardeo-user-profile--compact .asgardeo-user-profile__hero-name { + font-size: var(--asgardeo-typography-fontSize-md); +} `; export default USER_PROFILE_CSS; diff --git a/packages/vue/src/components/presentation/user-profile/UserProfile.ts b/packages/vue/src/components/presentation/user-profile/UserProfile.ts index 98db5c005..1719c748e 100644 --- a/packages/vue/src/components/presentation/user-profile/UserProfile.ts +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.ts @@ -16,22 +16,23 @@ * under the License. */ -import {withVendorCSSClassPrefix} from '@asgardeo/browser'; -import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import {AsgardeoError, User, withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h, ref, type Ref} from 'vue'; import BaseUserProfile from './BaseUserProfile'; +import updateMeProfile from '../../../api/updateMeProfile'; +import useAsgardeo from '../../../composables/useAsgardeo'; +import useI18n from '../../../composables/useI18n'; import useUser from '../../../composables/useUser'; -/** - * UserProfile — styled user profile component. - * - * Retrieves user profile data from context and delegates to BaseUserProfile. - */ - type UserProfileProps = Readonly<{ + avatarSize: 'sm' | 'md' | 'lg'; cardLayout: boolean; + cardVariant: 'elevated' | 'outlined' | 'flat'; className: string; + compact: boolean; editable: boolean; hideFields: string[]; + showAvatar: boolean; showFields: string[]; title: string; }>; @@ -39,28 +40,77 @@ type UserProfileProps = Readonly<{ const UserProfile: Component = defineComponent({ name: 'UserProfile', props: { + /** Avatar circle size. */ + avatarSize: { + default: 'lg', + type: String as PropType<'sm' | 'md' | 'lg'>, + }, + /** Whether to render the component inside a Card wrapper. */ cardLayout: {default: true, type: Boolean}, + /** Shadow / border style of the card wrapper. */ + cardVariant: { + default: 'elevated', + type: String as PropType<'elevated' | 'outlined' | 'flat'>, + }, + /** Extra CSS class added to the root element. */ className: {default: '', type: String}, + /** Tighter spacing — useful when embedded in a modal or dropdown. */ + compact: {default: false, type: Boolean}, + /** Whether fields can be edited inline. */ editable: {default: true, type: Boolean}, + /** Fields to hide by name. */ hideFields: {default: () => [], type: Array as PropType}, + /** Whether to render the avatar hero section. */ + showAvatar: {default: true, type: Boolean}, + /** Fields to show exclusively (empty = show all). */ showFields: {default: () => [], type: Array as PropType}, + /** Card header title. */ title: {default: 'Profile', type: String}, }, setup(props: UserProfileProps, {slots}: SetupContext): () => VNode { - const {flattenedProfile, schemas, updateProfile} = useUser(); + const {baseUrl, instanceId} = useAsgardeo(); + const {flattenedProfile, profile, schemas, onUpdateProfile} = useUser(); + const {t} = useI18n(); + + const error: Ref = ref(null); + + async function handleProfileUpdate(payload: any): Promise { + if (!baseUrl) return; + + error.value = null; + + try { + const response: User = await updateMeProfile({baseUrl, instanceId, payload}); + onUpdateProfile(response); + } catch (caughtError: unknown) { + let message: string = t('user.profile.update.generic.error') || 'Failed to update profile. Please try again.'; + + if (caughtError instanceof AsgardeoError) { + message = caughtError.message; + } + + error.value = message; + } + } return (): VNode => h( BaseUserProfile, { + avatarSize: props.avatarSize, cardLayout: props.cardLayout, + cardVariant: props.cardVariant, class: withVendorCSSClassPrefix('user-profile--styled'), className: props.className, + compact: props.compact, editable: props.editable, + error: error.value, flattenedProfile: flattenedProfile?.value, hideFields: props.hideFields, - onUpdate: updateProfile, + onUpdate: handleProfileUpdate, + profile: profile?.value?.profile ?? flattenedProfile?.value, schemas: schemas?.value, + showAvatar: props.showAvatar, showFields: props.showFields, title: props.title, }, diff --git a/packages/vue/src/components/control/user/User.ts b/packages/vue/src/components/presentation/user/User.ts similarity index 93% rename from packages/vue/src/components/control/user/User.ts rename to packages/vue/src/components/presentation/user/User.ts index 052e44de2..9e2a5203f 100644 --- a/packages/vue/src/components/control/user/User.ts +++ b/packages/vue/src/components/presentation/user/User.ts @@ -20,7 +20,7 @@ import {type Component, type VNode, defineComponent, h, Fragment} from 'vue'; import useAsgardeo from '../../../composables/useAsgardeo'; /** - * User — control component that exposes the current user via a scoped slot. + * User — presentation component that exposes the current user via a scoped slot. * * Renders the `default` slot with `{ user }` when a user is signed in, * or the `fallback` slot when no user is available. @@ -29,7 +29,7 @@ import useAsgardeo from '../../../composables/useAsgardeo'; * ```vue * * * - - ``` ### Using the User Render Function Pattern @@ -261,16 +242,6 @@ import { Callback } from '@asgardeo/vue' 🎉 **Congratulations!** You've successfully integrated Asgardeo authentication into your Vue app. -### What to explore next: - -- **[API Documentation](https://wso2.com/asgardeo/docs/sdks/vue/overview)** - Learn about all available composables and components -- **[Composables Guide](https://wso2.com/asgardeo/docs/sdks/vue/composables)** - Master the composable API (`useUser`, `useOrganization`, etc.) -- **[Custom Styling](https://wso2.com/asgardeo/docs/sdks/vue/customization/styling)** - Customize the appearance of authentication components -- **[Protected Routes](https://wso2.com/asgardeo/docs/sdks/vue/protected-routes)** - Implement route-level authentication -- **[Organizations/Workspaces](https://wso2.com/asgardeo/docs/sdks/vue/organizations)** - Implement multi-tenancy features -- **[User Profile Management](https://wso2.com/asgardeo/docs/sdks/vue/user-profile)** - Access and manage user profile data -- **[Social Login](https://wso2.com/asgardeo/docs/sdks/vue/social-login)** - Enable sign-in with Google, GitHub, Microsoft, and Facebook - ## Common Issues ### Redirect URL Mismatch @@ -287,23 +258,18 @@ import { Callback } from '@asgardeo/vue' ### Plugin Not Registered - **Problem**: Vue warns about plugin not being registered -- **Solution**: Make sure you've called `app.use(AsgardeoPlugin, { ... })` before mounting your app - -### State Not Updating -- **Problem**: User state doesn't update after sign-in -- **Solution**: Ensure you're using the composable (`useUser`) inside a component wrapped with `AsgardeoProvider` +- **Solution**: Make sure you've called `app.use(AsgardeoPlugin)` before mounting your app ## More Resources - [Asgardeo Documentation](https://wso2.com/asgardeo/docs/) - [Vue.js Documentation](https://vuejs.org/) -- [SDK Examples](../../samples/) -- [GitHub Repository](https://github.com/asgardeo/asgardeo-auth-vue-sdk) +- [GitHub Repository](https://github.com/asgardeo/javascript) ## Getting Help If you encounter issues: 1. Check the [FAQs](https://wso2.com/asgardeo/docs/faq/) -2. Search [GitHub Issues](https://github.com/asgardeo/asgardeo-auth-vue-sdk/issues) +2. Search [GitHub Issues](https://github.com/asgardeo/javascript/issues) 3. Ask on the [WSO2 Community Forum](https://wso2.com/community/) 4. Contact [Asgardeo Support](https://wso2.com/asgardeo/support/) diff --git a/packages/vue/README.md b/packages/vue/README.md index 0182db0aa..19f6a4076 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -39,28 +39,6 @@ pnpm add @asgardeo/vue yarn add @asgardeo/vue ``` -## Basic Usage - -```vue - - - -``` - -## API Documentation - -For complete API documentation including all components, composables, and customization options, see the [Vue SDK Documentation](https://wso2.com/asgardeo/docs/sdks/vue/overview). - ## Supported Features ### Composables @@ -120,23 +98,15 @@ For complete API documentation including all components, composables, and custom - `navigate` - Programmatic navigation helper - `http` - HTTP client with token management -## Examples - -Check out our [example applications](../../samples/) to see the Vue SDK in action: -- [Vue SDK Playground](../../playgrounds/vue/) - Example application - ## Documentation -- [Getting Started](https://wso2.com/asgardeo/docs/get-started/) -- [Vue SDK Guide](https://wso2.com/asgardeo/docs/sdks/vue/) -- [Configuration Options](https://wso2.com/asgardeo/docs/sdks/vue/configuration/) -- [Composables & Components](https://wso2.com/asgardeo/docs/sdks/vue/api/) +- [Vue Quick Start](https://wso2.com/asgardeo/docs/quick-starts/vue/) ## Support For support and questions: - [Asgardeo Documentation](https://wso2.com/asgardeo/docs/) -- [GitHub Issues](https://github.com/asgardeo/asgardeo-auth-vue-sdk/issues) +- [GitHub Issues](https://github.com/asgardeo/javascript/issues) - [WSO2 Community Forum](https://wso2.com/community/) ## Contributing From a399a6d4c6fd7d478615b6c533e657e62e433f16 Mon Sep 17 00:00:00 2001 From: Kavinda dewmith Date: Thu, 7 May 2026 18:32:56 +0530 Subject: [PATCH 7/9] =?UTF-8?q?Add=20changeset=20=F0=9F=A6=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/cute-houses-beam.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/cute-houses-beam.md diff --git a/.changeset/cute-houses-beam.md b/.changeset/cute-houses-beam.md new file mode 100644 index 000000000..8684d5ecd --- /dev/null +++ b/.changeset/cute-houses-beam.md @@ -0,0 +1,7 @@ +--- +'@asgardeo/javascript': minor +'@asgardeo/nuxt': minor +'@asgardeo/vue': minor +--- + +Add AsgardeoV2 platform support with Basic Auth token requests, platform-aware sign-out/profile handling in Nuxt, and updated component exports in Vue From 4d2a0e92e570b82d361e58e827902f74529e7605 Mon Sep 17 00:00:00 2001 From: Kavinda dewmith Date: Fri, 8 May 2026 20:20:42 +0530 Subject: [PATCH 8/9] Improve tampering tests by altering the first character of the signature for reliable decoding changes --- packages/nuxt/tests/unit/session-manager.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/nuxt/tests/unit/session-manager.test.ts b/packages/nuxt/tests/unit/session-manager.test.ts index c7a9ab062..b42fdf0cb 100644 --- a/packages/nuxt/tests/unit/session-manager.test.ts +++ b/packages/nuxt/tests/unit/session-manager.test.ts @@ -81,9 +81,11 @@ describe('createSessionToken / verifySessionToken', () => { TEST_SECRET, ); - // Flip the last character of the signature (third segment) + // Flip the first character of the signature — the first char encodes 6 full + // data bits, so any change reliably alters the decoded value (unlike the last + // char, which only carries 4 data bits and can be a no-op when those bits collide). const parts = token.split('.'); - parts[2] = parts[2].slice(0, -1) + (parts[2].endsWith('a') ? 'b' : 'a'); + parts[2] = (parts[2][0] === 'a' ? 'b' : 'a') + parts[2].slice(1); const tampered = parts.join('.'); await expect(verifySessionToken(tampered, TEST_SECRET)).rejects.toThrow(); @@ -146,7 +148,9 @@ describe('createTempSessionToken / verifyTempSessionToken', () => { it('rejects a tampered temp token', async () => { const token = await createTempSessionToken('temp-sess-3', TEST_SECRET); const parts = token.split('.'); - parts[2] = parts[2].slice(0, -1) + (parts[2].endsWith('a') ? 'b' : 'a'); + // Flip the first character of the signature — reliably changes the decoded + // value regardless of which character the signature happens to end with. + parts[2] = (parts[2][0] === 'a' ? 'b' : 'a') + parts[2].slice(1); await expect(verifyTempSessionToken(parts.join('.'), TEST_SECRET)).rejects.toThrow(); }); From ecc4211556ca867ff940b452f69dc39e6ddcd460 Mon Sep 17 00:00:00 2001 From: Kavinda dewmith Date: Wed, 13 May 2026 02:17:15 +0530 Subject: [PATCH 9/9] Add token endpoint authentication method configuration --- packages/javascript/src/__legacy__/client.ts | 11 +++---- .../src/__legacy__/models/client-config.ts | 9 ++++++ packages/javascript/src/index.ts | 1 + packages/javascript/src/models/config.ts | 19 ++++++++++++ .../src/models/token-endpoint-auth.ts | 30 +++++++++++++++++++ packages/nuxt/src/module.ts | 2 ++ .../src/runtime/server/AsgardeoNuxtClient.ts | 1 + .../runtime/server/plugins/asgardeo-ssr.ts | 1 + packages/nuxt/src/runtime/types.ts | 21 ++++++++++++- 9 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 packages/javascript/src/models/token-endpoint-auth.ts diff --git a/packages/javascript/src/__legacy__/client.ts b/packages/javascript/src/__legacy__/client.ts index 76e2332eb..a1c99566d 100644 --- a/packages/javascript/src/__legacy__/client.ts +++ b/packages/javascript/src/__legacy__/client.ts @@ -32,6 +32,7 @@ import {Platform} from '../models/platforms'; import {SessionData, UserSession} from '../models/session'; import {Storage, TemporaryStore} from '../models/store'; import {TokenResponse, IdToken, TokenExchangeRequestConfig} from '../models/token'; +import {TokenEndpointAuthMethod} from '../models/token-endpoint-auth'; import {User} from '../models/user'; import StorageManager from '../StorageManager'; import base64Encode from '../utils/base64Encode'; @@ -378,12 +379,12 @@ export class AsgardeoAuthClient { body.set('client_id', configData.clientId); - // AsgardeoV2 (Thunder) requires client_secret_basic: credentials in the Authorization header. - // All other platforms use client_secret_post: credentials in the request body. const hasSecret: boolean = Boolean(configData.clientSecret && configData.clientSecret.trim().length > 0); - const useBasicAuth: boolean = hasSecret && (configData as any).platform === Platform.AsgardeoV2; + const tokenEndpointAuthMethod: TokenEndpointAuthMethod = + configData.tokenRequest?.authMethod ?? + ((configData as any).platform === Platform.AsgardeoV2 ? 'client_secret_basic' : 'client_secret_post'); - if (hasSecret && !useBasicAuth) { + if (hasSecret && tokenEndpointAuthMethod === 'client_secret_post') { body.set('client_secret', configData.clientSecret); } @@ -414,7 +415,7 @@ export class AsgardeoAuthClient { 'Content-Type': 'application/x-www-form-urlencoded', }; - if (useBasicAuth) { + if (hasSecret && tokenEndpointAuthMethod === 'client_secret_basic') { const credential: string = `${encodeURIComponent(configData.clientId)}:${encodeURIComponent( configData.clientSecret, )}`; diff --git a/packages/javascript/src/__legacy__/models/client-config.ts b/packages/javascript/src/__legacy__/models/client-config.ts index ea6ff7bb0..7f0a71b89 100644 --- a/packages/javascript/src/__legacy__/models/client-config.ts +++ b/packages/javascript/src/__legacy__/models/client-config.ts @@ -19,6 +19,7 @@ import {OAuthResponseMode} from '../../models/oauth-response'; import {OIDCEndpoints} from '../../models/oidc-endpoints'; import {Platform} from '../../models/platforms'; +import {TokenEndpointAuthMethod} from '../../models/token-endpoint-auth'; export interface DefaultAuthClientConfig { afterSignInUrl: string; @@ -59,6 +60,14 @@ export interface DefaultAuthClientConfig { */ sendCookiesInRequests?: boolean; sendIdTokenInLogoutRequest?: boolean; + tokenRequest?: { + /** + * OAuth 2.0 client authentication method used at the token endpoint. + * When omitted, defaults to `client_secret_basic` for AsgardeoV2 and + * `client_secret_post` for all other platforms. + */ + authMethod?: TokenEndpointAuthMethod; + }; tokenValidation?: { /** * ID token validation config. diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index edc15b289..8777f0546 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -171,6 +171,7 @@ export { SignOutOptions, SignUpOptions, } from './models/config'; +export {TokenEndpointAuthMethod} from './models/token-endpoint-auth'; export type {ComponentRenderContext, ComponentRenderer, ComponentsExtensions} from './models/v2/extensions/components'; export {TokenResponse, IdToken, TokenExchangeRequestConfig} from './models/token'; export {AgentConfig} from './models/agent'; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 41de9ec12..660c84aee 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -18,6 +18,7 @@ import {I18nBundle} from '@asgardeo/i18n'; import {Platform} from './platforms'; +import {TokenEndpointAuthMethod} from './token-endpoint-auth'; import {RecursivePartial} from './utility-types'; import {ComponentsExtensions} from './v2/extensions/components'; import {ThemeConfig, ThemeMode} from '../theme/types'; @@ -323,6 +324,24 @@ export interface BaseConfig extends WithPreferences, WithExtensions }; }; + /** + * Configuration for the token endpoint request. + */ + tokenRequest?: { + /** + * OAuth 2.0 client authentication method used at the token endpoint. + * Maps to `token_endpoint_auth_method` in OIDC Discovery. + * + * - `client_secret_basic` — Credentials in the `Authorization: Basic` header. + * - `client_secret_post` — Credentials in the POST body. + * - `none` — No client authentication (public clients). + * + * When omitted the SDK applies its platform-based default: + * AsgardeoV2 → `client_secret_basic`; all others → `client_secret_post`. + */ + authMethod?: TokenEndpointAuthMethod; + }; + /** * Token validation configuration. * This allows you to configure how the SDK validates tokens received from the authorization server. diff --git a/packages/javascript/src/models/token-endpoint-auth.ts b/packages/javascript/src/models/token-endpoint-auth.ts new file mode 100644 index 000000000..865ee2db9 --- /dev/null +++ b/packages/javascript/src/models/token-endpoint-auth.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * OAuth 2.0 client authentication method used at the token endpoint. + * Corresponds to the `token_endpoint_auth_method` parameter in OIDC Discovery. + * + * - `client_secret_basic` — HTTP Basic authentication: credentials are sent in the + * `Authorization: Basic base64(client_id:client_secret)` header (RFC 6749 §2.3.1). + * Required for AsgardeoV2 (Thunder) by default. + * - `client_secret_post` — Credentials are sent as `client_id` / `client_secret` + * parameters in the POST body (RFC 6749 §2.3.1). Default for all other platforms. + * - `none` — No client authentication (public clients that have no client secret). + */ +export type TokenEndpointAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index b82b14ba2..312e70619 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -105,6 +105,7 @@ export default defineNuxtModule({ scopes: publicConfig.scopes, signInUrl: publicConfig.signInUrl, signUpUrl: publicConfig.signUpUrl, + tokenRequest: publicConfig.tokenRequest, }, ) as { afterSignInUrl: string; @@ -117,6 +118,7 @@ export default defineNuxtModule({ scopes: string[]; signInUrl?: string; signUpUrl?: string; + tokenRequest?: AsgardeoNuxtConfig['tokenRequest']; }; // Ensure clientSecret never leaks to public config diff --git a/packages/nuxt/src/runtime/server/AsgardeoNuxtClient.ts b/packages/nuxt/src/runtime/server/AsgardeoNuxtClient.ts index 1b7374c22..8fc71baae 100644 --- a/packages/nuxt/src/runtime/server/AsgardeoNuxtClient.ts +++ b/packages/nuxt/src/runtime/server/AsgardeoNuxtClient.ts @@ -120,6 +120,7 @@ class AsgardeoNuxtClient extends AsgardeoNodeClient { enablePKCE: true, platform: config.platform, scopes: config.scopes || ['openid', 'profile'], + tokenRequest: config.tokenRequest, } as AuthClientConfig; const result: boolean = await this.legacy.initialize(authConfig, storage); diff --git a/packages/nuxt/src/runtime/server/plugins/asgardeo-ssr.ts b/packages/nuxt/src/runtime/server/plugins/asgardeo-ssr.ts index a6e5b7a30..ba3c3e8b4 100644 --- a/packages/nuxt/src/runtime/server/plugins/asgardeo-ssr.ts +++ b/packages/nuxt/src/runtime/server/plugins/asgardeo-ssr.ts @@ -102,6 +102,7 @@ export default defineNitroPlugin((nitro: {hooks: {hook: Function}}) => { clientSecret: privateConfig?.clientSecret || undefined, platform: publicConfig.platform, scopes: publicConfig.scopes || ['openid', 'profile'], + tokenRequest: publicConfig.tokenRequest, }); } catch (err) { log.error('Failed to initialize Asgardeo client:', err); diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index 43661ee97..7f99b2522 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -16,7 +16,15 @@ * under the License. */ -import type {BrandingPreference, I18nPreferences, Organization, Platform, User, UserProfile} from '@asgardeo/node'; +import type { + BrandingPreference, + I18nPreferences, + Organization, + Platform, + TokenEndpointAuthMethod, + User, + UserProfile, +} from '@asgardeo/node'; import type {JWTPayload} from 'jose'; /** @@ -89,6 +97,17 @@ export interface AsgardeoNuxtConfig { * here instead of deriving the URL from `baseUrl`/`clientId`. */ signUpUrl?: string; + /** + * Configuration for the token endpoint request. + */ + tokenRequest?: { + /** + * OAuth 2.0 client authentication method used at the token endpoint. + * Defaults to `client_secret_basic` for AsgardeoV2 and `client_secret_post` + * for all other platforms when not specified. + */ + authMethod?: TokenEndpointAuthMethod; + }; } /**