diff --git a/.changeset/two-gifts-kiss.md b/.changeset/two-gifts-kiss.md new file mode 100644 index 000000000..4bb1de109 --- /dev/null +++ b/.changeset/two-gifts-kiss.md @@ -0,0 +1,6 @@ +--- +'@asgardeo/javascript': minor +'@asgardeo/vue': minor +--- + +Implemented with vue3 standards diff --git a/packages/javascript/src/theme/createTheme.ts b/packages/javascript/src/theme/createTheme.ts index 22390d1e9..b1cc8805f 100644 --- a/packages/javascript/src/theme/createTheme.ts +++ b/packages/javascript/src/theme/createTheme.ts @@ -53,11 +53,13 @@ const lightTheme: ThemeConfig = { error: { contrastText: '#d52828', dark: '#b71c1c', + light: '#fef2f2', main: '#d32f2f', }, info: { contrastText: '#43aeda', dark: '#01579b', + light: '#eff6ff', main: '#bbebff', }, primary: { @@ -68,11 +70,13 @@ const lightTheme: ThemeConfig = { secondary: { contrastText: '#ffffff', dark: '#212121', + light: '#f3f4f6', main: '#424242', }, success: { contrastText: '#00a807', dark: '#388e3c', + light: '#f0fdf4', main: '#4caf50', }, text: { @@ -83,6 +87,7 @@ const lightTheme: ThemeConfig = { warning: { contrastText: '#be7100', dark: '#f57c00', + light: '#fffbeb', main: '#ff9800', }, }, @@ -156,11 +161,13 @@ const darkTheme: ThemeConfig = { error: { contrastText: '#d52828', dark: '#b71c1c', + light: '#2d1515', main: '#d32f2f', }, info: { contrastText: '#43aeda', dark: '#01579b', + light: '#0f1f35', main: '#bbebff', }, primary: { @@ -171,11 +178,13 @@ const darkTheme: ThemeConfig = { secondary: { contrastText: '#ffffff', dark: '#212121', + light: '#2a2a2a', main: '#8b8b8b', }, success: { contrastText: '#00a807', dark: '#388e3c', + light: '#132d1a', main: '#4caf50', }, text: { @@ -186,6 +195,7 @@ const darkTheme: ThemeConfig = { warning: { contrastText: '#be7100', dark: '#f57c00', + light: '#2d2310', main: '#ff9800', }, }, @@ -280,6 +290,9 @@ const toCssVariables = (theme: ThemeConfig): Record => { if (theme.colors?.secondary?.contrastText) { cssVars[`--${prefix}-color-secondary-contrastText`] = theme.colors.secondary.contrastText; } + if (theme.colors?.secondary?.light) { + cssVars[`--${prefix}-color-secondary-light`] = theme.colors.secondary.light; + } // Colors - Background if (theme.colors?.background?.surface) { @@ -299,6 +312,9 @@ const toCssVariables = (theme: ThemeConfig): Record => { if (theme.colors?.error?.contrastText) { cssVars[`--${prefix}-color-error-contrastText`] = theme.colors.error.contrastText; } + if (theme.colors?.error?.light) { + cssVars[`--${prefix}-color-error-light`] = theme.colors.error.light; + } // Colors - Success if (theme.colors?.success?.main) { @@ -307,6 +323,9 @@ const toCssVariables = (theme: ThemeConfig): Record => { if (theme.colors?.success?.contrastText) { cssVars[`--${prefix}-color-success-contrastText`] = theme.colors.success.contrastText; } + if (theme.colors?.success?.light) { + cssVars[`--${prefix}-color-success-light`] = theme.colors.success.light; + } // Colors - Warning if (theme.colors?.warning?.main) { @@ -315,6 +334,9 @@ const toCssVariables = (theme: ThemeConfig): Record => { if (theme.colors?.warning?.contrastText) { cssVars[`--${prefix}-color-warning-contrastText`] = theme.colors.warning.contrastText; } + if (theme.colors?.warning?.light) { + cssVars[`--${prefix}-color-warning-light`] = theme.colors.warning.light; + } // Colors - Info if (theme.colors?.info?.main) { @@ -323,6 +345,9 @@ const toCssVariables = (theme: ThemeConfig): Record => { if (theme.colors?.info?.contrastText) { cssVars[`--${prefix}-color-info-contrastText`] = theme.colors.info.contrastText; } + if (theme.colors?.info?.light) { + cssVars[`--${prefix}-color-info-light`] = theme.colors.info.light; + } // Colors - Text if (theme.colors?.text?.primary) { diff --git a/packages/javascript/src/theme/types.ts b/packages/javascript/src/theme/types.ts index abc43b730..026994043 100644 --- a/packages/javascript/src/theme/types.ts +++ b/packages/javascript/src/theme/types.ts @@ -67,11 +67,13 @@ export interface ThemeColors { error: { contrastText: string; dark?: string; + light?: string; main: string; }; info: { contrastText: string; dark?: string; + light?: string; main: string; }; primary: { @@ -82,11 +84,13 @@ export interface ThemeColors { secondary: { contrastText: string; dark?: string; + light?: string; main: string; }; success: { contrastText: string; dark?: string; + light?: string; main: string; }; text: { @@ -97,6 +101,7 @@ export interface ThemeColors { warning: { contrastText: string; dark?: string; + light?: string; main: string; }; } diff --git a/packages/vue/QUICKSTART.md b/packages/vue/QUICKSTART.md new file mode 100644 index 000000000..b400bec82 --- /dev/null +++ b/packages/vue/QUICKSTART.md @@ -0,0 +1,309 @@ +# `@asgardeo/vue` Quickstart + +This guide will help you quickly integrate Asgardeo authentication into your Vue.js application. + +## Prerequisites + +- [Node.js](https://nodejs.org/en/download) (version 16 or later. LTS version recommended) +- An [Asgardeo account](https://wso2.com/asgardeo/docs/get-started/create-asgardeo-account/) +- Basic knowledge of Vue 3 and the Composition API + +## Step 1: Configure an Application in Asgardeo + +1. **Sign in to Asgardeo Console** + - Go to [Asgardeo Console](https://console.asgardeo.io/) + - Sign in with your Asgardeo account + +2. **Create a New Application** + - Click **Applications** in the left sidebar + - Click **+ New Application** + - Choose **Single Page Application (SPA)** + - Enter your application name (e.g., "My Vue App") + +3. **Note Down Your Credentials from the `Quickstart` tab** + - Copy the **Client ID** from the application details + - Note your **Base URL** (ex: `https://api.asgardeo.io/t/`) + +4. **Configure Application Settings from the `Protocol` tab** + - **Authorized redirect URLs**: Add your application URLs + - `https://localhost:5173` + - **Allowed origins**: Add the same URLs as above + - Click **Update** to save the configuration + +## Step 2: Create a Vue Application + +If you don't have a Vue application set up yet, you can create one using `create-vue`: + +```bash +# Using npm +npm create vue@latest vue-sample + +# Using pnpm +pnpm create vue@latest vue-sample + +# Using yarn +yarn create vue vue-sample +``` + +When prompted, enable TypeScript for a better development experience. + +Alternatively, using Vite: + +```bash +# Using npm +npm create vite@latest vue-sample --template vue-ts + +# Using pnpm +pnpm create vite@latest vue-sample --template vue-ts + +# Using yarn +yarn create vite vue-sample --template vue-ts +``` + +Navigate to your project: + +```bash +cd vue-sample +``` + +## Step 3: Install the SDK + +Install the Asgardeo Vue SDK in your project: + +```bash +# Using npm +npm install @asgardeo/vue + +# Using pnpm +pnpm add @asgardeo/vue + +# Using yarn +yarn add @asgardeo/vue +``` + +## Step 4: Configure the Provider + +Register the Asgardeo plugin and wrap your application with the `AsgardeoProvider` in your main entry file (`src/main.ts`): + +```ts +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' +import { AsgardeoPlugin, AsgardeoProvider } from '@asgardeo/vue' + +const app = createApp(App) + +app.use(AsgardeoPlugin, { + baseUrl: '', + clientId: '', +}) + +app.mount('#app') +``` + +Replace: +- `` with the Base URL you noted in Step 1 (e.g., `https://api.asgardeo.io/t/`) +- `` with the Client ID from Step 1 + +Then wrap your app component with the `AsgardeoProvider` in `src/App.vue`: + +```vue + + + +``` + +## Step 5: Add Sign-in & Sign-out to Your App + +Update your `src/App.vue` to include sign-in and sign-out functionality: + +```vue + + + + + +``` + +## Step 6: Display User Information + +You can also display user information by using the `User` component and the `useUser` composable: + +```vue + + + + + +``` + +### Using the User Render Function Pattern + +Alternatively, you can use the `User` component with a render function: + +```vue + + + +``` + +## Step 7: Try Login + +Run your application and test the sign-in functionality. You should see a "Sign In" button when you're not signed in, and clicking it will redirect you to the Asgardeo sign-in page. + +```bash +# Using npm +npm run dev + +# Using pnpm +pnpm dev + +# Using yarn +yarn dev +``` + +Open your browser and navigate to `http://localhost:5173` (or the port shown in your terminal). Click the "Sign In" button to test the authentication flow. + +## Step 8: Handle Callback + +The SDK automatically handles the OAuth callback redirect. Make sure your application loads correctly after returning from Asgardeo. For custom callback handling, you can use the `Callback` component: + +```vue + + + +``` + +## Next Steps + +πŸŽ‰ **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 +- **Problem**: Getting errors about redirect URI not matching +- **Solution**: Ensure your redirect URLs in Asgardeo match your local/production URLs exactly (including protocol and port) + +### CORS Errors +- **Problem**: Getting CORS-related errors in the console +- **Solution**: Make sure to add your domain to the "Allowed Origins" in your Asgardeo application settings + +### Client ID Not Found +- **Problem**: Authentication fails with "Client ID is invalid" +- **Solution**: Double-check that you're using the correct Client ID from your Asgardeo application and that it's properly configured in the plugin options + +### 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` + +## 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) + +## 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) +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 f25931867..2354f54ef 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -8,6 +8,141 @@ License +## Overview + +The Asgardeo Vue SDK provides a streamlined way to integrate secure authentication and user management into your Vue.js applications. Built for Vue 3, it offers a comprehensive set of composables, components, and utilities to handle authentication flows, user profiles, and multi-tenancy features. + +## Key Features + +- **Easy Integration**: Simple setup with the `AsgardeoPlugin` and provider components +- **Composable API**: Vue 3 composables for reactive authentication state management +- **Pre-built Components**: Ready-to-use components for sign-in, sign-up, user profiles, and more +- **Multi-Tenancy Support**: Built-in organization/workspace management capabilities +- **Customizable UI**: Primitive components and styling options for seamless integration +- **International Support**: Multi-language support with easy localization +- **Type-Safe**: Full TypeScript support for better developer experience + +## Quick Start + +Get started with Asgardeo in your Vue application in minutes. Follow our [Vue Quick Start Guide](./QUICKSTART.md) for step-by-step instructions on integrating authentication into your app. + +## Installation + +```bash +# Using npm +npm install @asgardeo/vue + +# Using pnpm +pnpm add @asgardeo/vue + +# Using yarn +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 +- `useAsgardeo()` - Main SDK client access +- `useUser()` - User profile and authentication state +- `useOrganization()` - Organization/workspace management +- `useI18n()` - Internationalization +- `useTheme()` - Theme customization +- `useBranding()` - Branding customization +- `useFlow()` - Authentication flow control +- `useFlowMeta()` - Flow metadata access + +### Components + +#### Control +- `SignedIn` - Renders children only when user is authenticated +- `SignedOut` - Renders children only when user is not authenticated +- `Loading` - Renders children while authentication state is loading +- `UserComponent` - Access user information with scoped slot pattern +- `OrganizationComponent` - Access organization context with scoped slot pattern + +#### Actions +- `SignInButton` / `BaseSignInButton` - Sign-in button (styled and unstyled) +- `SignOutButton` / `BaseSignOutButton` - Sign-out button (styled and unstyled) +- `SignUpButton` / `BaseSignUpButton` - Sign-up button (styled and unstyled) + +#### Presentation +- `SignIn` / `BaseSignIn` - Embedded sign-in form +- `SignUp` / `BaseSignUp` - Embedded sign-up form +- `UserProfileComponent` / `BaseUserProfile` - User profile display +- `UserDropdown` / `BaseUserDropdown` - User menu dropdown +- `OrganizationList` / `BaseOrganizationList` - Organization listing +- `OrganizationSwitcher` / `BaseOrganizationSwitcher` - Organization switcher +- `OrganizationProfile` / `BaseOrganizationProfile` - Organization profile +- `CreateOrganization` / `BaseCreateOrganization` - Organization creation form +- `AcceptInvite` / `BaseAcceptInvite` - Invitation acceptance +- `InviteUser` / `BaseInviteUser` - User invitation form +- `LanguageSwitcher` / `BaseLanguageSwitcher` - Language selection + +#### Auth Flow +- `Callback` - Handle OAuth callback redirect + +#### Social Login Adapters +- `GoogleButton` - Sign in with Google +- `FacebookButton` - Sign in with Facebook +- `GitHubButton` - Sign in with GitHub +- `MicrosoftButton` - Sign in with Microsoft + +#### Primitives +- `Button`, `Card`, `Alert`, `TextField`, `PasswordField`, `Select`, `Checkbox`, `DatePicker`, `OtpField`, `Typography`, `Divider`, `Logo`, `Spinner` + +### Utilities +- `createAsgardeoGuard` - Vue Router navigation guard for protected routes +- `createCallbackRoute` - Generate a callback route record for Vue Router +- `handleWebAuthnAuthentication` - WebAuthn/passkey support +- `hasAuthParamsInUrl` - Detect OAuth parameters in URL +- `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](../../samples/vue-sdk-playground/) - 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/) + +## Support + +For support and questions: +- [Asgardeo Documentation](https://wso2.com/asgardeo/docs/) +- [GitHub Issues](https://github.com/asgardeo/asgardeo-auth-vue-sdk/issues) +- [WSO2 Community Forum](https://wso2.com/community/) + +## Contributing + +We welcome contributions! Please see our [Contributing Guidelines](./CONTRIBUTING.md) for more details. + ## License -Licenses this source under the Apache License, Version 2.0 [LICENSE](./LICENSE), You may not use this file except in compliance with the License. +Licenses this source under the Apache License, Version 2.0 [LICENSE](./LICENSE), You may not use this file except in compliance with the License. \ No newline at end of file diff --git a/packages/vue/declarations.d.ts b/packages/vue/declarations.d.ts index e8f63d99f..d9f56d9dc 100644 --- a/packages/vue/declarations.d.ts +++ b/packages/vue/declarations.d.ts @@ -20,3 +20,10 @@ declare module '*.svg' { const content: string; export default content; } + +declare module '*.vue' { + import type {DefineComponent} from 'vue'; + + const component: DefineComponent; + export default component; +} diff --git a/packages/vue/esbuild.config.mjs b/packages/vue/esbuild.config.mjs new file mode 100644 index 000000000..39b81fb6f --- /dev/null +++ b/packages/vue/esbuild.config.mjs @@ -0,0 +1,45 @@ +/** + * 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 {readFileSync} from 'fs'; +import {build} from 'esbuild'; + +const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); + +const commonOptions = { + bundle: true, + entryPoints: ['src/index.ts'], + external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})], + metafile: true, + platform: 'browser', + target: ['es2020'], +}; + +await build({ + ...commonOptions, + format: 'esm', + outfile: 'dist/index.js', + sourcemap: true, +}); + +await build({ + ...commonOptions, + format: 'cjs', + outfile: 'dist/cjs/index.js', + sourcemap: true, +}); diff --git a/packages/vue/package.json b/packages/vue/package.json index 2acbf734a..7aa9c8ce9 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,27 +1,7 @@ { "name": "@asgardeo/vue", "version": "0.0.10", - "description": "Vue SDK for Asgardeo - Authentication and Identity Management", - "main": "dist/cjs/index.js", - "module": "dist/esm/index.js", - "types": "dist/index.d.ts", - "type": "module", - "author": "WSO2", - "license": "Apache-2.0", - "files": [ - "dist", - "LICENSE", - "README.md" - ], - "homepage": "https://github.com/asgardeo/javascript/tree/main/packages/vue#readme", - "bugs": { - "url": "https://github.com/asgardeo/javascript/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/asgardeo/javascript", - "directory": "packages/vue" - }, + "description": "Vue 3 SDK for Asgardeo - Authentication and Identity Management", "keywords": [ "asgardeo", "authentication", @@ -34,62 +14,68 @@ "sso", "identity-management" ], + "homepage": "https://github.com/asgardeo/javascript/tree/main/packages/vue#readme", + "bugs": { + "url": "https://github.com/asgardeo/javascript/issues" + }, + "author": "WSO2", + "license": "Apache-2.0", + "type": "module", + "main": "dist/cjs/index.js", + "module": "dist/index.js", + "exports": { + "import": "./dist/index.js", + "require": "./dist/cjs/index.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/asgardeo/javascript", + "directory": "packages/vue" + }, "scripts": { - "build": "rollup -c", - "dev": "rollup -c -w", - "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", - "lint:fix": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", - "typecheck": "vue-tsc --noEmit", - "test": "vitest --config src/vitest.config.ts --environment=jsdom --passWithNoTests" + "build": "pnpm clean && node esbuild.config.mjs && tsc -p tsconfig.lib.json --emitDeclarationOnly --outDir dist", + "clean": "rimraf dist", + "lint": "eslint . --ext .js,.ts,.vue,.cjs,.mjs", + "lint:fix": "eslint . --ext .js,.ts,.vue,.cjs,.mjs --fix", + "test": "vitest --passWithNoTests", + "typecheck": "tsc -p tsconfig.lib.json" }, "devDependencies": { - "vite": "7.1.12", - "@rollup/plugin-commonjs": "25.0.7", - "@rollup/plugin-image": "3.0.3", - "@rollup/plugin-node-resolve": "15.2.3", - "@rollup/plugin-typescript": "11.1.6", - "@types/node": "20.12.7", - "@vitest/coverage-v8": "3.0.8", - "@vitest/web-worker": "3.0.8", - "@vue/eslint-config-prettier": "8.0.0", - "@vue/eslint-config-typescript": "12.0.0", + "@types/node": "22.15.3", "@vue/test-utils": "2.4.6", "@wso2/eslint-plugin": "catalog:", "@wso2/prettier-config": "catalog:", - "@wso2/stylelint-config": "catalog:", + "esbuild": "0.25.9", "eslint": "8.57.0", - "prettier": "3.2.5", - "rollup": "4.32.0", - "rollup-plugin-dts": "6.1.0", - "rollup-plugin-polyfill-node": "0.13.0", - "rollup-plugin-styles": "4.0.0", - "sass": "1.75.0", - "stylelint": "15.1.0", - "tslib": "2.6.2", - "typescript": "5.1.6", - "vitest": "3.0.8", - "vue-tsc": "2.2.2" - }, - "dependencies": { - "@asgardeo/auth-spa": "3.3.2", - "@asgardeo/js": "0.1.3", - "@vitejs/plugin-vue": "5.2.4", - "base64url": "3.0.1", - "buffer": "6.0.3", - "clsx": "2.1.1", - "fast-sha256": "1.3.0", - "jose": "5.3.0", - "randombytes": "2.1.0" + "jsdom": "26.1.0", + "prettier": "2.6.2", + "rimraf": "6.1.0", + "typescript": "5.7.2", + "vitest": "3.1.3", + "vue": "3.5.30" }, "peerDependencies": { - "vue": ">=3.5.13" + "vue": ">=3.5.0", + "vue-router": ">=4.0.0" }, - "exports": { - "types": "./dist/index.d.ts", - "import": "./dist/esm/index.js", - "require": "./dist/cjs/index.js" + "peerDependenciesMeta": { + "vue-router": { + "optional": true + } + }, + "dependencies": { + "@asgardeo/browser": "workspace:*", + "@asgardeo/i18n": "workspace:*", + "dompurify": "catalog:", + "tslib": "2.8.1" }, "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/packages/vue/rollup.config.cjs b/packages/vue/rollup.config.cjs deleted file mode 100644 index 0598532e4..000000000 --- a/packages/vue/rollup.config.cjs +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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. - */ - -const commonjs = require('@rollup/plugin-commonjs'); -const image = require('@rollup/plugin-image'); -const nodeResolve = require('@rollup/plugin-node-resolve'); -const typescript = require('@rollup/plugin-typescript'); -const dts = require('rollup-plugin-dts'); -const nodePolyfills = require('rollup-plugin-polyfill-node'); -const styles = require('rollup-plugin-styles'); -const pkg = require('./package.json'); - -module.exports = [ - { - cache: false, - external: ['vue'], - input: 'src/index.ts', - onwarn(warning, warn) { - // Suppress this error message... there are hundreds of them - if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return; - // Use default for everything else - warn(warning); - }, - output: [ - { - file: pkg.main, - format: 'cjs', - sourcemap: true, - }, - { - file: pkg.module, - format: 'esm', - sourcemap: true, - }, - ], - plugins: [ - nodePolyfills(), - nodeResolve({ - browser: true, - preferBuiltins: true, - }), - commonjs(), - typescript({tsconfig: './tsconfig.lib.json'}), - styles({ - mode: 'inject', - }), - image(), - ], - }, - { - cache: false, - external: [/\.(sass|scss|css)$/] /* ignore style files */, - input: 'dist/esm/types/index.d.ts', - output: [{file: 'dist/index.d.ts', format: 'esm'}], - plugins: [dts.default()], - }, -]; diff --git a/packages/vue/src/AsgardeoVueClient.ts b/packages/vue/src/AsgardeoVueClient.ts new file mode 100644 index 000000000..976649e19 --- /dev/null +++ b/packages/vue/src/AsgardeoVueClient.ts @@ -0,0 +1,520 @@ +/** + * 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 { + AsgardeoBrowserClient, + flattenUserSchema, + generateFlattenedUserProfile, + UserProfile, + SignInOptions, + SignOutOptions, + User, + generateUserProfile, + EmbeddedFlowExecuteResponse, + SignUpOptions, + EmbeddedFlowExecuteRequestPayload, + AsgardeoRuntimeError, + executeEmbeddedSignUpFlow, + EmbeddedSignInFlowHandleRequestPayload, + executeEmbeddedSignInFlow, + executeEmbeddedSignInFlowV2, + Organization, + IdToken, + EmbeddedFlowExecuteRequestConfig, + deriveOrganizationHandleFromBaseUrl, + AllOrganizationsApiResponse, + extractUserClaimsFromIdToken, + TokenResponse, + HttpRequestConfig, + HttpResponse, + navigate, + getRedirectBasedSignUpUrl, + Config, + TokenExchangeRequestConfig, + Platform, + isEmpty, + EmbeddedSignInFlowResponseV2, + executeEmbeddedSignUpFlowV2, + EmbeddedSignInFlowStatusV2, +} from '@asgardeo/browser'; +import AuthAPI from './__temp__/api'; +import getAllOrganizations from './api/getAllOrganizations'; +import getMeOrganizations from './api/getMeOrganizations'; +import getSchemas from './api/getSchemas'; +import getScim2Me from './api/getScim2Me'; +import {AsgardeoVueConfig} from './models/config'; + +/** + * Client for implementing Asgardeo in Vue applications. + * This class provides the core functionality for managing user authentication and sessions. + * + * @typeParam T - Configuration type that extends AsgardeoVueConfig. + */ +class AsgardeoVueClient extends AsgardeoBrowserClient { + private asgardeo: AuthAPI; + + private loadingState: boolean = false; + + private clientInstanceId: number; + + constructor(instanceId: number = 0) { + super(); + this.clientInstanceId = instanceId; + + // FIXME: This has to be the browser client from `@asgardeo/browser` package. + this.asgardeo = new AuthAPI(undefined, instanceId); + } + + public getInstanceId(): number { + return this.clientInstanceId; + } + + private setLoading(loading: boolean): void { + this.loadingState = loading; + } + + private async withLoading(operation: () => Promise): Promise { + this.setLoading(true); + try { + const result: TResult = await operation(); + return result; + } finally { + this.setLoading(false); + } + } + + override initialize(config: AsgardeoVueConfig): Promise { + let resolvedOrganizationHandle: string | undefined = config?.organizationHandle; + + if (!resolvedOrganizationHandle) { + resolvedOrganizationHandle = deriveOrganizationHandleFromBaseUrl(config?.baseUrl); + } + + return this.withLoading(async () => + this.asgardeo.init({...config, organizationHandle: resolvedOrganizationHandle} as any), + ); + } + + override reInitialize(config: Partial): Promise { + return this.withLoading(async () => { + let isInitialized: boolean; + + try { + await this.asgardeo.reInitialize(config as any); + isInitialized = true; + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to check if the client is initialized: ${error instanceof Error ? error.message : String(error)}`, + 'AsgardeoVueClient-reInitialize-RuntimeError-001', + 'vue', + 'An error occurred while checking the initialization status of the client.', + ); + } + + return isInitialized; + }); + } + + // eslint-disable-next-line class-methods-use-this + override async updateUserProfile(): Promise { + throw new Error('Not implemented'); + } + + override async getUser(options?: any): Promise { + try { + let baseUrl: string = options?.baseUrl; + + if (!baseUrl) { + const configData: any = await this.asgardeo.getConfigData(); + baseUrl = configData?.baseUrl; + } + + const profile: User = await getScim2Me({baseUrl}); + const schemas: any = await getSchemas({baseUrl}); + + return generateUserProfile(profile, flattenUserSchema(schemas)); + } catch (error) { + return extractUserClaimsFromIdToken(await this.getDecodedIdToken()); + } + } + + async getDecodedIdToken(sessionId?: string): Promise { + return this.asgardeo.getDecodedIdToken(sessionId); + } + + async getIdToken(): Promise { + return this.withLoading(async () => this.asgardeo.getIdToken()); + } + + override async getUserProfile(options?: any): Promise { + return this.withLoading(async () => { + try { + let baseUrl: string = options?.baseUrl; + + if (!baseUrl) { + const configData: any = await this.asgardeo.getConfigData(); + baseUrl = configData?.baseUrl; + } + + const profile: User = await getScim2Me({baseUrl, instanceId: this.getInstanceId()}); + const schemas: any = await getSchemas({baseUrl, instanceId: this.getInstanceId()}); + + const processedSchemas: any = flattenUserSchema(schemas); + + const output: UserProfile = { + flattenedProfile: generateFlattenedUserProfile(profile, processedSchemas), + profile, + schemas: processedSchemas, + }; + + return output; + } catch (error) { + return { + flattenedProfile: extractUserClaimsFromIdToken(await this.getDecodedIdToken()), + profile: extractUserClaimsFromIdToken(await this.getDecodedIdToken()), + schemas: [], + }; + } + }); + } + + override async getMyOrganizations(options?: any): Promise { + try { + let baseUrl: string = options?.baseUrl; + + if (!baseUrl) { + const configData: any = await this.asgardeo.getConfigData(); + baseUrl = configData?.baseUrl; + } + + return await getMeOrganizations({baseUrl, instanceId: this.getInstanceId()}); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to fetch the user's associated organizations: ${ + error instanceof Error ? error.message : String(error) + }`, + 'AsgardeoVueClient-getMyOrganizations-RuntimeError-001', + 'vue', + 'An error occurred while fetching associated organizations of the signed-in user.', + ); + } + } + + override async getAllOrganizations(options?: any): Promise { + try { + let baseUrl: string = options?.baseUrl; + + if (!baseUrl) { + const configData: any = await this.asgardeo.getConfigData(); + baseUrl = configData?.baseUrl; + } + + return await getAllOrganizations({baseUrl, instanceId: this.getInstanceId()}); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to fetch all organizations: ${error instanceof Error ? error.message : String(error)}`, + 'AsgardeoVueClient-getAllOrganizations-RuntimeError-001', + 'vue', + 'An error occurred while fetching all the organizations associated with the user.', + ); + } + } + + override async getCurrentOrganization(): Promise { + try { + return await this.withLoading(async () => { + const idToken: IdToken = await this.getDecodedIdToken(); + return { + id: idToken?.org_id, + name: idToken?.org_name, + orgHandle: idToken?.org_handle, + }; + }); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to fetch the current organization: ${error instanceof Error ? error.message : String(error)}`, + 'AsgardeoVueClient-getCurrentOrganization-RuntimeError-001', + 'vue', + 'An error occurred while fetching the current organization of the signed-in user.', + ); + } + } + + override async switchOrganization(organization: Organization): Promise { + return this.withLoading(async () => { + try { + const configData: any = await this.asgardeo.getConfigData(); + const sourceInstanceId: number | undefined = configData?.organizationChain?.sourceInstanceId; + + if (!organization.id) { + throw new AsgardeoRuntimeError( + 'Organization ID is required for switching organizations', + 'vue-AsgardeoVueClient-SwitchOrganizationError-001', + 'vue', + 'The organization object must contain a valid ID to perform the organization switch.', + ); + } + + const exchangeConfig: TokenExchangeRequestConfig = { + attachToken: false, + data: { + client_id: '{{clientId}}', + grant_type: 'organization_switch', + scope: '{{scopes}}', + switching_organization: organization.id, + token: '{{accessToken}}', + }, + id: 'organization-switch', + returnsSession: true, + signInRequired: sourceInstanceId === undefined, + }; + + return (await this.asgardeo.exchangeToken(exchangeConfig, () => {})) as TokenResponse | Response; + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to switch organization: ${error.message || error}`, + 'vue-AsgardeoVueClient-SwitchOrganizationError-003', + 'vue', + 'An error occurred while switching to the specified organization. Please try again.', + ); + } + }); + } + + override isLoading(): boolean { + return this.loadingState || this.asgardeo.isLoading(); + } + + async isInitialized(): Promise { + return this.asgardeo.isInitialized(); + } + + override async isSignedIn(): Promise { + return this.asgardeo.isSignedIn(); + } + + override getConfiguration(): T { + return this.asgardeo.getConfigData() as unknown as T; + } + + override async exchangeToken(config: TokenExchangeRequestConfig): Promise { + return this.withLoading( + async () => this.asgardeo.exchangeToken(config, () => {}) as unknown as TokenResponse | Response, + ); + } + + override signIn( + options?: SignInOptions, + sessionId?: string, + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; + override signIn( + payload: EmbeddedSignInFlowHandleRequestPayload, + request: EmbeddedFlowExecuteRequestConfig, + sessionId?: string, + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; + override async signIn(...args: any[]): Promise { + return this.withLoading(async () => { + const arg1: any = args[0]; + const arg2: any = args[1]; + + const config: AsgardeoVueConfig | undefined = (await this.asgardeo.getConfigData()) as + | AsgardeoVueConfig + | undefined; + + const platformFromStorage: string | null = sessionStorage.getItem('asgardeo_platform'); + const isV2Platform: boolean = + (config && config.platform === Platform.AsgardeoV2) || platformFromStorage === 'AsgardeoV2'; + + if (isV2Platform && typeof arg1 === 'object' && arg1 !== null && (arg1 as any).callOnlyOnRedirect === true) { + return undefined as any; + } + + if ( + isV2Platform && + typeof arg1 === 'object' && + arg1 !== null && + !isEmpty(arg1) && + ('flowId' in arg1 || 'applicationId' in arg1) + ) { + const authIdFromUrl: string = new URL(window.location.href).searchParams.get('authId'); + const authIdFromStorage: string = sessionStorage.getItem('asgardeo_auth_id'); + const authId: string = authIdFromUrl || authIdFromStorage; + const baseUrlFromStorage: string = sessionStorage.getItem('asgardeo_base_url'); + const baseUrl: string = config?.baseUrl || baseUrlFromStorage; + + const response: EmbeddedSignInFlowResponseV2 = await executeEmbeddedSignInFlowV2({ + authId, + baseUrl, + payload: arg1 as EmbeddedSignInFlowHandleRequestPayload, + url: arg2?.url, + }); + + if ( + isV2Platform && + response && + typeof response === 'object' && + response['flowStatus'] === EmbeddedSignInFlowStatusV2.Complete && + response['assertion'] + ) { + const decodedAssertion: { + [key: string]: unknown; + exp?: number; + iat?: number; + scope?: string; + } = await this.decodeJwtToken<{ + [key: string]: unknown; + exp?: number; + iat?: number; + scope?: string; + }>(response['assertion']); + + const createdAt: number = decodedAssertion.iat ? decodedAssertion.iat * 1000 : Date.now(); + const expiresIn: number = + decodedAssertion.exp && decodedAssertion.iat ? decodedAssertion.exp - decodedAssertion.iat : 3600; + + await this.setSession({ + access_token: response['assertion'], + created_at: createdAt, + expires_in: expiresIn, + id_token: response['assertion'], + scope: decodedAssertion.scope, + token_type: 'Bearer', + }); + } + + return response; + } + + if (typeof arg1 === 'object' && 'flowId' in arg1 && typeof arg2 === 'object' && 'url' in arg2) { + return executeEmbeddedSignInFlow({ + payload: arg1, + url: arg2.url, + }); + } + + return (await this.asgardeo.signIn(arg1 as any)) as unknown as Promise; + }); + } + + override async signInSilently(options?: SignInOptions): Promise { + return this.asgardeo.signInSilently(options as Record); + } + + override signOut(options?: SignOutOptions, afterSignOut?: (afterSignOutUrl: string) => void): Promise; + override signOut( + options?: SignOutOptions, + sessionId?: string, + afterSignOut?: (afterSignOutUrl: string) => void, + ): Promise; + override async signOut(...args: any[]): Promise { + let afterSignOut: ((url: string) => void) | undefined; + + if (typeof args[1] === 'string') { + // Overload 2: signOut(options, sessionId, afterSignOut?) + [, , afterSignOut] = args; + } else if (typeof args[1] === 'function') { + // Overload 1: signOut(options, afterSignOut) + [, afterSignOut] = args; + } + + const config: AsgardeoVueConfig = (await this.asgardeo.getConfigData()) as AsgardeoVueConfig; + + if (config.platform === Platform.AsgardeoV2) { + this.asgardeo.clearSession(); + + if (config.signInUrl) { + navigate(config.signInUrl); + } else { + this.signIn(config.signInOptions); + } + + afterSignOut?.(config.afterSignOutUrl || ''); + + return Promise.resolve(config.afterSignOutUrl || ''); + } + + const response: boolean = await this.asgardeo.signOut(afterSignOut as any); + + return Promise.resolve(String(response)); + } + + override async signUp(options?: SignUpOptions): Promise; + override async signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; + override async signUp(...args: any[]): Promise { + const config: AsgardeoVueConfig = (await this.asgardeo.getConfigData()) as AsgardeoVueConfig; + const firstArg: any = args[0]; + const baseUrl: string = config?.baseUrl; + + if (config.platform === Platform.AsgardeoV2) { + const authIdFromUrl: string = new URL(window.location.href).searchParams.get('authId'); + const authIdFromStorage: string = sessionStorage.getItem('asgardeo_auth_id'); + const authId: string = authIdFromUrl || authIdFromStorage; + + if (authIdFromUrl && !authIdFromStorage) { + sessionStorage.setItem('asgardeo_auth_id', authIdFromUrl); + } + + return executeEmbeddedSignUpFlowV2({ + authId, + baseUrl, + payload: + typeof firstArg === 'object' && 'flowType' in firstArg + ? {...(firstArg as EmbeddedFlowExecuteRequestPayload), verbose: true} + : (firstArg as EmbeddedFlowExecuteRequestPayload), + }) as any; + } + + if (typeof firstArg === 'object' && 'flowType' in firstArg) { + return executeEmbeddedSignUpFlow({ + baseUrl, + payload: firstArg as EmbeddedFlowExecuteRequestPayload, + }); + } + + navigate(getRedirectBasedSignUpUrl(config as Config)); + return undefined; + } + + async request(requestConfig?: HttpRequestConfig): Promise> { + return this.asgardeo.httpRequest(requestConfig); + } + + async requestAll(requestConfigs?: HttpRequestConfig[]): Promise[]> { + return this.asgardeo.httpRequestAll(requestConfigs); + } + + override async getAccessToken(sessionId?: string): Promise { + return this.asgardeo.getAccessToken(sessionId); + } + + override clearSession(sessionId?: string): void { + this.asgardeo.clearSession(sessionId); + } + + override async setSession(sessionData: Record, sessionId?: string): Promise { + return (await this.asgardeo.getStorageManager()).setSessionData(sessionData, sessionId); + } + + override decodeJwtToken>(token: string): Promise { + return this.asgardeo.decodeJwtToken(token); + } +} + +export default AsgardeoVueClient; diff --git a/packages/vue/src/__temp__/api.ts b/packages/vue/src/__temp__/api.ts new file mode 100644 index 000000000..95d8bf83e --- /dev/null +++ b/packages/vue/src/__temp__/api.ts @@ -0,0 +1,273 @@ +/** + * 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, + AuthClientConfig, + User, + LegacyConfig as Config, + IdToken, + Hooks, + HttpClientInstance, + HttpRequestConfig, + HttpResponse, + OIDCEndpoints, + SignInConfig, + SPACustomGrantConfig, +} from '@asgardeo/browser'; +import {AuthStateInterface} from './models'; + +class AuthAPI { + static DEFAULT_STATE: AuthStateInterface; + + private authState: AuthStateInterface = AuthAPI.DEFAULT_STATE; + + private client: AsgardeoSPAClient; + + private apiInstanceId: number; + + private loadingState: boolean; + + constructor(spaClient?: AsgardeoSPAClient, instanceId: number = 0) { + this.apiInstanceId = instanceId; + this.client = spaClient ?? AsgardeoSPAClient.getInstance(instanceId); + + this.getState = this.getState.bind(this); + this.init = this.init.bind(this); + this.signIn = this.signIn.bind(this); + this.signOut = this.signOut.bind(this); + this.updateState = this.updateState.bind(this); + } + + public getInstanceId(): number { + return this.apiInstanceId; + } + + public setLoadingState(isLoading: boolean): void { + this.loadingState = isLoading; + } + + public getLoadingState(): boolean { + return this.loadingState; + } + + public isLoading(): boolean { + return this.getLoadingState(); + } + + public getState(): AuthStateInterface { + return this.authState; + } + + public async init(config: AuthClientConfig): Promise { + return this.client.initialize(config); + } + + public async getConfigData(): Promise> { + return this.client.getConfigData(); + } + + public async getStorageManager(): Promise { + return this.client.getStorageManager(); + } + + public async isInitialized(): Promise { + return this.client.isInitialized(); + } + + public async signIn( + config: SignInConfig, + authorizationCode?: string, + sessionState?: string, + authState?: string, + callback?: (response: User) => void, + tokenRequestConfig?: { + params: Record; + }, + ): Promise { + return this.client + .signIn(config, authorizationCode, sessionState, authState, tokenRequestConfig) + .then(async (response: User) => { + if (!response) { + return null; + } + + if (await this.client.isSignedIn()) { + const stateToUpdate: AuthStateInterface = { + displayName: response.displayName, + email: response.email, + isLoading: false, + isSignedIn: true, + username: response.username, + }; + + this.updateState(stateToUpdate); + this.setLoadingState(false); + + if (callback) { + callback(response); + } + } + + return response; + }) + .catch((error: Error) => Promise.reject(error)); + } + + public signOut(callback?: (response?: boolean) => void): Promise { + return this.client + .signOut() + .then((response: boolean) => { + if (callback) { + callback(response); + } + + return response; + }) + .catch((error: Error) => Promise.reject(error)); + } + + public updateState(state: AuthStateInterface): void { + this.authState = {...this.authState, ...state}; + } + + public async getUser(): Promise { + return this.client.getUser(); + } + + public async httpRequest(config: HttpRequestConfig): Promise> { + return this.client.httpRequest(config); + } + + public async httpRequestAll(configs: HttpRequestConfig[]): Promise[]> { + return this.client.httpRequestAll(configs); + } + + public exchangeToken( + config: SPACustomGrantConfig, + callback: (response: User | Response) => void, + ): Promise { + return this.client + .exchangeToken(config) + .then((response: User | Response) => { + if (!response) { + return null; + } + + if (config.returnsSession) { + this.updateState({ + ...this.getState(), + ...(response as User), + isLoading: false, + isSignedIn: true, + }); + } + + if (callback) { + callback(response); + } + + return response; + }) + .catch((error: Error) => Promise.reject(error)); + } + + public async getOpenIDProviderEndpoints(): Promise { + return this.client.getOpenIDProviderEndpoints(); + } + + public async getHttpClient(): Promise { + return this.client.getHttpClient(); + } + + public async decodeJwtToken>(token: string): Promise { + return this.client.decodeJwtToken(token); + } + + public async getDecodedIdToken(sessionId?: string): Promise { + return this.client.getDecodedIdToken(sessionId); + } + + public async getIdToken(): Promise { + return this.client.getIdToken(); + } + + public async getAccessToken(sessionId?: string): Promise { + return this.client.getAccessToken(sessionId); + } + + public async refreshAccessToken(): Promise { + return this.client.refreshAccessToken(); + } + + public async isSignedIn(): Promise { + return this.client.isSignedIn(); + } + + public async enableHttpHandler(): Promise { + return this.client.enableHttpHandler(); + } + + public async disableHttpHandler(): Promise { + return this.client.disableHttpHandler(); + } + + public async reInitialize(config: Partial>): Promise { + return this.client.reInitialize(config); + } + + public on(hook: Hooks.CustomGrant, callback: (response?: any) => void, id: string): Promise; + public on(hook: Exclude, callback: (response?: any) => void): Promise; + public on(hook: Hooks, callback: (response?: any) => void, id?: string): Promise { + if (hook === Hooks.CustomGrant) { + return this.client.on(hook, callback, id); + } + + return this.client.on(hook, callback); + } + + public async signInSilently( + additionalParams?: Record, + tokenRequestConfig?: {params: Record}, + ): Promise { + return this.client + .signInSilently(additionalParams, tokenRequestConfig) + .then(async (response: User | boolean) => { + if (!response) { + return false; + } + + return response; + }) + .catch((error: Error) => Promise.reject(error)); + } + + public clearSession(sessionId?: string): void { + this.client.clearSession(sessionId); + } +} + +AuthAPI.DEFAULT_STATE = { + displayName: '', + email: '', + isLoading: true, + isSignedIn: false, + username: '', +}; + +export default AuthAPI; diff --git a/packages/vue/src/__temp__/models.ts b/packages/vue/src/__temp__/models.ts new file mode 100644 index 000000000..7da8ce972 --- /dev/null +++ b/packages/vue/src/__temp__/models.ts @@ -0,0 +1,44 @@ +/** + * 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. + */ + +/** + * Interface for the Authenticated state of the user which is exposed + * via `state` object from the auth context. + */ +export interface AuthStateInterface { + /** + * The display name of the user. + */ + displayName?: string; + /** + * The email address of the user. + */ + email?: string; + /** + * Are the Auth requests loading. + */ + isLoading: boolean; + /** + * Specifies if the user is authenticated or not. + */ + isSignedIn: boolean; + /** + * The username of the user. + */ + username?: string; +} diff --git a/packages/vue/src/__tests__/components/actions.test.ts b/packages/vue/src/__tests__/components/actions.test.ts new file mode 100644 index 000000000..d93b30b90 --- /dev/null +++ b/packages/vue/src/__tests__/components/actions.test.ts @@ -0,0 +1,256 @@ +/** + * 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. + */ + +/* eslint-disable sort-keys, @typescript-eslint/typedef */ +import {mount} from '@vue/test-utils'; +import {describe, expect, it, vi} from 'vitest'; +import {h, ref} from 'vue'; +import BaseSignInButton from '../../components/actions/BaseSignInButton'; +import BaseSignOutButton from '../../components/actions/BaseSignOutButton'; +import SignInButton from '../../components/actions/SignInButton'; +import SignOutButton from '../../components/actions/SignOutButton'; +import {ASGARDEO_KEY} from '../../keys'; +import type {AsgardeoContext} from '../../models/contexts'; + +function createMockAsgardeoContext(overrides: Partial = {}): AsgardeoContext { + return { + isSignedIn: ref(false), + isLoading: ref(false), + isInitialized: ref(true), + user: ref(null), + organization: ref(null), + signIn: vi.fn().mockResolvedValue(undefined), + signOut: vi.fn().mockResolvedValue(undefined), + signUp: vi.fn(), + signInSilently: vi.fn(), + getAccessToken: vi.fn(), + getDecodedIdToken: vi.fn(), + getIdToken: vi.fn(), + exchangeToken: vi.fn(), + reInitialize: vi.fn(), + clearSession: vi.fn(), + http: { + request: vi.fn(), + requestAll: vi.fn(), + }, + ...overrides, + } as unknown as AsgardeoContext; +} + +describe('BaseSignInButton', () => { + it('should render with default "Sign In" text', () => { + const wrapper = mount(BaseSignInButton, { + props: {unstyled: true}, + }); + + expect(wrapper.text()).toBe('Sign In'); + }); + + it('should render custom slot content', () => { + const wrapper = mount(BaseSignInButton, { + props: {unstyled: true}, + slots: { + default: () => h('span', 'Login Now'), + }, + }); + + expect(wrapper.text()).toBe('Login Now'); + }); + + it('should emit click event when clicked', async () => { + const wrapper = mount(BaseSignInButton, { + props: {unstyled: true}, + }); + + await wrapper.find('button').trigger('click'); + expect(wrapper.emitted('click')).toHaveLength(1); + }); + + it('should not emit click when disabled', async () => { + const wrapper = mount(BaseSignInButton, { + props: {unstyled: true, disabled: true}, + }); + + await wrapper.find('button').trigger('click'); + expect(wrapper.emitted('click')).toBeUndefined(); + }); + + it('should not emit click when loading', async () => { + const wrapper = mount(BaseSignInButton, { + props: {unstyled: true, isLoading: true}, + }); + + await wrapper.find('button').trigger('click'); + expect(wrapper.emitted('click')).toBeUndefined(); + }); + + it('should set disabled attribute when disabled prop is true', () => { + const wrapper = mount(BaseSignInButton, { + props: {unstyled: true, disabled: true}, + }); + + const button = wrapper.find('button'); + expect(button.attributes('disabled')).toBeDefined(); + }); + + it('should set disabled attribute when isLoading prop is true', () => { + const wrapper = mount(BaseSignInButton, { + props: {unstyled: true, isLoading: true}, + }); + + const button = wrapper.find('button'); + expect(button.attributes('disabled')).toBeDefined(); + }); +}); + +describe('BaseSignOutButton', () => { + it('should render with default "Sign Out" text in unstyled mode', () => { + const wrapper = mount(BaseSignOutButton, { + props: {unstyled: true}, + }); + + expect(wrapper.text()).toBe('Sign Out'); + }); + + it('should emit click event when clicked', async () => { + const wrapper = mount(BaseSignOutButton, { + props: {unstyled: true}, + }); + + await wrapper.find('button').trigger('click'); + expect(wrapper.emitted('click')).toHaveLength(1); + }); + + it('should not emit click when disabled', async () => { + const wrapper = mount(BaseSignOutButton, { + props: {unstyled: true, disabled: true}, + }); + + await wrapper.find('button').trigger('click'); + expect(wrapper.emitted('click')).toBeUndefined(); + }); +}); + +describe('SignInButton', () => { + it('should call signIn when clicked', async () => { + const signIn = vi.fn().mockResolvedValue(undefined); + const mockContext = createMockAsgardeoContext({signIn}); + + const wrapper = mount(SignInButton, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + }); + + await wrapper.find('button').trigger('click'); + // Allow the async handler to complete + await vi.waitFor(() => { + expect(signIn).toHaveBeenCalled(); + }); + }); + + it('should emit error event when signIn fails', async () => { + const signIn = vi.fn().mockRejectedValue(new Error('Auth failed')); + const mockContext = createMockAsgardeoContext({signIn}); + + const wrapper = mount(SignInButton, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + config: { + errorHandler: () => { + // Suppress unhandled AsgardeoRuntimeError thrown after emit + }, + }, + }, + }); + + await wrapper.find('button').trigger('click'); + await vi.waitFor(() => { + expect(wrapper.emitted('error')).toBeDefined(); + }); + }); + + it('should pass signInOptions prop to signIn', async () => { + const signIn = vi.fn().mockResolvedValue(undefined); + const mockContext = createMockAsgardeoContext({signIn}); + const options = {prompt: 'login'}; + + const wrapper = mount(SignInButton, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + props: { + signInOptions: options, + }, + }); + + await wrapper.find('button').trigger('click'); + await vi.waitFor(() => { + expect(signIn).toHaveBeenCalledWith(options); + }); + }); +}); + +describe('SignOutButton', () => { + it('should call signOut when clicked', async () => { + const signOut = vi.fn().mockResolvedValue(undefined); + const mockContext = createMockAsgardeoContext({signOut}); + + const wrapper = mount(SignOutButton, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + }); + + await wrapper.find('button').trigger('click'); + await vi.waitFor(() => { + expect(signOut).toHaveBeenCalled(); + }); + }); + + it('should emit error event when signOut fails', async () => { + const signOut = vi.fn().mockRejectedValue(new Error('Sign out failed')); + const mockContext = createMockAsgardeoContext({signOut}); + + const wrapper = mount(SignOutButton, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + config: { + errorHandler: () => { + // Suppress unhandled AsgardeoRuntimeError thrown after emit + }, + }, + }, + }); + + await wrapper.find('button').trigger('click'); + await vi.waitFor(() => { + expect(wrapper.emitted('error')).toBeDefined(); + }); + }); +}); diff --git a/packages/vue/src/__tests__/components/callback.test.ts b/packages/vue/src/__tests__/components/callback.test.ts new file mode 100644 index 000000000..6db7b4b6e --- /dev/null +++ b/packages/vue/src/__tests__/components/callback.test.ts @@ -0,0 +1,179 @@ +/** + * 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. + */ + +/* eslint-disable sort-keys, @typescript-eslint/typedef */ +import {mount} from '@vue/test-utils'; +import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'; +import Callback from '../../components/auth/Callback'; + +describe('Callback', () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + sessionStorage.clear(); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + }); + sessionStorage.clear(); + }); + + function setWindowLocation(url: string): void { + Object.defineProperty(window, 'location', { + value: new URL(url), + writable: true, + }); + } + + it('should render nothing (headless component)', () => { + setWindowLocation('http://localhost:3000/callback'); + const wrapper = mount(Callback); + expect(wrapper.html()).toBe(''); + setWindowLocation('http://localhost:3000/callback'); + const onNavigate = vi.fn(); + const onError = vi.fn(); + + mount(Callback, { + props: {onNavigate, onError}, + }); + + expect(onNavigate).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('should forward OAuth code to the return path', () => { + const state = 'test-state-123'; + setWindowLocation(`http://localhost:3000/callback?code=auth-code-456&state=${state}`); + + // Store the state data in sessionStorage (simulating what signIn does) + sessionStorage.setItem(`asgardeo_oauth_${state}`, JSON.stringify({path: '/dashboard', timestamp: Date.now()})); + + const onNavigate = vi.fn(); + mount(Callback, { + props: {onNavigate}, + }); + + expect(onNavigate).toHaveBeenCalledTimes(1); + const navigatedPath = onNavigate.mock.calls[0][0]; + expect(navigatedPath).toContain('/dashboard'); + expect(navigatedPath).toContain('code=auth-code-456'); + }); + + it('should include nonce in forwarded params when present', () => { + const state = 'test-state-789'; + setWindowLocation(`http://localhost:3000/callback?code=auth-code&state=${state}&nonce=test-nonce`); + + sessionStorage.setItem(`asgardeo_oauth_${state}`, JSON.stringify({path: '/app', timestamp: Date.now()})); + + const onNavigate = vi.fn(); + mount(Callback, { + props: {onNavigate}, + }); + + expect(onNavigate).toHaveBeenCalledTimes(1); + const navigatedPath = onNavigate.mock.calls[0][0]; + expect(navigatedPath).toContain('nonce=test-nonce'); + }); + + it('should call onError when state is missing', () => { + setWindowLocation('http://localhost:3000/callback?code=auth-code'); + + const onError = vi.fn(); + const onNavigate = vi.fn(); + mount(Callback, { + props: {onError, onNavigate}, + }); + + expect(onError).toHaveBeenCalledTimes(1); + const error = onError.mock.calls[0][0]; + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('Missing OAuth state parameter'); + }); + + it('should call onError when stored state is not found', () => { + setWindowLocation('http://localhost:3000/callback?code=auth-code&state=unknown-state'); + + const onError = vi.fn(); + const onNavigate = vi.fn(); + mount(Callback, { + props: {onError, onNavigate}, + }); + + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('should call onError when state has expired', () => { + const state = 'expired-state'; + setWindowLocation(`http://localhost:3000/callback?code=auth-code&state=${state}`); + + // Set timestamp to 11 minutes ago (beyond the 10-minute max) + sessionStorage.setItem( + `asgardeo_oauth_${state}`, + JSON.stringify({path: '/dashboard', timestamp: Date.now() - 11 * 60 * 1000}), + ); + + const onError = vi.fn(); + const onNavigate = vi.fn(); + mount(Callback, { + props: {onError, onNavigate}, + }); + + expect(onError).toHaveBeenCalledTimes(1); + const error = onError.mock.calls[0][0]; + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('expired'); + }); + + it('should handle OAuth error response and call onError', () => { + const state = 'error-state'; + setWindowLocation( + `http://localhost:3000/callback?error=access_denied&error_description=User+cancelled&state=${state}`, + ); + + sessionStorage.setItem(`asgardeo_oauth_${state}`, JSON.stringify({path: '/login', timestamp: Date.now()})); + + const onError = vi.fn(); + const onNavigate = vi.fn(); + mount(Callback, { + props: {onError, onNavigate}, + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledTimes(1); + const navigatedPath = onNavigate.mock.calls[0][0]; + expect(navigatedPath).toContain('error=access_denied'); + }); + + it('should clean up sessionStorage after processing', () => { + const state = 'cleanup-state'; + setWindowLocation(`http://localhost:3000/callback?code=auth-code&state=${state}`); + + sessionStorage.setItem(`asgardeo_oauth_${state}`, JSON.stringify({path: '/app', timestamp: Date.now()})); + + const onNavigate = vi.fn(); + mount(Callback, { + props: {onNavigate}, + }); + + expect(sessionStorage.getItem(`asgardeo_oauth_${state}`)).toBeNull(); + }); +}); diff --git a/packages/vue/src/__tests__/components/control.test.ts b/packages/vue/src/__tests__/components/control.test.ts new file mode 100644 index 000000000..e8a105a18 --- /dev/null +++ b/packages/vue/src/__tests__/components/control.test.ts @@ -0,0 +1,293 @@ +/** + * 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. + */ + +/* eslint-disable sort-keys, @typescript-eslint/typedef */ +import {mount} from '@vue/test-utils'; +import {describe, expect, it, vi} from 'vitest'; +import {h, ref} from 'vue'; +import Loading from '../../components/control/Loading'; +import SignedIn from '../../components/control/SignedIn'; +import SignedOut from '../../components/control/SignedOut'; +import {ASGARDEO_KEY} from '../../keys'; +import type {AsgardeoContext} from '../../models/contexts'; + +function createMockAsgardeoContext(overrides: Partial = {}): AsgardeoContext { + return { + isSignedIn: ref(false), + isLoading: ref(false), + isInitialized: ref(true), + user: ref(null), + organization: ref(null), + signIn: vi.fn(), + signOut: vi.fn(), + signUp: vi.fn(), + signInSilently: vi.fn(), + getAccessToken: vi.fn(), + getDecodedIdToken: vi.fn(), + getIdToken: vi.fn(), + exchangeToken: vi.fn(), + reInitialize: vi.fn(), + clearSession: vi.fn(), + http: { + request: vi.fn(), + requestAll: vi.fn(), + }, + ...overrides, + } as unknown as AsgardeoContext; +} + +describe('SignedIn', () => { + it('should render default slot content when user is signed in', () => { + const mockContext = createMockAsgardeoContext({isSignedIn: ref(true)}); + + const wrapper = mount(SignedIn, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'dashboard'}, 'Dashboard'), + }, + }); + + expect(wrapper.find('.dashboard').exists()).toBe(true); + expect(wrapper.text()).toBe('Dashboard'); + }); + + it('should not render default slot content when user is not signed in', () => { + const mockContext = createMockAsgardeoContext({isSignedIn: ref(false)}); + + const wrapper = mount(SignedIn, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'dashboard'}, 'Dashboard'), + }, + }); + + expect(wrapper.find('.dashboard').exists()).toBe(false); + }); + + it('should render fallback slot when user is not signed in', () => { + const mockContext = createMockAsgardeoContext({isSignedIn: ref(false)}); + + const wrapper = mount(SignedIn, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'dashboard'}, 'Dashboard'), + fallback: () => h('div', {class: 'fallback'}, 'Please sign in'), + }, + }); + + expect(wrapper.find('.dashboard').exists()).toBe(false); + expect(wrapper.find('.fallback').exists()).toBe(true); + expect(wrapper.text()).toBe('Please sign in'); + }); + + it('should react to auth state changes', async () => { + const isSignedIn = ref(false); + const mockContext = createMockAsgardeoContext({isSignedIn}); + + const wrapper = mount(SignedIn, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'dashboard'}, 'Dashboard'), + }, + }); + + expect(wrapper.find('.dashboard').exists()).toBe(false); + + isSignedIn.value = true; + await wrapper.vm.$nextTick(); + + expect(wrapper.find('.dashboard').exists()).toBe(true); + }); +}); + +describe('SignedOut', () => { + it('should render default slot content when user is not signed in', () => { + const mockContext = createMockAsgardeoContext({isSignedIn: ref(false)}); + + const wrapper = mount(SignedOut, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'landing'}, 'Welcome'), + }, + }); + + expect(wrapper.find('.landing').exists()).toBe(true); + expect(wrapper.text()).toBe('Welcome'); + }); + + it('should not render default slot content when user is signed in', () => { + const mockContext = createMockAsgardeoContext({isSignedIn: ref(true)}); + + const wrapper = mount(SignedOut, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'landing'}, 'Welcome'), + }, + }); + + expect(wrapper.find('.landing').exists()).toBe(false); + }); + + it('should render fallback slot when user is signed in', () => { + const mockContext = createMockAsgardeoContext({isSignedIn: ref(true)}); + + const wrapper = mount(SignedOut, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'landing'}, 'Welcome'), + fallback: () => h('div', {class: 'fallback'}, 'Already signed in'), + }, + }); + + expect(wrapper.find('.landing').exists()).toBe(false); + expect(wrapper.find('.fallback').exists()).toBe(true); + }); + + it('should react to auth state changes', async () => { + const isSignedIn = ref(false); + const mockContext = createMockAsgardeoContext({isSignedIn}); + + const wrapper = mount(SignedOut, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'landing'}, 'Welcome'), + }, + }); + + expect(wrapper.find('.landing').exists()).toBe(true); + + isSignedIn.value = true; + await wrapper.vm.$nextTick(); + + expect(wrapper.find('.landing').exists()).toBe(false); + }); +}); + +describe('Loading', () => { + it('should render default slot content when loading', () => { + const mockContext = createMockAsgardeoContext({isLoading: ref(true)}); + + const wrapper = mount(Loading, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'spinner'}, 'Loading...'), + }, + }); + + expect(wrapper.find('.spinner').exists()).toBe(true); + expect(wrapper.text()).toBe('Loading...'); + }); + + it('should not render default slot content when not loading', () => { + const mockContext = createMockAsgardeoContext({isLoading: ref(false)}); + + const wrapper = mount(Loading, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'spinner'}, 'Loading...'), + }, + }); + + expect(wrapper.find('.spinner').exists()).toBe(false); + }); + + it('should render fallback slot when not loading', () => { + const mockContext = createMockAsgardeoContext({isLoading: ref(false)}); + + const wrapper = mount(Loading, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'spinner'}, 'Loading...'), + fallback: () => h('div', {class: 'content'}, 'Content loaded'), + }, + }); + + expect(wrapper.find('.spinner').exists()).toBe(false); + expect(wrapper.find('.content').exists()).toBe(true); + }); + + it('should react to loading state changes', async () => { + const isLoading = ref(true); + const mockContext = createMockAsgardeoContext({isLoading}); + + const wrapper = mount(Loading, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + slots: { + default: () => h('div', {class: 'spinner'}, 'Loading...'), + fallback: () => h('div', {class: 'content'}, 'Content loaded'), + }, + }); + + expect(wrapper.find('.spinner').exists()).toBe(true); + expect(wrapper.find('.content').exists()).toBe(false); + + isLoading.value = false; + await wrapper.vm.$nextTick(); + + expect(wrapper.find('.spinner').exists()).toBe(false); + expect(wrapper.find('.content').exists()).toBe(true); + }); +}); diff --git a/packages/vue/src/__tests__/composables/secondary-composables.test.ts b/packages/vue/src/__tests__/composables/secondary-composables.test.ts new file mode 100644 index 000000000..6732f29d0 --- /dev/null +++ b/packages/vue/src/__tests__/composables/secondary-composables.test.ts @@ -0,0 +1,249 @@ +/** + * 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 {mount} from '@vue/test-utils'; +/* eslint-disable sort-keys, @typescript-eslint/typedef, @typescript-eslint/explicit-function-return-type */ +import {describe, expect, it, vi} from 'vitest'; +import {defineComponent, h, ref} from 'vue'; +import useBranding from '../../composables/useBranding'; +import useFlow from '../../composables/useFlow'; +import useFlowMeta from '../../composables/useFlowMeta'; +import useI18n from '../../composables/useI18n'; +import useTheme from '../../composables/useTheme'; +import {FLOW_KEY, FLOW_META_KEY, THEME_KEY, BRANDING_KEY, I18N_KEY} from '../../keys'; +import type { + FlowContextValue, + FlowMetaContextValue, + ThemeContextValue, + BrandingContextValue, + I18nContextValue, +} from '../../models/contexts'; + +describe('useFlow', () => { + it('should return the FlowContextValue when called inside a provider', () => { + const mockContext: Partial = { + currentStep: ref('signin') as any, + messages: ref([]) as any, + isLoading: ref(false) as any, + navigateToFlow: vi.fn(), + }; + let result: FlowContextValue | undefined; + + const TestChild = defineComponent({ + setup() { + result = useFlow(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [FLOW_KEY as symbol]: mockContext, + }, + }, + }); + + expect(result).toBeDefined(); + }); + + it('should throw an error when called outside of AsgardeoProvider', () => { + const TestChild = defineComponent({ + setup() { + useFlow(); + return () => h('div', 'test'); + }, + }); + + expect(() => { + mount(TestChild); + }).toThrow('[Asgardeo] useFlow() was called outside of '); + }); +}); + +describe('useFlowMeta', () => { + it('should return the FlowMetaContextValue when called inside a provider', () => { + const mockContext: Partial = { + meta: ref(null) as any, + isLoading: ref(false) as any, + error: ref(null) as any, + fetchFlowMeta: vi.fn(), + }; + let result: FlowMetaContextValue | undefined; + + const TestChild = defineComponent({ + setup() { + result = useFlowMeta(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [FLOW_META_KEY as symbol]: mockContext, + }, + }, + }); + + expect(result).toBeDefined(); + }); + + it('should throw an error when called outside of AsgardeoProvider', () => { + const TestChild = defineComponent({ + setup() { + useFlowMeta(); + return () => h('div', 'test'); + }, + }); + + expect(() => { + mount(TestChild); + }).toThrow('[Asgardeo] useFlowMeta() was called outside of '); + }); +}); + +describe('useTheme', () => { + it('should return the ThemeContextValue when called inside a provider', () => { + const mockContext: Partial = { + theme: ref({}) as any, + colorScheme: ref('light') as any, + direction: ref('ltr') as any, + toggleTheme: vi.fn(), + }; + let result: ThemeContextValue | undefined; + + const TestChild = defineComponent({ + setup() { + result = useTheme(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [THEME_KEY as symbol]: mockContext, + }, + }, + }); + + expect(result).toBeDefined(); + }); + + it('should throw an error when called outside of AsgardeoProvider', () => { + const TestChild = defineComponent({ + setup() { + useTheme(); + return () => h('div', 'test'); + }, + }); + + expect(() => { + mount(TestChild); + }).toThrow('[Asgardeo] useTheme() was called outside of '); + }); +}); + +describe('useBranding', () => { + it('should return the BrandingContextValue when called inside a provider', () => { + const mockContext: Partial = { + brandingPreference: ref(null) as any, + theme: ref(null) as any, + isLoading: ref(false) as any, + error: ref(null) as any, + refetch: vi.fn(), + }; + let result: BrandingContextValue | undefined; + + const TestChild = defineComponent({ + setup() { + result = useBranding(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [BRANDING_KEY as symbol]: mockContext, + }, + }, + }); + + expect(result).toBeDefined(); + }); + + it('should throw an error when called outside of AsgardeoProvider', () => { + const TestChild = defineComponent({ + setup() { + useBranding(); + return () => h('div', 'test'); + }, + }); + + expect(() => { + mount(TestChild); + }).toThrow('[Asgardeo] useBranding() was called outside of '); + }); +}); + +describe('useI18n', () => { + it('should return the I18nContextValue when called inside a provider', () => { + const mockContext: Partial = { + t: vi.fn((key: string) => key), + currentLanguage: ref('en-US') as any, + fallbackLanguage: 'en-US', + setLanguage: vi.fn(), + bundles: ref({}) as any, + injectBundles: vi.fn(), + }; + let result: I18nContextValue | undefined; + + const TestChild = defineComponent({ + setup() { + result = useI18n(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [I18N_KEY as symbol]: mockContext, + }, + }, + }); + + expect(result).toBeDefined(); + expect(typeof result!.t).toBe('function'); + }); + + it('should throw an error when called outside of AsgardeoProvider', () => { + const TestChild = defineComponent({ + setup() { + useI18n(); + return () => h('div', 'test'); + }, + }); + + expect(() => { + mount(TestChild); + }).toThrow('[Asgardeo] useI18n() was called outside of '); + }); +}); diff --git a/packages/vue/src/__tests__/composables/useAsgardeo.test.ts b/packages/vue/src/__tests__/composables/useAsgardeo.test.ts new file mode 100644 index 000000000..027e8da34 --- /dev/null +++ b/packages/vue/src/__tests__/composables/useAsgardeo.test.ts @@ -0,0 +1,143 @@ +/** + * 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 {mount} from '@vue/test-utils'; +/* eslint-disable sort-keys, @typescript-eslint/typedef, @typescript-eslint/explicit-function-return-type */ +import {describe, expect, it, vi} from 'vitest'; +import {defineComponent, h, ref} from 'vue'; +import useAsgardeo from '../../composables/useAsgardeo'; +import {ASGARDEO_KEY} from '../../keys'; +import type {AsgardeoContext} from '../../models/contexts'; + +/** + * Creates a minimal mock AsgardeoContext for testing purposes. + */ +function createMockAsgardeoContext(overrides: Partial = {}): AsgardeoContext { + return { + isSignedIn: ref(false), + isLoading: ref(false), + isInitialized: ref(true), + user: ref(null), + organization: ref(null), + signIn: vi.fn(), + signOut: vi.fn(), + signUp: vi.fn(), + signInSilently: vi.fn(), + getAccessToken: vi.fn(), + getDecodedIdToken: vi.fn(), + getIdToken: vi.fn(), + exchangeToken: vi.fn(), + reInitialize: vi.fn(), + clearSession: vi.fn(), + http: { + request: vi.fn(), + requestAll: vi.fn(), + }, + ...overrides, + } as unknown as AsgardeoContext; +} + +describe('useAsgardeo', () => { + it('should return the AsgardeoContext when called inside a provider', () => { + const mockContext = createMockAsgardeoContext(); + let result: AsgardeoContext | undefined; + + const TestChild = defineComponent({ + setup() { + result = useAsgardeo(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + }); + + expect(result).toBeDefined(); + expect(result!.isSignedIn.value).toBe(false); + expect(result!.isLoading.value).toBe(false); + expect(result!.isInitialized.value).toBe(true); + }); + + it('should throw an error when called outside of AsgardeoProvider', () => { + const TestChild = defineComponent({ + setup() { + useAsgardeo(); + return () => h('div', 'test'); + }, + }); + + expect(() => { + mount(TestChild); + }).toThrow('[Asgardeo] useAsgardeo() was called outside of '); + }); + + it('should return reactive auth state', () => { + const isSignedIn = ref(false); + const mockContext = createMockAsgardeoContext({isSignedIn}); + let result: AsgardeoContext | undefined; + + const TestChild = defineComponent({ + setup() { + result = useAsgardeo(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + }); + + expect(result!.isSignedIn.value).toBe(false); + isSignedIn.value = true; + expect(result!.isSignedIn.value).toBe(true); + }); + + it('should expose signIn and signOut methods', () => { + const mockContext = createMockAsgardeoContext(); + let result: AsgardeoContext | undefined; + + const TestChild = defineComponent({ + setup() { + result = useAsgardeo(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [ASGARDEO_KEY as symbol]: mockContext, + }, + }, + }); + + expect(typeof result!.signIn).toBe('function'); + expect(typeof result!.signOut).toBe('function'); + expect(typeof result!.signUp).toBe('function'); + expect(typeof result!.getAccessToken).toBe('function'); + }); +}); diff --git a/packages/vue/src/__tests__/composables/useOrganization.test.ts b/packages/vue/src/__tests__/composables/useOrganization.test.ts new file mode 100644 index 000000000..8e7d14e31 --- /dev/null +++ b/packages/vue/src/__tests__/composables/useOrganization.test.ts @@ -0,0 +1,101 @@ +/** + * 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. + */ + +/* eslint-disable sort-keys, @typescript-eslint/typedef, @typescript-eslint/explicit-function-return-type */ +import {mount} from '@vue/test-utils'; +import {describe, expect, it, vi} from 'vitest'; +import {defineComponent, h, ref, shallowRef} from 'vue'; +import useOrganization from '../../composables/useOrganization'; +import {ORGANIZATION_KEY} from '../../keys'; +import type {OrganizationContextValue} from '../../models/contexts'; + +function createMockOrganizationContext(overrides: Partial = {}): OrganizationContextValue { + return { + myOrganizations: shallowRef([]), + currentOrganization: shallowRef(null), + switchOrganization: vi.fn(), + getAllOrganizations: vi.fn(), + revalidateMyOrganizations: vi.fn(), + isLoading: ref(false), + error: ref(null), + ...overrides, + } as unknown as OrganizationContextValue; +} + +describe('useOrganization', () => { + it('should return the OrganizationContextValue when called inside a provider', () => { + const mockContext = createMockOrganizationContext(); + let result: OrganizationContextValue | undefined; + + const TestChild = defineComponent({ + setup() { + result = useOrganization(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [ORGANIZATION_KEY as symbol]: mockContext, + }, + }, + }); + + expect(result).toBeDefined(); + expect(result!.myOrganizations.value).toEqual([]); + expect(result!.currentOrganization.value).toBeNull(); + }); + + it('should throw an error when called outside of AsgardeoProvider', () => { + const TestChild = defineComponent({ + setup() { + useOrganization(); + return () => h('div', 'test'); + }, + }); + + expect(() => { + mount(TestChild); + }).toThrow('[Asgardeo] useOrganization() was called outside of '); + }); + + it('should expose organization management methods', () => { + const mockContext = createMockOrganizationContext(); + let result: OrganizationContextValue | undefined; + + const TestChild = defineComponent({ + setup() { + result = useOrganization(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [ORGANIZATION_KEY as symbol]: mockContext, + }, + }, + }); + + expect(typeof result!.switchOrganization).toBe('function'); + expect(typeof result!.getAllOrganizations).toBe('function'); + expect(typeof result!.revalidateMyOrganizations).toBe('function'); + }); +}); diff --git a/packages/vue/src/__tests__/composables/useUser.test.ts b/packages/vue/src/__tests__/composables/useUser.test.ts new file mode 100644 index 000000000..7a8fcf64b --- /dev/null +++ b/packages/vue/src/__tests__/composables/useUser.test.ts @@ -0,0 +1,97 @@ +/** + * 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. + */ + +/* eslint-disable sort-keys, @typescript-eslint/typedef, @typescript-eslint/explicit-function-return-type */ +import {mount} from '@vue/test-utils'; +import {describe, expect, it, vi} from 'vitest'; +import {defineComponent, h, shallowRef} from 'vue'; +import useUser from '../../composables/useUser'; +import {USER_KEY} from '../../keys'; +import type {UserContextValue} from '../../models/contexts'; + +function createMockUserContext(overrides: Partial = {}): UserContextValue { + return { + profile: shallowRef(null), + flattenedProfile: shallowRef(null), + schemas: shallowRef([]), + updateProfile: vi.fn(), + revalidateProfile: vi.fn(), + ...overrides, + } as unknown as UserContextValue; +} + +describe('useUser', () => { + it('should return the UserContextValue when called inside a provider', () => { + const mockContext = createMockUserContext(); + let result: UserContextValue | undefined; + + const TestChild = defineComponent({ + setup() { + result = useUser(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [USER_KEY as symbol]: mockContext, + }, + }, + }); + + expect(result).toBeDefined(); + expect(result!.profile.value).toBeNull(); + }); + + it('should throw an error when called outside of AsgardeoProvider', () => { + const TestChild = defineComponent({ + setup() { + useUser(); + return () => h('div', 'test'); + }, + }); + + expect(() => { + mount(TestChild); + }).toThrow('[Asgardeo] useUser() was called outside of '); + }); + + it('should expose updateProfile and revalidateProfile methods', () => { + const mockContext = createMockUserContext(); + let result: UserContextValue | undefined; + + const TestChild = defineComponent({ + setup() { + result = useUser(); + return () => h('div', 'test'); + }, + }); + + mount(TestChild, { + global: { + provide: { + [USER_KEY as symbol]: mockContext, + }, + }, + }); + + expect(typeof result!.updateProfile).toBe('function'); + expect(typeof result!.revalidateProfile).toBe('function'); + }); +}); diff --git a/packages/vue/src/__tests__/plugins/AsgardeoPlugin.test.ts b/packages/vue/src/__tests__/plugins/AsgardeoPlugin.test.ts new file mode 100644 index 000000000..e8120e491 --- /dev/null +++ b/packages/vue/src/__tests__/plugins/AsgardeoPlugin.test.ts @@ -0,0 +1,55 @@ +/** + * 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. + */ + +/* eslint-disable @typescript-eslint/typedef */ +import {describe, expect, it} from 'vitest'; +import {createApp, h} from 'vue'; +import AsgardeoPlugin from '../../plugins/AsgardeoPlugin'; + +describe('AsgardeoPlugin', () => { + it('should be a valid Vue plugin with an install method', () => { + expect(AsgardeoPlugin).toBeDefined(); + expect(typeof AsgardeoPlugin.install).toBe('function'); + }); + + it('should install without errors when given a Vue app', () => { + const app = createApp({ + render() { + return h('div', 'test'); + }, + }); + + expect(() => { + app.use(AsgardeoPlugin); + }).not.toThrow(); + }); + + it('should register AsgardeoProvider as a global component', () => { + const app = createApp({ + render() { + return h('div', 'test'); + }, + }); + + app.use(AsgardeoPlugin); + + // Vue 3 stores global component registrations internally + // We verify the component was registered by checking the app's component resolution + expect(app.component('AsgardeoProvider')).toBeDefined(); + }); +}); diff --git a/packages/vue/src/api/getAllOrganizations.ts b/packages/vue/src/api/getAllOrganizations.ts new file mode 100644 index 000000000..439a1048e --- /dev/null +++ b/packages/vue/src/api/getAllOrganizations.ts @@ -0,0 +1,63 @@ +/** + * 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 { + HttpInstance, + HttpResponse, + AsgardeoSPAClient, + HttpRequestConfig, + getAllOrganizations as baseGetAllOrganizations, + GetAllOrganizationsConfig as BaseGetAllOrganizationsConfig, + AllOrganizationsApiResponse, +} from '@asgardeo/browser'; + +export interface GetAllOrganizationsConfig extends Omit { + fetcher?: (url: string, config: RequestInit) => Promise; + instanceId?: number; +} + +const getAllOrganizations = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: GetAllOrganizationsConfig): Promise => { + const defaultFetcher = async (url: string, config: RequestInit): Promise => { + const client: AsgardeoSPAClient = AsgardeoSPAClient.getInstance(instanceId); + const httpClient: HttpInstance = client.httpRequest.bind(client); + const response: HttpResponse = await httpClient({ + headers: config.headers as Record, + method: config.method || 'GET', + 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 baseGetAllOrganizations({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default getAllOrganizations; diff --git a/packages/vue/src/api/getMeOrganizations.ts b/packages/vue/src/api/getMeOrganizations.ts new file mode 100644 index 000000000..9eba523a9 --- /dev/null +++ b/packages/vue/src/api/getMeOrganizations.ts @@ -0,0 +1,63 @@ +/** + * 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 { + Organization, + HttpInstance, + HttpResponse, + AsgardeoSPAClient, + HttpRequestConfig, + getMeOrganizations as baseGetMeOrganizations, + GetMeOrganizationsConfig as BaseGetMeOrganizationsConfig, +} from '@asgardeo/browser'; + +export interface GetMeOrganizationsConfig extends Omit { + fetcher?: (url: string, config: RequestInit) => Promise; + instanceId?: number; +} + +const getMeOrganizations = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: GetMeOrganizationsConfig): Promise => { + const defaultFetcher = async (url: string, config: RequestInit): Promise => { + const client: AsgardeoSPAClient = AsgardeoSPAClient.getInstance(instanceId); + const httpClient: HttpInstance = client.httpRequest.bind(client); + const response: HttpResponse = await httpClient({ + headers: config.headers as Record, + method: config.method || 'GET', + 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 baseGetMeOrganizations({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default getMeOrganizations; diff --git a/packages/vue/src/api/getSchemas.ts b/packages/vue/src/api/getSchemas.ts new file mode 100644 index 000000000..8f90e270b --- /dev/null +++ b/packages/vue/src/api/getSchemas.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 { + Schema, + HttpInstance, + HttpResponse, + AsgardeoSPAClient, + HttpRequestConfig, + getSchemas as baseGetSchemas, + GetSchemasConfig as BaseGetSchemasConfig, +} from '@asgardeo/browser'; + +export interface GetSchemasConfig extends Omit { + fetcher?: (url: string, config: RequestInit) => Promise; + instanceId?: number; +} + +const getSchemas = async ({fetcher, instanceId = 0, ...requestConfig}: GetSchemasConfig): Promise => { + const defaultFetcher = async (url: string, config: RequestInit): Promise => { + const client: AsgardeoSPAClient = AsgardeoSPAClient.getInstance(instanceId); + const httpClient: HttpInstance = client.httpRequest.bind(client); + const response: HttpResponse = await httpClient({ + headers: config.headers as Record, + method: config.method || 'GET', + 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 baseGetSchemas({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default getSchemas; diff --git a/packages/vue/src/api/getScim2Me.ts b/packages/vue/src/api/getScim2Me.ts new file mode 100644 index 000000000..c518b8655 --- /dev/null +++ b/packages/vue/src/api/getScim2Me.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 { + User, + HttpInstance, + HttpResponse, + AsgardeoSPAClient, + HttpRequestConfig, + getScim2Me as baseGetScim2Me, + GetScim2MeConfig as BaseGetScim2MeConfig, +} from '@asgardeo/browser'; + +export interface GetScim2MeConfig extends Omit { + fetcher?: (url: string, config: RequestInit) => Promise; + instanceId?: number; +} + +const getScim2Me = async ({fetcher, instanceId = 0, ...requestConfig}: GetScim2MeConfig): Promise => { + const defaultFetcher = async (url: string, config: RequestInit): Promise => { + const client: AsgardeoSPAClient = AsgardeoSPAClient.getInstance(instanceId); + const httpClient: HttpInstance = client.httpRequest.bind(client); + const response: HttpResponse = await httpClient({ + headers: config.headers as Record, + method: config.method || 'GET', + 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 baseGetScim2Me({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default getScim2Me; diff --git a/packages/vue/src/auth-api.ts b/packages/vue/src/auth-api.ts deleted file mode 100644 index cd57c4fcb..000000000 --- a/packages/vue/src/auth-api.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * 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 { - AsgardeoAuthException, - AsgardeoSPAClient, - AuthClientConfig, - BasicUserInfo, - Config, - IdToken, - Hooks, - HttpClientInstance, - HttpRequestConfig, - HttpResponse, - OIDCEndpoints, - SignInConfig, - SPACustomGrantConfig, -} from '@asgardeo/auth-spa'; -import {reactive} from 'vue'; -import {AuthStateInterface, AuthVueConfig} from './types'; - -class AuthAPI { - static DEFAULT_STATE: AuthStateInterface; - - private _authState: AuthStateInterface = reactive({...AuthAPI.DEFAULT_STATE}); - - private _client: AsgardeoSPAClient; - - constructor(spaClient?: AsgardeoSPAClient) { - this._client = spaClient ?? AsgardeoSPAClient.getInstance(); - } - - /** - * Method to return Auth Client instance authentication state. - * - * @return {AuthStateInterface} Authentication State. - */ - public getState = (): AuthStateInterface => this._authState; - - /** - * Initializes the AuthClient instance with the given authentication configuration. - * - * @param {AuthClientConfig} config - The authentication configuration object - * containing details such as client ID, redirect URLs, and base URL. - * @returns {Promise} A promise that resolves to `true` if initialization is successful. - */ - public init = (config: AuthVueConfig): Promise => this._client.initialize(config); - - /** - * Handles user sign-in by exchanging the authorization code for tokens - * and updating the authentication state if the user is authenticated. - * - * @param {SignInConfig} config - The sign-in configuration containing client-specific settings. - * @param {string} authorizationCode - The authorization code received from the authentication provider. - * @param {string} sessionState - The session state value to track the authentication session. - * @param {string} [authState] - An optional authentication state parameter for additional tracking. - * @param {{ params: Record }} [tokenRequestConfig] - Optional token request parameters. - * @returns {Promise} A promise resolving to the authenticated user's basic information. - */ - public signIn = async ( - config?: SignInConfig, - authorizationCode?: string, - sessionState?: string, - authState?: string, - callback?: (response: BasicUserInfo) => void, - tokenRequestConfig?: {params: Record}, - ): Promise => - this._client - .signIn(config, authorizationCode, sessionState, authState, tokenRequestConfig) - .then(async (response: BasicUserInfo) => { - if (!response) { - return response; - } - if (await this._client.isSignedIn()) { - Object.assign(this._authState, { - allowedScopes: response.allowedScopes, - displayName: response.displayName, - email: response.email, - isLoading: false, - isSignedIn: true, - isSigningOut: false, - sub: response.sub, - username: response.username, - }); - - if (callback) { - callback(response); - } - } - - return response; - }) - .catch((error: Error) => Promise.reject(error)); - - /** - * Signs the user out and resets the authentication state. - * - * @param {(response?: boolean) => void} callback - An optional callback function to execute after sign-out. - * @returns {Promise} A promise resolving to `true` if sign-out is successful. - * - */ - public signOut = async (callback?: (response?: boolean) => void): Promise => - this._client - .signOut() - .then((response: boolean) => { - if (callback) { - callback(response); - } - return response; - }) - .catch((error: AsgardeoAuthException) => Promise.reject(error)); - - /** - * Method to update Auth Client instance authentication state. - * - * @param {AuthStateInterface} state - State values to update in authentication state. - */ - public updateState(state: AuthStateInterface): void { - this._authState = {...this._authState, ...state}; - } - - /** - * This method returns a Promise that resolves with the basic user information obtained from the ID token. - * - * @return {Promise} a promise that resolves with the user information. - */ - public async getBasicUserInfo(): Promise { - return this._client.getBasicUserInfo(); - } - - /** - * This method returns a Promise that resolves with the basic user information obtained from the ID token. - * - * @return {Promise} a promise that resolves with the user information. - */ - public async getUser(): Promise { - return this._client.getBasicUserInfo(); - } - - /** - * This method sends an API request to a protected endpoint. - * The access token is automatically attached to the header of the request. - * This is the only way by which protected endpoints can be accessed - * when the web worker is used to store session information. - * - * @param {HttpRequestConfig} config - The config object containing attributes necessary to send a request. - * - * @return {Promise} - Returns a Promise that resolves with the response to the request. - */ - public async httpRequest(config: HttpRequestConfig): Promise> { - return this._client.httpRequest(config); - } - - /** - * This method sends multiple API requests to a protected endpoint. - * The access token is automatically attached to the header of the request. - * This is the only way by which multiple requests can be sent to protected endpoints - * when the web worker is used to store session information. - * - * @param {HttpRequestConfig[]} configs - The config object containing attributes necessary to send a request. - * - * @return {Promise} a Promise that resolves with the responses to the requests. - */ - public async httpRequestAll(configs: HttpRequestConfig[]): Promise[]> { - return this._client.httpRequestAll(configs); - } - - /** - * This method allows you to send a request with a custom grant. - * - * @param {CustomGrantRequestParams} config - The request parameters. - * @param {(response: BasicUserInfo | Response) => void} [callback] - An optional callback function. - * - * @return {Promise} a promise that resolves with - * the value returned by the custom grant request. - */ - public exchangeToken( - config: SPACustomGrantConfig, - callback?: (response: BasicUserInfo | Response) => void, - ): Promise { - return this._client - .exchangeToken(config) - .then((response: BasicUserInfo | Response) => { - if (!response) { - return response; - } - - if (config.returnsSession) { - Object.assign(this._authState, { - ...this._authState, - ...(response as BasicUserInfo), - isLoading: false, - isSignedIn: true, - }); - } - if (callback) { - callback(response); - } - return response; - }) - .catch((error: AsgardeoAuthException) => Promise.reject(error)); - } - - /** - * This method ends a user session. The access token is revoked and the session information is destroyed. - * - * @return {Promise} - A promise that resolves with `true` if the process is successful. - */ - public async revokeAccessToken(): Promise { - return this._client - .revokeAccessToken() - .then(() => { - this._authState = {...AuthAPI.DEFAULT_STATE, isLoading: false}; - return true; - }) - .catch((error: AsgardeoAuthException) => Promise.reject(error)); - } - - /** - * This method returns a Promise that resolves with an object containing the service endpoints. - * - * @return {Promise} - A Promise that resolves with an object containing the service endpoints. - */ - public async getOpenIDProviderEndpoints(): Promise { - return this._client.getOpenIDProviderEndpoints(); - } - - /** - * This methods returns the Axios http client. - * - * @return {HttpClientInstance} - The Axios HTTP client. - */ - public async getHttpClient(): Promise { - return this._client.getHttpClient(); - } - - /** - * This method decodes the payload of the id token and returns it. - * - * @return {Promise} - A Promise that resolves with - * the decoded payload of the id token. - */ - public async getDecodedIdToken(): Promise { - return this._client.getDecodedIdToken(); - } - - /** - * This method decodes the payload of the idp id token and returns it. - * @remarks - * This method is intended for retrieving the IdP ID token when extending a plugin. - * - * @return {Promise} - A Promise that resolves with - * the decoded payload of the idp id token. - */ - public async getDecodedIDPIDToken(): Promise { - return this._client.getDecodedIdToken(); - } - - /** - * This method returns the ID token. - * - * @return {Promise} - A Promise that resolves with the id token. - */ - public async getIdToken(): Promise { - return this._client.getIdToken(); - } - - /** - * This method return a Promise that resolves with the access token. - * - * @remarks - * This method will not return the access token if the storage type is set to `webWorker`. - * - * @return {Promise} - A Promise that resolves with the access token. - */ - public getAccessToken = async (): Promise => this._client.getAccessToken(); - - /** - * This method returns a Promise that resolves with the IDP access token. - * - * @remarks - * This method will not return the IDP access token if the storage type is set to `webWorker`. - * It can be used to access the IDP access token when custom authentication grant functionalities are used. - * - * @return {Promise} A Promise that resolves with the IDP access token. - */ - public async getIDPAccessToken(): Promise { - return this._client.getIDPAccessToken(); - } - - /** - * This method refreshes the access token. - * - * @return {BasicUserInfo} - A Promise that resolves with an object containing - * information about the refreshed access token. - */ - public async refreshAccessToken(): Promise { - return this._client.refreshAccessToken(); - } - - /** - * This method specifies if the user is authenticated or not. - * - * @return {Promise} - A Promise that resolves with `true` if the user is authenticated. - */ - public async isSignedIn(): Promise { - return this._client.isSignedIn(); - } - - /** - * This method specifies if the session is active or not. - * - * @return {Promise} - A Promise that resolves with `true` if there is an active session. - */ - public async isSessionActive(): Promise { - return this._client.isSessionActive(); - } - - /** - * This method enables callback functions attached to the http client. - * - * @return {Promise} - A promise that resolves with `true`. - */ - public async enableHttpHandler(): Promise { - return this._client.enableHttpHandler(); - } - - /** - * This method disables callback functions attached to the http client. - * - * @return {Promise} - A promise that resolves with `true`. - */ - public async disableHttpHandler(): Promise { - return this._client.disableHttpHandler(); - } - - /** - * This method updates the configuration that was passed into the constructor when instantiating this class. - * - * @param {Partial>} config - A config object to update the SDK configurations with. - */ - public async reInitialize(config: Partial>): Promise { - return this._client.reInitialize(config); - } - - /** - * This method attaches a callback function to an event hook that fires the callback when the event happens. - * - * @param {Hooks.CustomGrant} hook - The name of the hook. - * @param {(response?: any) => void} callback - The callback function. - * @param {string} id- Optional id for the hook. This is used when multiple custom grants are used. - * - */ - public on(hook: Hooks.CustomGrant, callback: (response?: any) => void, id: string): Promise; - public on(hook: Exclude, callback: (response?: any) => void): Promise; - public on(hook: Hooks, callback: (response?: any) => void, id?: string): Promise { - if (hook === Hooks.CustomGrant) { - return this._client.on(hook, callback, id); - } - - return this._client.on(hook, callback); - } - - /** - * This method allows you to sign in silently. - * First, this method sends a prompt-none request to check for an active user session in the identity provider. - * If a session exists, it retrieves the access token and stores it. Otherwise, it returns `false`. - * - * @param {Record} [additionalParams] - Optional additional parameters to be sent with the request. - * @param {{ params: Record }} [tokenRequestConfig] - Optional configuration for the token request. - * - * @returns {Promise} A Promise that resolves with the user information after signing in, - * or `false` if the user is not signed in. - * - * @example - * ``` - * client.signInSilently(); - * ``` - */ - public async signInSilently( - additionalParams?: Record, - tokenRequestConfig?: {params: Record}, - ): Promise { - return this._client - .signInSilently(additionalParams, tokenRequestConfig) - .then(async (response: BasicUserInfo | boolean) => { - if (!response) { - Object.assign(this._authState, {isLoading: false}); - return false; - } - - if (await this._client.isSignedIn()) { - const basicUserInfo: BasicUserInfo = response as BasicUserInfo; - Object.assign(this._authState, { - allowedScopes: basicUserInfo.allowedScopes, - displayName: basicUserInfo.displayName, - email: basicUserInfo.email, - isLoading: false, - isSignedIn: true, - sub: basicUserInfo.sub, - username: basicUserInfo.username, - }); - } - return response; - }) - .catch((error: AsgardeoAuthException) => Promise.reject(error)); - } -} - -AuthAPI.DEFAULT_STATE = { - allowedScopes: '', - displayName: '', - email: '', - isLoading: true, - isSignedIn: false, - sub: '', - username: '', -}; - -export default AuthAPI; diff --git a/packages/vue/src/components/actions/BaseSignInButton.ts b/packages/vue/src/components/actions/BaseSignInButton.ts new file mode 100644 index 000000000..6ef9b3c70 --- /dev/null +++ b/packages/vue/src/components/actions/BaseSignInButton.ts @@ -0,0 +1,104 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type VNode, defineComponent, h} from 'vue'; +import Button from '../primitives/Button'; + +/** + * BaseSignInButton β€” styled sign-in button with customization support. + * + * By default, renders a styled Button primitive with contents from the slot or fallback text. + * Set `unstyled={true}` to render a plain + * + * ``` + */ +const BaseSignIn: Component = defineComponent({ + emits: ['error', 'success'], + name: 'BaseSignIn', + props: { + additionalData: { + default: (): Record => ({}), + type: Object as PropType>, + }, + buttonClassName: {default: '', type: String}, + className: {default: '', type: String}, + components: { + default: (): EmbeddedFlowComponent[] => [], + type: Array as PropType, + }, + error: {default: null, type: Object as PropType}, + errorClassName: {default: '', type: String}, + inputClassName: {default: '', type: String}, + isLoading: {default: false, type: Boolean}, + isTimeoutDisabled: {default: false, type: Boolean}, + messageClassName: {default: '', type: String}, + size: { + default: 'medium', + type: String as PropType<'small' | 'medium' | 'large'>, + }, + variant: { + default: 'outlined', + type: String as PropType<'elevated' | 'outlined' | 'flat'>, + }, + }, + setup( + props: Readonly<{ + additionalData: Record; + buttonClassName: string; + className: string; + components: EmbeddedFlowComponent[]; + error: Error | null; + errorClassName: string; + inputClassName: string; + isLoading: boolean; + isTimeoutDisabled: boolean; + messageClassName: string; + onSubmit?: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise; + size: 'small' | 'medium' | 'large'; + variant: 'elevated' | 'outlined' | 'flat'; + }>, + {slots, emit, attrs}: SetupContext, + ): () => VNode | null { + const {meta: metaRef} = useFlowMeta(); + const {t} = useI18n(); + const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); + + const isSubmitting: Ref = ref(false); + const apiError: Ref = ref(null); + + const isLoading: ComputedRef = computed(() => props.isLoading || isSubmitting.value); + + // Form state + const formValues: Ref> = ref({}); + const touchedFields: Ref> = ref({}); + + // Reset form state when components change (new flow step) + watch( + () => props.components, + (newComponents: EmbeddedFlowComponent[]) => { + const fields: FieldDefinition[] = extractFormFields(newComponents || []); + const freshValues: Record = {}; + fields.forEach((f: FieldDefinition) => { + freshValues[f.name] = ''; + }); + formValues.value = freshValues; + touchedFields.value = {}; + }, + {deep: false, immediate: true}, + ); + + // Computed form errors based on current values + touched + const formErrors: ComputedRef> = computed>(() => { + const fields: FieldDefinition[] = extractFormFields(props.components || []); + const errors: Record = {}; + fields.forEach((field: FieldDefinition) => { + const value: string = formValues.value[field.name] || ''; + const isTouched: boolean = touchedFields.value[field.name] || false; + if (field.required && isTouched && (!value || value.trim() === '')) { + errors[field.name] = t('validations.required.field.error') || 'This field is required'; + } + if (field.type === 'EMAIL_INPUT' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + errors[field.name] = t('field.email.invalid') || 'Invalid email address'; + } + }); + return errors; + }); + + const isFormValid: ComputedRef = computed(() => Object.keys(formErrors.value).length === 0); + + const handleError = (error: any): void => { + const errorMessage: string = (error as any)?.failureReason || extractErrorMessage(error, t); + apiError.value = error instanceof Error ? error : new Error(errorMessage); + clearMessages(); + addMessage({message: errorMessage, type: 'error'}); + }; + + const handleInputChange = (name: string, value: string): void => { + formValues.value = {...formValues.value, [name]: value}; + }; + + const handleInputBlur = (name: string): void => { + touchedFields.value = {...touchedFields.value, [name]: true}; + }; + + const touchAllFields = (): void => { + const fields: FieldDefinition[] = extractFormFields(props.components || []); + const newTouched: Record = {}; + fields.forEach((f: FieldDefinition) => { + newTouched[f.name] = true; + }); + touchedFields.value = newTouched; + }; + + const validateForm = (): {fieldErrors: Record; isValid: boolean} => { + touchAllFields(); + const errors: Record = formErrors.value; + return {fieldErrors: errors, isValid: Object.keys(errors).length === 0}; + }; + + const handleSubmit = async ( + component: EmbeddedFlowComponent, + data?: Record, + skipValidation?: boolean, + ): Promise => { + if (!skipValidation) { + const {isValid} = validateForm(); + if (!isValid) return; + } + + isSubmitting.value = true; + apiError.value = null; + clearMessages(); + + try { + const filteredInputs: Record = {}; + if (data) { + Object.keys(data).forEach((key: string) => { + if (data[key] !== undefined && data[key] !== null && data[key] !== '') { + filteredInputs[key] = data[key]; + } + }); + } + + const payload: EmbeddedSignInFlowRequest = { + ...((component as any).id ? {action: (component as any).id} : {}), + inputs: filteredInputs, + }; + + await props.onSubmit?.(payload, component); + } catch (err: unknown) { + handleError(err); + emit('error', err); + } finally { + isSubmitting.value = false; + } + }; + + const renderComponents = (): VNode[] => + renderSignInComponents( + props.components || [], + formValues.value, + touchedFields.value, + formErrors.value, + isLoading.value, + isFormValid.value, + handleInputChange, + { + additionalData: props.additionalData, + buttonClassName: props.buttonClassName, + inputClassName: props.inputClassName, + isTimeoutDisabled: props.isTimeoutDisabled, + meta: (metaRef as Ref).value, + onInputBlur: handleInputBlur, + onSubmit: handleSubmit, + size: props.size, + t, + }, + ); + + return (): VNode | null => { + const containerClass: string = [ + withVendorCSSClassPrefix('signin'), + withVendorCSSClassPrefix(`signin--${props.size}`), + withVendorCSSClassPrefix(`signin--${props.variant}`), + props.className, + ] + .filter(Boolean) + .join(' '); + + // If a scoped slot is provided, use render props pattern + if (slots['default']) { + const renderProps: BaseSignInRenderProps = { + components: props.components || [], + error: apiError.value, + fieldErrors: formErrors.value, + handleInputChange, + handleSubmit, + isLoading: isLoading.value, + isTimeoutDisabled: props.isTimeoutDisabled, + isValid: isFormValid.value, + messages: (flowMessages as Ref>).value || [], + meta: (metaRef as Ref).value, + subtitle: (flowSubtitle as Ref).value, + title: (flowTitle as Ref).value || t('signin.heading') || 'Sign In', + touched: touchedFields.value, + validateForm, + values: formValues.value, + }; + return h('div', {class: containerClass, ...attrs}, slots['default'](renderProps)); + } + + // Loading state + if (isLoading.value && (!props.components || props.components.length === 0)) { + return h(Card, {class: containerClass, variant: props.variant}, () => + h('div', {style: 'display:flex;justify-content:center;padding:2rem'}, h(Spinner)), + ); + } + + // No components available + if (!props.components || props.components.length === 0) { + return h(Card, {class: containerClass, variant: props.variant}, () => + h(Alert, {severity: 'warning'}, () => + h( + Typography, + {variant: 'body1'}, + () => t('errors.signin.components.not.available') || 'No sign-in options available', + ), + ), + ); + } + + const messages: Array<{message: string; type: string}> = + (flowMessages as Ref>).value || []; + const externalError: Error | null = props.error; + + return h(Card, {class: containerClass, ...attrs, variant: props.variant}, () => [ + // Show errors and flow messages + (externalError || messages.length > 0) && + h( + 'div', + {class: [withVendorCSSClassPrefix('signin__messages'), props.messageClassName].filter(Boolean).join(' ')}, + [ + externalError && + h(Alert, {severity: 'error'}, () => h(Typography, {variant: 'body2'}, () => externalError.message)), + ...messages.map((msg: {message: string; type: string}, index: number) => + h(Alert, {key: index, severity: msg.type === 'error' ? 'error' : 'info'}, () => + h(Typography, {variant: 'body2'}, () => msg.message), + ), + ), + ], + ), + // Render flow components + h('div', {class: withVendorCSSClassPrefix('signin__content')}, renderComponents()), + ]); + }; + }, +}); + +export default BaseSignIn; diff --git a/packages/vue/src/components/presentation/sign-in/v2/SignIn.ts b/packages/vue/src/components/presentation/sign-in/v2/SignIn.ts new file mode 100644 index 000000000..4275d851c --- /dev/null +++ b/packages/vue/src/components/presentation/sign-in/v2/SignIn.ts @@ -0,0 +1,629 @@ +/** + * 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 { + AsgardeoRuntimeError, + EmbeddedFlowComponentV2 as EmbeddedFlowComponent, + EmbeddedFlowType, + EmbeddedSignInFlowRequestV2, + EmbeddedSignInFlowResponseV2, + EmbeddedSignInFlowStatusV2, + EmbeddedSignInFlowTypeV2, + FlowMetadataResponse, +} from '@asgardeo/browser'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + defineComponent, + h, + onMounted, + onUnmounted, + ref, + watch, +} from 'vue'; +import BaseSignIn from './BaseSignIn'; +import useAsgardeo from '../../../../composables/useAsgardeo'; +import useFlowMeta from '../../../../composables/useFlowMeta'; +import useI18n from '../../../../composables/useI18n'; +import {useOAuthCallback} from '../../../../composables/useOAuthCallback'; +import {initiateOAuthRedirect} from '../../../../utils/oauth'; +import {normalizeFlowResponse} from '../../../../utils/v2/flowTransformer'; +import {handlePasskeyAuthentication, handlePasskeyRegistration} from '../../../../utils/v2/passkey'; + +const FLOW_ID_STORAGE_KEY: string = 'asgardeo_flow_id'; +const AUTH_ID_STORAGE_KEY: string = 'asgardeo_auth_id'; + +interface PasskeyState { + actionId: string | null; + challenge: string | null; + creationOptions: string | null; + error: Error | null; + flowId: string | null; + isActive: boolean; +} + +/** + * Render props passed to the default scoped slot for custom UI rendering. + */ +export interface SignInRenderProps { + additionalData?: Record; + components: EmbeddedFlowComponent[]; + error: Error | null; + initialize: () => Promise; + isInitialized: boolean; + isLoading: boolean; + isTimeoutDisabled?: boolean; + meta: FlowMetadataResponse | null; + onSubmit: (payload: EmbeddedSignInFlowRequestV2) => Promise; +} + +/** + * SignIn β€” app-native sign-in component with full flow lifecycle management. + * + * Initializes the authentication flow, handles passkey authentication/registration, + * OAuth redirect flows, and renders the UI via `BaseSignIn` or a scoped slot. + * + * @example + * ```vue + * + * + * + * + * + * + * + * ``` + */ +const SignIn: Component = defineComponent({ + emits: ['error', 'success'], + name: 'SignIn', + props: { + className: {default: '', type: String}, + size: { + default: 'medium', + type: String as PropType<'small' | 'medium' | 'large'>, + }, + variant: { + default: 'outlined', + type: String as PropType<'elevated' | 'outlined' | 'flat'>, + }, + }, + setup( + props: Readonly<{className: string; size: 'small' | 'medium' | 'large'; variant: 'elevated' | 'outlined' | 'flat'}>, + {slots, emit, attrs}: SetupContext, + ): () => VNode | null { + const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading: sdkLoading} = useAsgardeo(); + const {meta: flowMeta} = useFlowMeta(); + const {t} = useI18n(); + + // Flow state + const components: Ref = ref([]); + const additionalData: Ref> = ref({}); + const currentFlowId: Ref = ref(null); + const isFlowInitialized: Ref = ref(false); + const flowError: Ref = ref(null); + const isSubmitting: Ref = ref(false); + const isTimeoutDisabled: Ref = ref(false); + const passkeyState: Ref = ref({ + actionId: null, + challenge: null, + creationOptions: null, + error: null, + flowId: null, + isActive: false, + }); + + // Track one-time initialization and OAuth processing + let initializationAttempted: boolean = false; + const oauthCodeProcessedFlag: {value: boolean} = {value: false}; + let passkeyProcessed: boolean = false; + + // ── Helpers ────────────────────────────────────────────────────────── + + const persistFlowId = (flowId: string | null): void => { + currentFlowId.value = flowId; + if (flowId) { + sessionStorage.setItem(FLOW_ID_STORAGE_KEY, flowId); + } else { + sessionStorage.removeItem(FLOW_ID_STORAGE_KEY); + } + }; + + const clearFlowState = (): void => { + persistFlowId(null); + isFlowInitialized.value = false; + sessionStorage.removeItem(AUTH_ID_STORAGE_KEY); + isTimeoutDisabled.value = false; + oauthCodeProcessedFlag.value = false; + }; + + interface UrlParams { + applicationId: string | null; + authId: string | null; + code: string | null; + error: string | null; + errorDescription: string | null; + flowId: string | null; + nonce: string | null; + state: string | null; + } + + const getUrlParams = (): UrlParams => { + const params: URLSearchParams = new URLSearchParams(window?.location?.search ?? ''); + return { + applicationId: params.get('applicationId'), + authId: params.get('authId'), + code: params.get('code'), + error: params.get('error'), + errorDescription: params.get('error_description'), + flowId: params.get('flowId'), + nonce: params.get('nonce'), + state: params.get('state'), + }; + }; + + const cleanupOAuthUrlParams = (): void => { + if (!window?.location?.href) return; + const url: URL = new URL(window.location.href); + ['error', 'error_description', 'code', 'state', 'nonce'].forEach((p: string) => url.searchParams.delete(p)); + window.history.replaceState({}, '', url.toString()); + }; + + const cleanupFlowUrlParams = (): void => { + if (!window?.location?.href) return; + const url: URL = new URL(window.location.href); + ['flowId', 'authId', 'applicationId'].forEach((p: string) => url.searchParams.delete(p)); + window.history.replaceState({}, '', url.toString()); + }; + + const setError = (error: Error): void => { + flowError.value = error; + isFlowInitialized.value = true; + emit('error', error); + }; + + // ── Flow initialization ─────────────────────────────────────────────── + + const initializeFlow = async (): Promise => { + const urlParams: UrlParams = getUrlParams(); + + oauthCodeProcessedFlag.value = false; + + if (urlParams.authId) { + sessionStorage.setItem(AUTH_ID_STORAGE_KEY, urlParams.authId); + } + + const effectiveApplicationId: string | null | undefined = + (applicationId as string | undefined) || urlParams.applicationId; + + if (!urlParams.flowId && !effectiveApplicationId) { + const err: AsgardeoRuntimeError = new AsgardeoRuntimeError( + 'Either flowId or applicationId is required for authentication', + 'SIGN_IN_ERROR', + 'vue', + ); + setError(err); + throw err; + } + + try { + flowError.value = null; + + let response: EmbeddedSignInFlowResponseV2; + + if (urlParams.flowId) { + response = (await signIn({flowId: urlParams.flowId})) as EmbeddedSignInFlowResponseV2; + } else { + response = (await signIn({ + applicationId: effectiveApplicationId, + flowType: EmbeddedFlowType.Authentication, + })) as EmbeddedSignInFlowResponseV2; + } + + // Handle OAuth redirect types + if (response.type === EmbeddedSignInFlowTypeV2.Redirection) { + const redirectURL: string | undefined = (response.data as any)?.redirectURL || (response as any)?.redirectURL; + if (redirectURL && window?.location) { + if (response.flowId) persistFlowId(response.flowId); + if (urlParams.authId) sessionStorage.setItem(AUTH_ID_STORAGE_KEY, urlParams.authId); + initiateOAuthRedirect(redirectURL); + return; + } + } + + const { + flowId: normalizedFlowId, + components: normalizedComponents, + additionalData: normalizedAdditionalData, + } = normalizeFlowResponse(response, t, {resolveTranslations: false}, flowMeta.value); + + if (normalizedFlowId && normalizedComponents) { + persistFlowId(normalizedFlowId); + components.value = normalizedComponents; + additionalData.value = normalizedAdditionalData ?? {}; + isFlowInitialized.value = true; + isTimeoutDisabled.value = false; + cleanupFlowUrlParams(); + } + } catch (error: unknown) { + const err: any = error as any; + clearFlowState(); + const errorMessage: string = err?.failureReason || (err instanceof Error ? err.message : String(err)); + setError(new Error(errorMessage)); + initializationAttempted = false; + } + }; + + // ── Submit handler ──────────────────────────────────────────────────── + + const handleSubmit = async (payload: EmbeddedSignInFlowRequestV2): Promise => { + const effectiveFlowId: string | null = payload.flowId || currentFlowId.value; + + if (!effectiveFlowId) { + throw new Error('No active flow ID'); + } + + const processedInputs: Record = {...payload.inputs}; + + // Auto-compile consent decisions if on a consent prompt step + if (additionalData.value?.['consentPrompt']) { + try { + const consentRaw: any = additionalData.value['consentPrompt']; + const purposes: any[] = + typeof consentRaw === 'string' ? JSON.parse(consentRaw) : consentRaw.purposes || consentRaw; + + let isDeny: boolean = false; + if (payload.action) { + const findAction = (comps: any[]): any => { + if (!comps?.length) return null; + const found: any = comps.find((c: any) => c.id === payload.action); + if (found) return found; + return comps.reduce((acc: any, c: any) => acc || (c.components ? findAction(c.components) : null), null); + }; + const submitAction: any = findAction(components.value as any[]); + if (submitAction && submitAction.variant?.toLowerCase() !== 'primary') { + isDeny = true; + } + } + + const decisions: Record = { + purposes: purposes.map((p: any) => ({ + approved: !isDeny, + elements: [ + ...(p.essential || []).map((attr: string) => ({approved: !isDeny, name: attr})), + ...(p.optional || []).map((attr: string) => { + const key: string = `__consent_opt__${p.purpose_id}__${attr}`; + return {approved: isDeny ? false : processedInputs[key] !== 'false', name: attr}; + }), + ], + purpose_name: p.purpose_name, + })), + }; + processedInputs['consent_decisions'] = JSON.stringify(decisions); + + Object.keys(processedInputs).forEach((key: string) => { + if (key.startsWith('__consent_opt__')) delete processedInputs[key]; + }); + } catch { + // Ignore consent construction failures + } + } + + try { + isSubmitting.value = true; + flowError.value = null; + + const response: EmbeddedSignInFlowResponseV2 = (await signIn({ + flowId: effectiveFlowId, + ...payload, + inputs: processedInputs, + })) as EmbeddedSignInFlowResponseV2; + + // Handle OAuth redirect + if (response.type === EmbeddedSignInFlowTypeV2.Redirection) { + const redirectURL: string | undefined = (response.data as any)?.redirectURL || (response as any)?.redirectURL; + if (redirectURL && window?.location) { + if (response.flowId) persistFlowId(response.flowId); + const urlParams: UrlParams = getUrlParams(); + if (urlParams.authId) sessionStorage.setItem(AUTH_ID_STORAGE_KEY, urlParams.authId); + initiateOAuthRedirect(redirectURL); + return; + } + } + + // Handle passkey challenge in response + if ( + response.data?.additionalData?.['passkeyChallenge'] || + response.data?.additionalData?.['passkeyCreationOptions'] + ) { + const {passkeyChallenge, passkeyCreationOptions} = response.data.additionalData as any; + passkeyProcessed = false; + passkeyState.value = { + actionId: 'submit', + challenge: passkeyChallenge || null, + creationOptions: passkeyCreationOptions || null, + error: null, + flowId: response.flowId || effectiveFlowId, + isActive: true, + }; + isSubmitting.value = false; + return; + } + + const { + flowId: normalizedFlowId, + components: normalizedComponents, + additionalData: normalizedAdditionalData, + } = normalizeFlowResponse(response, t, {resolveTranslations: false}, flowMeta.value); + + // Handle error flow status + if (response.flowStatus === EmbeddedSignInFlowStatusV2.Error) { + clearFlowState(); + const failureReason: string = + (response as any)?.failureReason || 'Authentication flow failed. Please try again.'; + const err: Error = new Error(failureReason); + setError(err); + cleanupFlowUrlParams(); + throw err; + } + + // Handle flow completion + if (response.flowStatus === EmbeddedSignInFlowStatusV2.Complete) { + const redirectUrl: string | undefined = (response as any)?.redirectUrl || (response as any)?.redirect_uri; + const finalRedirectUrl: string | undefined = redirectUrl || afterSignInUrl; + + isSubmitting.value = false; + persistFlowId(null); + isFlowInitialized.value = false; + sessionStorage.removeItem(AUTH_ID_STORAGE_KEY); + cleanupOAuthUrlParams(); + + emit('success', { + redirectUrl: finalRedirectUrl, + ...(response.data || {}), + }); + + if (finalRedirectUrl && window?.location) { + window.location.href = finalRedirectUrl; + } + return; + } + + // Update flow state for next step + if (normalizedFlowId && normalizedComponents) { + persistFlowId(normalizedFlowId); + components.value = normalizedComponents; + additionalData.value = normalizedAdditionalData ?? {}; + isTimeoutDisabled.value = false; + isFlowInitialized.value = true; + cleanupFlowUrlParams(); + + if ((response as any)?.failureReason) { + flowError.value = new Error((response as any).failureReason); + } + } + } catch (error: unknown) { + const err: any = error as any; + if (err instanceof Error && flowError.value === err) { + // Already set; re-throw + throw err; + } + clearFlowState(); + const errorMessage: string = err?.failureReason || (err instanceof Error ? err.message : String(err)); + setError(new Error(errorMessage)); + } finally { + isSubmitting.value = false; + } + }; + + // ── Step timeout ────────────────────────────────────────────────────── + + let timeoutHandle: ReturnType | null = null; + + const scheduleTimeout = (timeoutMs: number): void => { + if (timeoutHandle) clearTimeout(timeoutHandle); + if (timeoutMs <= 0 || !isFlowInitialized.value) { + isTimeoutDisabled.value = false; + return; + } + const remaining: number = Math.max(0, Math.floor((timeoutMs - Date.now()) / 1000)); + if (remaining <= 0) { + isTimeoutDisabled.value = true; + setError(new Error(t('errors.signin.timeout') || 'Time allowed to complete the step has expired.')); + return; + } + timeoutHandle = setTimeout(() => { + isTimeoutDisabled.value = true; + setError(new Error(t('errors.signin.timeout') || 'Time allowed to complete the step has expired.')); + }, remaining * 1000); + }; + + watch( + () => [additionalData.value?.['stepTimeout'], isFlowInitialized.value] as [number | undefined, boolean], + ([timeoutMs]: [number | undefined, boolean]) => { + scheduleTimeout(Number(timeoutMs) || 0); + }, + ); + + onUnmounted(() => { + if (timeoutHandle) clearTimeout(timeoutHandle); + }); + + // ── Passkey processing ──────────────────────────────────────────────── + + watch( + () => passkeyState.value, + async (state: PasskeyState) => { + if (!state.isActive || (!state.challenge && !state.creationOptions) || !state.flowId) return; + if (passkeyProcessed) return; + passkeyProcessed = true; + + try { + let inputs: Record; + + if (state.challenge) { + const passkeyResponse: string = await handlePasskeyAuthentication(state.challenge); + const obj: any = JSON.parse(passkeyResponse); + inputs = { + authenticatorData: obj.response.authenticatorData, + clientDataJSON: obj.response.clientDataJSON, + credentialId: obj.id, + signature: obj.response.signature, + userHandle: obj.response.userHandle, + }; + } else if (state.creationOptions) { + const passkeyResponse: string = await handlePasskeyRegistration(state.creationOptions); + const obj: any = JSON.parse(passkeyResponse); + inputs = { + attestationObject: obj.response.attestationObject, + clientDataJSON: obj.response.clientDataJSON, + credentialId: obj.id, + }; + } else { + throw new Error('No passkey challenge or creation options available'); + } + + await handleSubmit({flowId: state.flowId!, inputs}); + + passkeyState.value = { + actionId: null, + challenge: null, + creationOptions: null, + error: null, + flowId: null, + isActive: false, + }; + } catch (error: unknown) { + const err: Error = error as Error; + passkeyState.value = {...passkeyState.value, error: err, isActive: false}; + flowError.value = err; + emit('error', err); + } + }, + {deep: true}, + ); + + // ── OAuth callback (via composable) ───────────────────────────────── + + useOAuthCallback({ + currentFlowId, + flowIdStorageKey: FLOW_ID_STORAGE_KEY, + isInitialized, + isSubmitting, + onError: (err: any) => { + // Guard against double-processing when handleSubmit already set the error + if (!flowError.value) { + clearFlowState(); + setError(err instanceof Error ? err : new Error(String(err))); + } + }, + onSubmit: (payload: EmbeddedSignInFlowRequestV2) => + handleSubmit({flowId: payload.flowId, inputs: payload.inputs}), + processedFlag: oauthCodeProcessedFlag, + setFlowId: persistFlowId, + }); + + // ── Lifecycle ───────────────────────────────────────────────────────── + + onMounted(() => { + const urlParams: UrlParams = getUrlParams(); + + if (urlParams.authId) { + sessionStorage.setItem(AUTH_ID_STORAGE_KEY, urlParams.authId); + } + }); + + // Initialize flow when SDK is ready (OAuth callback is handled by useOAuthCallback) + watch( + () => + [isInitialized.value, sdkLoading.value, isFlowInitialized.value, currentFlowId.value, isSubmitting.value] as [ + boolean, + boolean, + boolean, + string | null, + boolean, + ], + ([initialized, loading, flowInit, flowId, submitting]: [boolean, boolean, boolean, string | null, boolean]) => { + const urlParams: UrlParams = getUrlParams(); + const hasOAuthCode: boolean = !!urlParams.code; + const hasOAuthState: boolean = !!urlParams.state; + + // Initialize flow when SDK is ready and no flow is active + if ( + initialized && + !loading && + !flowInit && + !initializationAttempted && + !flowId && + !hasOAuthCode && + !hasOAuthState && + !submitting && + !oauthCodeProcessedFlag.value + ) { + initializationAttempted = true; + initializeFlow(); + } + }, + ); + + // ── Render ──────────────────────────────────────────────────────────── + + return (): VNode | null => { + const combinedIsLoading: boolean = sdkLoading.value || isSubmitting.value || !isInitialized.value; + + // Scoped slot / render props pattern + if (slots['default']) { + const renderProps: SignInRenderProps = { + additionalData: additionalData.value, + components: components.value, + error: flowError.value, + initialize: initializeFlow, + isInitialized: isFlowInitialized.value, + isLoading: combinedIsLoading, + isTimeoutDisabled: isTimeoutDisabled.value, + meta: flowMeta.value, + onSubmit: handleSubmit, + }; + return h('div', {}, slots['default'](renderProps)); + } + + // Default BaseSignIn rendering + return h(BaseSignIn, { + ...attrs, + additionalData: additionalData.value, + class: props.className, + components: components.value, + error: flowError.value, + isLoading: combinedIsLoading || !isFlowInitialized.value, + isTimeoutDisabled: isTimeoutDisabled.value, + onError: (err: Error) => emit('error', err), + onSubmit: handleSubmit, + size: props.size, + variant: props.variant, + }); + }; + }, +}); + +export default SignIn; diff --git a/packages/vue/src/components/presentation/sign-up/BaseSignUp.ts b/packages/vue/src/components/presentation/sign-up/BaseSignUp.ts new file mode 100644 index 000000000..d3de95730 --- /dev/null +++ b/packages/vue/src/components/presentation/sign-up/BaseSignUp.ts @@ -0,0 +1,692 @@ +/** + * 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 { + EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, + EmbeddedFlowExecuteRequestPayload, + EmbeddedFlowExecuteResponse, + EmbeddedFlowResponseType, + EmbeddedFlowStatus, + FlowMetadataResponse, + withVendorCSSClassPrefix, +} from '@asgardeo/browser'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + defineComponent, + h, + ref, + watch, +} from 'vue'; +import useFlowMeta from '../../../composables/useFlowMeta'; +import useI18n from '../../../composables/useI18n'; +import {createVueLogger} from '../../../utils/logger'; +import {normalizeFlowResponse, extractErrorMessage} from '../../../utils/v2/flowTransformer'; +import getAuthComponentHeadings from '../../../utils/v2/getAuthComponentHeadings'; +import {handlePasskeyRegistration} from '../../../utils/v2/passkey'; +import Alert from '../../primitives/Alert'; +import Card from '../../primitives/Card'; +import Spinner from '../../primitives/Spinner'; +import Typography from '../../primitives/Typography'; +import {renderSignUpComponents} from '../sign-in/AuthOptionFactory'; + +const logger: ReturnType = createVueLogger('BaseSignUp'); + +/** + * Passkey registration tracking state. + */ +interface PasskeyState { + actionId: string | null; + creationOptions: string | null; + error: Error | null; + flowId: string | null; + isActive: boolean; +} + +/** + * Render props passed to the default scoped slot. + */ +export interface BaseSignUpRenderProps { + components: any[]; + error?: Error | null; + fieldErrors: Record; + handleInputChange: (name: string, value: string) => void; + handleSubmit: (component: any, data?: Record) => Promise; + isLoading: boolean; + isValid: boolean; + messages: Array<{message: string; type: string}>; + subtitle: string; + title: string; + touched: Record; + validateForm: () => {fieldErrors: Record; isValid: boolean}; + values: Record; +} + +export interface BaseSignUpProps { + afterSignUpUrl?: string; + buttonClassName?: string; + className?: string; + error?: Error | null; + errorClassName?: string; + inputClassName?: string; + isInitialized?: boolean; + messageClassName?: string; + onComplete?: (response: EmbeddedFlowExecuteResponse) => void; + onError?: (error: Error) => void; + onFlowChange?: (response: EmbeddedFlowExecuteResponse) => void; + onInitialize?: (payload?: EmbeddedFlowExecuteRequestPayload) => Promise; + onSubmit?: (payload: EmbeddedFlowExecuteRequestPayload) => Promise; + shouldRedirectAfterSignUp?: boolean; + showLogo?: boolean; + showSubtitle?: boolean; + showTitle?: boolean; + size?: 'small' | 'medium' | 'large'; + variant?: 'elevated' | 'outlined' | 'flat'; +} + +interface FieldDefinition { + name: string; + required: boolean; + type: string; +} + +const extractFormFields = (components: any[]): FieldDefinition[] => { + const fields: FieldDefinition[] = []; + const process = (comps: any[]): void => { + comps.forEach((c: any) => { + if ( + c.type === EmbeddedFlowComponentType.TextInput || + c.type === EmbeddedFlowComponentType.PasswordInput || + c.type === EmbeddedFlowComponentType.EmailInput || + c.type === EmbeddedFlowComponentType.Select + ) { + const fieldName: string = c.ref || c.id; + fields.push({name: fieldName, required: c.required || false, type: c.type}); + } + if (c.components && Array.isArray(c.components)) { + process(c.components); + } + }); + }; + process(components); + return fields; +}; + +/** + * BaseSignUp β€” app-native sign-up presentation component. + * + * Manages the sign-up flow lifecycle including initialization, form state, + * passkey registration, popup-based social OAuth, and renders the server-driven UI. + */ +const BaseSignUp: Component = defineComponent({ + emits: ['error', 'complete', 'flowChange'], + name: 'BaseSignUp', + props: { + afterSignUpUrl: {default: undefined, type: String}, + buttonClassName: {default: '', type: String}, + className: {default: '', type: String}, + error: {default: null, type: Object as PropType}, + errorClassName: {default: '', type: String}, + inputClassName: {default: '', type: String}, + isInitialized: {default: false, type: Boolean}, + messageClassName: {default: '', type: String}, + onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>}, + onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, + onFlowChange: { + default: undefined, + type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>, + }, + onInitialize: { + default: undefined, + type: Function as PropType<(payload?: EmbeddedFlowExecuteRequestPayload) => Promise>, + }, + onSubmit: { + default: undefined, + type: Function as PropType<(payload: EmbeddedFlowExecuteRequestPayload) => Promise>, + }, + showSubtitle: {default: true, type: Boolean}, + showTitle: {default: true, type: Boolean}, + size: { + default: 'medium', + type: String as PropType<'small' | 'medium' | 'large'>, + }, + variant: { + default: 'outlined', + type: String as PropType<'elevated' | 'outlined' | 'flat'>, + }, + }, + setup(props: any, {slots}: SetupContext): () => VNode | null { + const {meta: flowMetaRef} = useFlowMeta(); + const {t} = useI18n(); + + // ── State ── + const isLoading: Ref = ref(false); + const isFlowInitialized: Ref = ref(false); + const currentFlow: Ref = ref(null); + const apiError: Ref = ref(null); + const flowMessages: Ref> = ref([]); + const passkeyState: Ref = ref({ + actionId: null, + creationOptions: null, + error: null, + flowId: null, + isActive: false, + }); + + // Form state + const formValues: Ref> = ref({}); + const touchedFields: Ref> = ref({}); + const formErrors: Ref> = ref({}); + const isFormValid: Ref = ref(true); + + // One-time flags (plain mutable, not reactive) + let initializationAttempted: boolean = false; + let passkeyProcessed: boolean = false; + + // ── Helpers ── + + const handleError = (error: any): void => { + const errorMessage: string = error?.failureReason || extractErrorMessage(error, t); + apiError.value = error instanceof Error ? error : new Error(errorMessage); + flowMessages.value = [{message: errorMessage, type: 'error'}]; + }; + + const normalizeFlowResponseLocal = (response: EmbeddedFlowExecuteResponse): EmbeddedFlowExecuteResponse => { + if (response?.data?.components && Array.isArray(response.data.components)) { + return response; + } + if (response?.data) { + const {components} = normalizeFlowResponse( + response, + t, + {defaultErrorKey: 'components.signUp.errors.generic', resolveTranslations: false}, + (flowMetaRef as Ref).value, + ); + return {...response, data: {...response.data, components: components as any}}; + } + return response; + }; + + const setupFormFields = (flowResponse: EmbeddedFlowExecuteResponse): void => { + const fields: FieldDefinition[] = extractFormFields(flowResponse.data?.components || []); + const initialValues: Record = {}; + fields.forEach((f: FieldDefinition) => { + initialValues[f.name] = ''; + }); + formValues.value = initialValues; + touchedFields.value = {}; + formErrors.value = {}; + isFormValid.value = true; + }; + + const computeFormErrors = (): Record => { + const components: any[] = currentFlow.value?.data?.components || []; + const fields: FieldDefinition[] = extractFormFields(components); + const errors: Record = {}; + fields.forEach((field: FieldDefinition) => { + const value: string = formValues.value[field.name] || ''; + if (field.required && (!value || value.trim() === '')) { + errors[field.name] = t('validations.required.field.error') || 'This field is required'; + } + if ( + (field.type === EmbeddedFlowComponentType.EmailInput || field.type === 'EMAIL') && + value && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + ) { + errors[field.name] = t('field.email.invalid') || 'Invalid email address'; + } + }); + return errors; + }; + + const touchAllFields = (): void => { + const fields: FieldDefinition[] = extractFormFields(currentFlow.value?.data?.components || []); + const newTouched: Record = {}; + fields.forEach((f: FieldDefinition) => { + newTouched[f.name] = true; + }); + touchedFields.value = newTouched; + }; + + const validateForm = (): {fieldErrors: Record; isValid: boolean} => { + touchAllFields(); + const errors: Record = computeFormErrors(); + formErrors.value = errors; + const valid: boolean = Object.keys(errors).length === 0; + isFormValid.value = valid; + return {fieldErrors: errors, isValid: valid}; + }; + + // ── Input handlers ── + + const handleInputChange = (name: string, value: string): void => { + formValues.value = {...formValues.value, [name]: value}; + }; + + const handleInputBlur = (name: string): void => { + touchedFields.value = {...touchedFields.value, [name]: true}; + }; + + // ── Popup OAuth for social sign-up ── + + const handleRedirectionIfNeeded = (response: EmbeddedFlowExecuteResponse): boolean => { + if (response?.type !== EmbeddedFlowResponseType.Redirection || !response?.data?.redirectURL) { + return false; + } + + const redirectUrl: string = response.data.redirectURL; + const popup: Window | null = window.open( + redirectUrl, + 'oauth_popup', + 'width=500,height=600,scrollbars=yes,resizable=yes', + ); + + if (!popup) { + logger.error('Failed to open popup window'); + return false; + } + + let hasProcessedCallback: boolean = false; + let popupMonitor: ReturnType | null = null; + let messageHandler: ((event: MessageEvent) => Promise) | null = null; + + const cleanup = (): void => { + if (messageHandler) window.removeEventListener('message', messageHandler); + if (popupMonitor) clearInterval(popupMonitor); + }; + + const processOAuthCode = async (code: string, state: string): Promise => { + const payload: EmbeddedFlowExecuteRequestPayload = { + ...(currentFlow.value?.flowId && {flowId: currentFlow.value.flowId}), + action: '', + flowType: (currentFlow.value as any)?.flowType || 'REGISTRATION', + inputs: {code, state}, + } as any; + + try { + const continueResponse: EmbeddedFlowExecuteResponse = await props.onSubmit(payload); + props.onFlowChange?.(continueResponse); + + if (continueResponse.flowStatus === EmbeddedFlowStatus.Complete) { + props.onComplete?.(continueResponse); + } else if (continueResponse.flowStatus === EmbeddedFlowStatus.Incomplete) { + currentFlow.value = continueResponse; + setupFormFields(continueResponse); + } + popup.close(); + cleanup(); + } catch (err) { + handleError(err); + props.onError?.(err as Error); + popup.close(); + cleanup(); + } + }; + + messageHandler = async (event: MessageEvent): Promise => { + if (event.source !== popup) return; + const expectedOrigin: string = props.afterSignUpUrl + ? new URL(props.afterSignUpUrl).origin + : window.location.origin; + if (event.origin !== expectedOrigin && event.origin !== window.location.origin) return; + const {code, state} = event.data; + if (code && state) { + await processOAuthCode(code, state); + } + }; + + window.addEventListener('message', messageHandler); + + popupMonitor = setInterval(async () => { + try { + if (popup.closed) { + cleanup(); + return; + } + if (hasProcessedCallback) return; + try { + const popupUrl: string = popup.location.href; + if (popupUrl && (popupUrl.includes('code=') || popupUrl.includes('error='))) { + hasProcessedCallback = true; + const url: URL = new URL(popupUrl); + const code: string | null = url.searchParams.get('code'); + const state: string | null = url.searchParams.get('state'); + const error: string | null = url.searchParams.get('error'); + + if (error) { + logger.error('OAuth error'); + popup.close(); + cleanup(); + return; + } + if (code && state) { + await processOAuthCode(code, state); + } + } + } catch { + // Cross-origin error expected during OAuth redirect + } + } catch { + logger.error('Error monitoring popup'); + } + }, 1000); + + return true; + }; + + // ── Submit handler ── + + const handleSubmit = async ( + component: any, + data?: Record, + skipValidation?: boolean, + ): Promise => { + if (!currentFlow.value) return; + + if (!skipValidation) { + const validation: {fieldErrors: Record; isValid: boolean} = validateForm(); + if (!validation.isValid) return; + } + + isLoading.value = true; + apiError.value = null; + flowMessages.value = []; + + try { + const filteredInputs: Record = {}; + if (data) { + Object.entries(data).forEach(([key, value]: [string, any]) => { + if (value !== null && value !== undefined && value !== '') { + filteredInputs[key] = value; + } + }); + } + + const payload: EmbeddedFlowExecuteRequestPayload = { + ...(currentFlow.value.flowId && {flowId: currentFlow.value.flowId}), + flowType: (currentFlow.value as any).flowType || 'REGISTRATION', + ...(component.id && {action: component.id}), + inputs: filteredInputs, + } as any; + + const rawResponse: EmbeddedFlowExecuteResponse = await props.onSubmit(payload); + const response: EmbeddedFlowExecuteResponse = normalizeFlowResponseLocal(rawResponse); + props.onFlowChange?.(response); + + if (response.flowStatus === EmbeddedFlowStatus.Complete) { + props.onComplete?.(response); + return; + } + + if (response.flowStatus === EmbeddedFlowStatus.Incomplete) { + if (handleRedirectionIfNeeded(response)) return; + + if (response.data?.additionalData?.['passkeyCreationOptions']) { + const {passkeyCreationOptions} = response.data.additionalData as any; + const effectiveFlowId: string | undefined = response.flowId || currentFlow.value?.flowId; + passkeyProcessed = false; + passkeyState.value = { + actionId: component.id || 'submit', + creationOptions: passkeyCreationOptions, + error: null, + flowId: effectiveFlowId || null, + isActive: true, + }; + isLoading.value = false; + return; + } + + currentFlow.value = response; + setupFormFields(response); + } + } catch (err) { + handleError(err); + props.onError?.(err as Error); + } finally { + isLoading.value = false; + } + }; + + // ── Passkey registration watch ── + + watch( + () => passkeyState.value, + async (state: PasskeyState) => { + if (!state.isActive || !state.creationOptions || !state.flowId) return; + if (passkeyProcessed) return; + passkeyProcessed = true; + + try { + const passkeyResponse: string = await handlePasskeyRegistration(state.creationOptions); + const passkeyObj: any = JSON.parse(passkeyResponse); + const inputs: Record = { + attestationObject: passkeyObj.response.attestationObject, + clientDataJSON: passkeyObj.response.clientDataJSON, + credentialId: passkeyObj.id, + }; + + const payload: EmbeddedFlowExecuteRequestPayload = { + actionId: state.actionId || 'submit', + flowId: state.flowId, + flowType: (currentFlow.value as any)?.flowType || 'REGISTRATION', + inputs, + } as any; + + const nextResponse: EmbeddedFlowExecuteResponse = await props.onSubmit(payload); + const processed: EmbeddedFlowExecuteResponse = normalizeFlowResponseLocal(nextResponse); + props.onFlowChange?.(processed); + + if (processed.flowStatus === EmbeddedFlowStatus.Complete) { + props.onComplete?.(processed); + } else { + currentFlow.value = processed; + setupFormFields(processed); + } + + passkeyState.value = {actionId: null, creationOptions: null, error: null, flowId: null, isActive: false}; + } catch (error: unknown) { + passkeyState.value = {...passkeyState.value, error: error as Error, isActive: false}; + handleError(error); + props.onError?.(error as Error); + } + }, + {deep: true}, + ); + + // ── Flow initialization ── + + watch( + () => [props.isInitialized, isFlowInitialized.value] as [boolean, boolean], + ([initialized, flowInit]: [boolean, boolean]) => { + // Skip if URL has OAuth code params + const urlParams: URLSearchParams = new URL(window.location.href).searchParams; + if (urlParams.get('code') || urlParams.get('state')) return; + + if (initialized && !flowInit && !initializationAttempted) { + initializationAttempted = true; + + (async (): Promise => { + isLoading.value = true; + apiError.value = null; + flowMessages.value = []; + + try { + const rawResponse: EmbeddedFlowExecuteResponse = await props.onInitialize(); + const response: EmbeddedFlowExecuteResponse = normalizeFlowResponseLocal(rawResponse); + currentFlow.value = response; + isFlowInitialized.value = true; + props.onFlowChange?.(response); + + if (response.flowStatus === EmbeddedFlowStatus.Complete) { + props.onComplete?.(response); + return; + } + if (response.flowStatus === EmbeddedFlowStatus.Incomplete) { + setupFormFields(response); + } + } catch (err) { + handleError(err); + props.onError?.(err as Error); + } finally { + isLoading.value = false; + } + })(); + } + }, + {immediate: true}, + ); + + // ── Render ── + + return (): VNode | null => { + const containerClass: string = [ + withVendorCSSClassPrefix('signup'), + withVendorCSSClassPrefix(`signup--${props.size}`), + withVendorCSSClassPrefix(`signup--${props.variant}`), + props.className, + ] + .filter(Boolean) + .join(' '); + + // Scoped slot / render props + if (slots['default']) { + const renderProps: BaseSignUpRenderProps = { + components: currentFlow.value?.data?.components || [], + error: apiError.value, + fieldErrors: formErrors.value, + handleInputChange, + handleSubmit, + isLoading: isLoading.value, + isValid: isFormValid.value, + messages: flowMessages.value, + subtitle: t('signup.subheading') || 'Create your account', + title: t('signup.heading') || 'Sign Up', + touched: touchedFields.value, + validateForm: (): {fieldErrors: Record; isValid: boolean} => { + const result: {fieldErrors: Record; isValid: boolean} = validateForm(); + return {fieldErrors: result.fieldErrors, isValid: result.isValid}; + }, + values: formValues.value, + }; + return h('div', {class: containerClass}, slots['default'](renderProps)); + } + + // Loading state + if (!isFlowInitialized.value && isLoading.value) { + return h(Card, {class: containerClass, variant: props.variant}, () => + h('div', {style: 'display:flex;justify-content:center;padding:2rem'}, h(Spinner)), + ); + } + + // No flow available + if (!currentFlow.value) { + return h(Card, {class: containerClass, variant: props.variant}, () => + h( + Alert, + {variant: 'error'}, + () => t('errors.signup.flow.initialization.failure') || 'Failed to initialize sign-up flow', + ), + ); + } + + // Extract headings + const componentsToRender: any[] = currentFlow.value.data?.components || []; + const {title, subtitle, componentsWithoutHeadings} = getAuthComponentHeadings( + componentsToRender, + undefined, + undefined, + t('signup.heading') || 'Sign Up', + t('signup.subheading') || 'Create your account', + ); + + const meta: FlowMetadataResponse | null = (flowMetaRef as Ref).value; + + const renderedComponents: VNode[] = + componentsWithoutHeadings.length > 0 + ? renderSignUpComponents( + componentsWithoutHeadings, + formValues.value, + touchedFields.value, + formErrors.value, + isLoading.value, + isFormValid.value, + handleInputChange, + { + buttonClassName: props.buttonClassName, + inputClassName: props.inputClassName, + meta, + onInputBlur: handleInputBlur, + onSubmit: handleSubmit, + size: props.size, + t, + variant: props.variant, + }, + ) + : []; + + return h(Card, {class: containerClass, variant: props.variant}, () => [ + // Header with title/subtitle + props.showTitle || props.showSubtitle + ? h('div', {style: 'padding: 1rem 1rem 0'}, [ + props.showTitle ? h(Typography, {variant: 'h5'}, () => title) : null, + props.showSubtitle + ? h(Typography, {style: 'margin-top: 0.25rem', variant: 'body1'}, () => subtitle) + : null, + ]) + : null, + // External error + props.error + ? h( + 'div', + {style: 'padding: 0 1rem'}, + h(Alert, {variant: 'error'}, () => props.error.message), + ) + : null, + // Flow messages + flowMessages.value.length > 0 + ? h( + 'div', + {style: 'padding: 0 1rem'}, + flowMessages.value.map((msg: {message: string; type: string}, i: number) => + h(Alert, {key: i, variant: msg.type === 'error' ? 'error' : 'info'}, () => msg.message), + ), + ) + : null, + // Components + h( + 'div', + {style: 'padding: 1rem'}, + renderedComponents.length > 0 + ? renderedComponents + : [ + h( + Alert, + {variant: 'warning'}, + () => t('errors.signup.components.not.available') || 'No components available', + ), + ], + ), + ]); + }; + }, +}); + +export default BaseSignUp; diff --git a/packages/vue/src/components/presentation/sign-up/SignUp.ts b/packages/vue/src/components/presentation/sign-up/SignUp.ts new file mode 100644 index 000000000..173d33be7 --- /dev/null +++ b/packages/vue/src/components/presentation/sign-up/SignUp.ts @@ -0,0 +1,126 @@ +/** + * 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 { + EmbeddedFlowExecuteRequestPayload, + EmbeddedFlowExecuteResponse, + EmbeddedFlowResponseType, + EmbeddedFlowType, +} from '@asgardeo/browser'; +import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import BaseSignUp from './BaseSignUp'; +import type {BaseSignUpRenderProps} from './BaseSignUp'; +import useAsgardeo from '../../../composables/useAsgardeo'; + +export type SignUpRenderProps = BaseSignUpRenderProps; + +/** + * SignUp β€” embedded sign-up component that handles API calls and delegates UI to BaseSignUp. + */ +const SignUp: Component = defineComponent({ + name: 'SignUp', + props: { + afterSignUpUrl: {default: undefined, type: String}, + buttonClassName: {default: '', type: String}, + className: {default: '', type: String}, + errorClassName: {default: '', type: String}, + inputClassName: {default: '', type: String}, + messageClassName: {default: '', type: String}, + onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>}, + onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, + shouldRedirectAfterSignUp: {default: true, type: Boolean}, + showSubtitle: {default: true, type: Boolean}, + showTitle: {default: true, type: Boolean}, + size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, + variant: {default: 'outlined', type: String as PropType<'elevated' | 'outlined' | 'flat'>}, + }, + setup(props: any, {slots}: SetupContext): () => VNode | null { + const {signUp, isInitialized, applicationId} = useAsgardeo(); + + const handleInitialize = async ( + payload?: EmbeddedFlowExecuteRequestPayload, + ): Promise => { + const urlParams: URLSearchParams = new URL(window.location.href).searchParams; + const applicationIdFromUrl: string | null = urlParams.get('applicationId'); + const effectiveApplicationId: string | undefined = applicationId || applicationIdFromUrl || undefined; + + const initialPayload: any = payload || { + flowType: EmbeddedFlowType.Registration, + ...(effectiveApplicationId && {applicationId: effectiveApplicationId}), + }; + + return (await signUp(initialPayload)) as EmbeddedFlowExecuteResponse; + }; + + const handleOnSubmit = async (payload: EmbeddedFlowExecuteRequestPayload): Promise => + (await signUp(payload)) as EmbeddedFlowExecuteResponse; + + const handleComplete = (response: EmbeddedFlowExecuteResponse): void => { + props.onComplete?.(response); + + const oauthRedirectUrl: string | undefined = (response as any)?.redirectUrl; + if (props.shouldRedirectAfterSignUp && oauthRedirectUrl) { + window.location.href = oauthRedirectUrl; + return; + } + + if ( + props.shouldRedirectAfterSignUp && + response?.type !== EmbeddedFlowResponseType.Redirection && + props.afterSignUpUrl + ) { + window.location.href = props.afterSignUpUrl; + } + + if ( + props.shouldRedirectAfterSignUp && + response?.type === EmbeddedFlowResponseType.Redirection && + response?.data?.redirectURL && + !response.data.redirectURL.includes('oauth') && + !response.data.redirectURL.includes('auth') + ) { + window.location.href = response.data.redirectURL; + } + }; + + return (): VNode | null => + h( + BaseSignUp, + { + afterSignUpUrl: props.afterSignUpUrl, + buttonClassName: props.buttonClassName, + className: props.className, + errorClassName: props.errorClassName, + inputClassName: props.inputClassName, + isInitialized: isInitialized?.value ?? false, + messageClassName: props.messageClassName, + onComplete: handleComplete, + onError: props.onError, + onInitialize: handleInitialize, + onSubmit: handleOnSubmit, + showSubtitle: props.showSubtitle, + showTitle: props.showTitle, + size: props.size, + variant: props.variant, + }, + slots['default'] ? {default: (renderProps: any) => slots['default']!(renderProps)} : undefined, + ); + }, +}); + +export default SignUp; diff --git a/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts b/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts new file mode 100644 index 000000000..0c47c67ff --- /dev/null +++ b/packages/vue/src/components/presentation/user-dropdown/BaseUserDropdown.ts @@ -0,0 +1,176 @@ +/** + * 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 {type User, withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type PropType, type Ref, type VNode, defineComponent, h, ref} from 'vue'; +import {ChevronDownIcon, LogOutIcon, UserIcon, XIcon} from '../../primitives/Icons'; +import Typography from '../../primitives/Typography'; + +export interface BaseUserDropdownProps { + className?: string; + isProfileModalOpen?: boolean; + onProfileClick?: () => void; + onProfileModalClose?: () => void; + onSignOut?: () => void; + profileContent?: VNode | null; + user?: User | null; +} + +/** + * BaseUserDropdown β€” unstyled user dropdown with avatar, profile link, sign-out. + */ +const BaseUserDropdown: Component = defineComponent({ + inheritAttrs: false, + name: 'BaseUserDropdown', + props: { + className: {default: '', type: String}, + isProfileModalOpen: {default: false, type: Boolean}, + 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}, + 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 { + const isOpen: Ref = ref(false); + const prefix: typeof withVendorCSSClassPrefix = withVendorCSSClassPrefix; + + return (): VNode | VNode[] | null => { + if (slots['default']) { + return slots['default']({ + isOpen: isOpen.value, + toggle: () => { + 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', + }, + [ + 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}), + ], + ), + ); + + // Dropdown menu + if (isOpen.value) { + const menuItems: VNode[] = []; + + 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'), + ]), + ); + } + + if (slots['items']) { + menuItems.push(...(slots['items']() ?? [])); + } + + 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'), + ]), + ); + } + + children.push(h('div', {class: prefix('user-dropdown__menu')}, menuItems)); + } + + const container: VNode = h( + 'div', + {class: [prefix('user-dropdown'), props.className].filter(Boolean).join(' ')}, + children, + ); + + // If profile modal is open, render modal overlay + 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, + ]), + ]), + ]); + } + + return container; + }; + }, +}); + +export default BaseUserDropdown; diff --git a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts new file mode 100644 index 000000000..23cf6e01a --- /dev/null +++ b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.css.ts @@ -0,0 +1,211 @@ +/** + * 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. + */ + +/** + * Styles for the UserDropdown presentation component. + * + * 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`. + * + * 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 + */ +const USER_DROPDOWN_CSS: string = ` +/* ============================================================ + UserDropdown + ============================================================ */ + +.asgardeo-user-dropdown { + position: relative; + display: inline-block; + font-family: var(--asgardeo-typography-fontFamily); +} + +/* 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); + background: none; + border: 1px solid var(--asgardeo-color-border); + border-radius: 9999px; + 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; +} + +.asgardeo-user-dropdown__trigger:hover { + background-color: var(--asgardeo-color-action-hover); + border-color: var(--asgardeo-color-primary-main); +} + +.asgardeo-user-dropdown__trigger:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--asgardeo-focus-ring-width) var(--asgardeo-focus-ring-color); +} + +/* 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); + border-radius: 50%; + background-color: var(--asgardeo-color-primary-main); + color: var(--asgardeo-color-primary-contrastText); + flex-shrink: 0; + font-size: var(--asgardeo-typography-fontSize-sm); + font-weight: var(--asgardeo-typography-fontWeight-medium); +} + +/* Name ------------------------------------------------------ */ + +.asgardeo-user-dropdown__name { + max-width: 140px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Dropdown menu --------------------------------------------- */ + +.asgardeo-user-dropdown__menu { + position: absolute; + top: calc(100% + calc(var(--asgardeo-spacing-unit) * 0.5)); + right: 0; + z-index: 1000; + background-color: var(--asgardeo-color-background-surface); + border: 1px solid var(--asgardeo-color-border); + border-radius: var(--asgardeo-dropdown-borderRadius); + box-shadow: var(--asgardeo-dropdown-shadow); + overflow: hidden; + min-width: 160px; + display: flex; + flex-direction: column; + padding: calc(var(--asgardeo-spacing-unit) * 0.5) 0; +} + +/* Menu items ------------------------------------------------ */ + +.asgardeo-user-dropdown__item { + display: flex; + align-items: center; + gap: calc(var(--asgardeo-spacing-unit) * 0.75); + width: 100%; + padding: var(--asgardeo-dropdown-itemPaddingY) var(--asgardeo-dropdown-itemPaddingX); + background: none; + border: none; + cursor: pointer; + text-align: left; + font-family: var(--asgardeo-typography-fontFamily); + font-size: var(--asgardeo-typography-fontSize-sm); + color: var(--asgardeo-color-text-primary); + transition: background-color var(--asgardeo-transition-fast); + box-sizing: border-box; +} + +.asgardeo-user-dropdown__item:hover { + background-color: var(--asgardeo-color-action-hover); +} + +.asgardeo-user-dropdown__item:focus-visible { + outline: none; + background-color: var(--asgardeo-color-action-focus); +} + +/* 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); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + backdrop-filter: blur(2px); +} + +/* Modal content ------------------------------------------------ */ + +.asgardeo-user-dropdown__modal-content { + background: var(--asgardeo-color-background-surface); + border-radius: var(--asgardeo-border-radius-medium); + box-shadow: var(--asgardeo-shadow-large); + max-width: 460px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + position: relative; +} + +/* Modal close button ------------------------------------------ */ + +.asgardeo-user-dropdown__modal-close { + position: absolute; + top: calc(var(--asgardeo-spacing-unit) * 1.25); + right: calc(var(--asgardeo-spacing-unit) * 1.25); + background: none; + border: none; + cursor: pointer; + color: var(--asgardeo-color-text-secondary); + display: flex; + align-items: center; + justify-content: center; + padding: calc(var(--asgardeo-spacing-unit) * 0.5); + border-radius: var(--asgardeo-border-radius-small); + z-index: 10001; + transition: + color var(--asgardeo-transition-fast), + background-color var(--asgardeo-transition-fast); + line-height: 0; +} + +.asgardeo-user-dropdown__modal-close:hover { + color: var(--asgardeo-color-text-primary); + background-color: var(--asgardeo-color-action-hover); +} + +.asgardeo-user-dropdown__modal-close:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--asgardeo-focus-ring-width) var(--asgardeo-focus-ring-color); +} +`; + +export default USER_DROPDOWN_CSS; diff --git a/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts new file mode 100644 index 000000000..eb35e5273 --- /dev/null +++ b/packages/vue/src/components/presentation/user-dropdown/UserDropdown.ts @@ -0,0 +1,73 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type Ref, type VNode, defineComponent, h, ref} from 'vue'; +import BaseUserDropdown from './BaseUserDropdown'; +import useAsgardeo from '../../../composables/useAsgardeo'; +import UserProfileComponent from '../user-profile/UserProfile'; + +/** + * UserDropdown β€” styled user dropdown component. + * + * Retrieves user and signOut from context and delegates to BaseUserDropdown. + */ +const UserDropdown: Component = defineComponent({ + emits: ['profileClick'], + name: 'UserDropdown', + props: { + className: { + default: '', + type: String, + }, + }, + setup(props: {className: string}, {slots, emit}: {emit: any; slots: any}): () => VNode | VNode[] | null { + const {user, signOut} = useAsgardeo(); + const isProfileModalOpen: Ref = ref(false); + + return (): VNode | VNode[] | null => + h( + BaseUserDropdown, + { + class: withVendorCSSClassPrefix('user-dropdown--styled'), + className: props.className, + isProfileModalOpen: isProfileModalOpen.value, + onProfileClick: (): void => { + isProfileModalOpen.value = true; + emit('profileClick'); + }, + onProfileModalClose: (): void => { + isProfileModalOpen.value = false; + }, + onSignOut: (): void => { + signOut(); + }, + profileContent: isProfileModalOpen.value + ? h(UserProfileComponent, { + cardLayout: false, + editable: true, + }) + : null, + user: user.value, + }, + slots, + ); + }, +}); + +export default UserDropdown; diff --git a/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts b/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts new file mode 100644 index 000000000..80dc5585d --- /dev/null +++ b/packages/vue/src/components/presentation/user-profile/BaseUserProfile.ts @@ -0,0 +1,311 @@ +/** + * 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 {type User, type Schema, type UpdateMeProfileConfig, withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type PropType, type Ref, type SetupContext, type VNode, defineComponent, h, ref} from 'vue'; +import Alert from '../../primitives/Alert'; +import Button from '../../primitives/Button'; +import Card from '../../primitives/Card'; +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'; + +export interface BaseUserProfileProps { + cardLayout?: boolean; + className?: string; + editable?: boolean; + error?: string | null; + flattenedProfile?: User; + hideFields?: string[]; + isLoading?: boolean; + onUpdate?: ( + requestConfig: UpdateMeProfileConfig, + sessionId?: string, + ) => Promise<{data: {user: User}; error: string; success: boolean}>; + profile?: User; + schemas?: Schema[]; + 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}; + +const PROFILE_FIELD_DESCRIPTORS: ProfileFieldDescriptor[] = [ + {keys: ['username', 'userName', 'user_name'], label: 'Username', readonly: true}, + {keys: ['firstName', 'givenName'], label: 'First Name', readonly: false}, + {keys: ['lastName', 'familyName'], label: 'Last Name', readonly: false}, + {keys: ['email', 'emails'], label: 'Email', readonly: false}, + {keys: ['country'], label: 'Country', readonly: false}, + {keys: ['birthdate', 'birthDate', 'dateOfBirth'], label: 'Birth Date', readonly: false}, + {keys: ['mobile', 'mobileNumber', 'phoneNumbers'], label: 'Mobile Numbers', readonly: false}, +]; + +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; + } + return AVATAR_GRADIENTS[Math.abs(hash) % AVATAR_GRADIENTS.length]; +}; + +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() || '?'; +}; + +/** + * 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: { + cardLayout: {default: true, type: Boolean}, + className: {default: '', type: String}, + 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}> + >, + }, + profile: {default: null, type: Object as PropType}, + schemas: {default: () => [], type: Array as PropType}, + 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>({}); + + return (): VNode | VNode[] => { + if (slots['default']) { + return slots['default']({ + error: props.error, + isLoading: props.isLoading, + profile: props.flattenedProfile || props.profile, + }); + } + + 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); + + const children: VNode[] = []; + + // Header: title + children.push( + h('div', {class: prefix('user-profile__header')}, [ + h(Typography, {class: prefix('user-profile__title'), variant: 'h5'}, () => props.title), + ]), + ); + + children.push(h(Divider, {class: prefix('user-profile__header-divider')})); + + // Avatar section + children.push( + h('div', {class: prefix('user-profile__avatar-section')}, [ + h( + 'div', + { + class: prefix('user-profile__avatar'), + style: {background: avatarGradient}, + }, + [h('span', {class: prefix('user-profile__avatar-initials')}, initials)], + ), + ]), + ); + + if (props.error) { + children.push(h(Alert, {class: prefix('user-profile__error'), severity: 'error' as const}, () => props.error)); + } + + 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: {[key]: editedValues.value[key]}, + } 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)); + } + + if (slots['footer']) { + children.push(h('div', {class: prefix('user-profile__footer')}, slots['footer']())); + } + + if (props.cardLayout) { + return h(Card, {class: [prefix('user-profile'), props.className].filter(Boolean).join(' ')}, () => children); + } + + return h('div', {class: [prefix('user-profile'), props.className].filter(Boolean).join(' ')}, children); + }; + }, +}); + +export default BaseUserProfile; diff --git a/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts b/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts new file mode 100644 index 000000000..93f6e4adc --- /dev/null +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.css.ts @@ -0,0 +1,215 @@ +/** + * 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. + */ + +/** + * Styles for the UserProfile presentation component. + * + * BEM block: `.asgardeo-user-profile` + */ +const USER_PROFILE_CSS: string = ` +/* ============================================================ + UserProfile + ============================================================ */ + +.asgardeo-user-profile { + display: flex; + flex-direction: column; + min-width: 320px; + padding: 0; + overflow: hidden; +} + +/* 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); +} + +.asgardeo-user-profile__title { + margin: 0; +} + +.asgardeo-user-profile__header-divider { + margin: 0; +} + +/* Avatar ---------------------------------------------------- */ + +.asgardeo-user-profile__avatar-section { + display: flex; + justify-content: center; + padding: calc(var(--asgardeo-spacing-unit) * 2) 0 calc(var(--asgardeo-spacing-unit) * 1.25); +} + +.asgardeo-user-profile__avatar { + width: var(--asgardeo-avatar-size); + height: var(--asgardeo-avatar-size); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.asgardeo-user-profile__avatar-initials { + color: #ffffff; + font-size: var(--asgardeo-avatar-fontSize); + font-weight: 600; + line-height: 1; + letter-spacing: 0.02em; + pointer-events: none; + user-select: none; +} + +/* Alerts & loading ------------------------------------------ */ + +.asgardeo-user-profile__error { + margin: 0 calc(var(--asgardeo-spacing-unit) * 2.5) calc(var(--asgardeo-spacing-unit) * 1.25); +} + +.asgardeo-user-profile__loading { + display: flex; + align-items: center; + justify-content: center; + padding: calc(var(--asgardeo-spacing-unit) * 3) 0; +} + +/* Fields ---------------------------------------------------- */ + +.asgardeo-user-profile__fields { + display: flex; + flex-direction: column; +} + +.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); + gap: calc(var(--asgardeo-spacing-unit) * 0.75); + box-sizing: border-box; + transition: background-color var(--asgardeo-transition-fast); +} + +.asgardeo-user-profile__field:hover { + background-color: var(--asgardeo-color-action-hover); +} + +.asgardeo-user-profile__field + .asgardeo-user-profile__field { + 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 */ +} + +.asgardeo-user-profile__field-display { + display: flex; + align-items: center; + justify-content: space-between; + gap: calc(var(--asgardeo-spacing-unit) * 0.5); + min-height: 1.5rem; +} + +.asgardeo-user-profile__field-value { + color: var(--asgardeo-color-text-primary); + word-break: break-word; + flex: 1; + font-size: var(--asgardeo-typography-fontSize-sm); +} + +.asgardeo-user-profile__field-placeholder { + color: var(--asgardeo-color-primary-main); + font-style: italic; + font-size: var(--asgardeo-typography-fontSize-sm); + flex: 1; + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; +} + +/* Edit button (pencil icon) --------------------------------- */ + +.asgardeo-user-profile__field-edit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + color: var(--asgardeo-color-text-secondary); + flex-shrink: 0; + padding: calc(var(--asgardeo-spacing-unit) * 0.375); + border-radius: var(--asgardeo-border-radius-small); + transition: + color var(--asgardeo-transition-fast), + background-color var(--asgardeo-transition-fast), + opacity var(--asgardeo-transition-fast); + opacity: 0; + line-height: 0; +} + +.asgardeo-user-profile__field:hover .asgardeo-user-profile__field-edit-btn { + opacity: 1; +} + +.asgardeo-user-profile__field-edit-btn:hover { + color: var(--asgardeo-color-primary-main); + background-color: var(--asgardeo-color-primary-light); +} + +.asgardeo-user-profile__field-edit-btn:focus-visible { + opacity: 1; + outline: none; + box-shadow: 0 0 0 var(--asgardeo-focus-ring-width) var(--asgardeo-focus-ring-color); +} + +/* Edit mode ------------------------------------------------- */ + +.asgardeo-user-profile__field-edit { + display: flex; + flex-direction: column; + gap: calc(var(--asgardeo-spacing-unit) * 0.75); +} + +.asgardeo-user-profile__field-edit-actions { + display: flex; + align-items: center; + gap: calc(var(--asgardeo-spacing-unit) * 0.75); +} + +/* 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); +} +`; + +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 new file mode 100644 index 000000000..98db5c005 --- /dev/null +++ b/packages/vue/src/components/presentation/user-profile/UserProfile.ts @@ -0,0 +1,72 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import BaseUserProfile from './BaseUserProfile'; +import useUser from '../../../composables/useUser'; + +/** + * UserProfile β€” styled user profile component. + * + * Retrieves user profile data from context and delegates to BaseUserProfile. + */ + +type UserProfileProps = Readonly<{ + cardLayout: boolean; + className: string; + editable: boolean; + hideFields: string[]; + showFields: string[]; + title: string; +}>; + +const UserProfile: Component = defineComponent({ + name: 'UserProfile', + props: { + cardLayout: {default: true, type: Boolean}, + className: {default: '', type: String}, + editable: {default: true, type: Boolean}, + hideFields: {default: () => [], type: Array as PropType}, + showFields: {default: () => [], type: Array as PropType}, + title: {default: 'Profile', type: String}, + }, + setup(props: UserProfileProps, {slots}: SetupContext): () => VNode { + const {flattenedProfile, schemas, updateProfile} = useUser(); + + return (): VNode => + h( + BaseUserProfile, + { + cardLayout: props.cardLayout, + class: withVendorCSSClassPrefix('user-profile--styled'), + className: props.className, + editable: props.editable, + flattenedProfile: flattenedProfile?.value, + hideFields: props.hideFields, + onUpdate: updateProfile, + schemas: schemas?.value, + showFields: props.showFields, + title: props.title, + }, + slots, + ); + }, +}); + +export default UserProfile; diff --git a/packages/vue/src/components/primitives/Alert/Alert.css.ts b/packages/vue/src/components/primitives/Alert/Alert.css.ts new file mode 100644 index 000000000..0d2f37ed8 --- /dev/null +++ b/packages/vue/src/components/primitives/Alert/Alert.css.ts @@ -0,0 +1,97 @@ +/** + * 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. + */ + +/** + * Styles for the Alert primitive component. + * + * BEM block: `.asgardeo-alert` + * + * Modifiers: + * Severity: --info | --success | --warning | --error + * + * Elements: + * __content | __dismiss + */ +const ALERT_CSS: string = ` +/* ============================================================ + Alert + ============================================================ */ + +.asgardeo-alert { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: calc(var(--asgardeo-spacing-unit) * 1); + padding: var(--asgardeo-alert-paddingY) var(--asgardeo-alert-paddingX); + border-radius: var(--asgardeo-alert-borderRadius); + border: 1px solid transparent; + font-family: var(--asgardeo-typography-fontFamily); + font-size: var(--asgardeo-typography-fontSize-sm); + box-sizing: border-box; + width: 100%; + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +.asgardeo-alert__content { + flex: 1; +} + +.asgardeo-alert--info { + background-color: var(--asgardeo-color-info-light); + border-color: var(--asgardeo-color-info-main); + color: var(--asgardeo-color-info-contrastText); +} + +.asgardeo-alert--success { + background-color: var(--asgardeo-color-success-light); + border-color: var(--asgardeo-color-success-main); + color: var(--asgardeo-color-success-contrastText); +} + +.asgardeo-alert--warning { + background-color: var(--asgardeo-color-warning-light); + border-color: var(--asgardeo-color-warning-main); + color: var(--asgardeo-color-warning-contrastText); +} + +.asgardeo-alert--error { + background-color: var(--asgardeo-color-error-light); + border-color: var(--asgardeo-color-error-main); + color: var(--asgardeo-color-error-contrastText); +} + +.asgardeo-alert__dismiss { + background: none; + border: none; + cursor: pointer; + font-size: 1em; + line-height: 0; + padding: calc(var(--asgardeo-spacing-unit) * 0.25); + border-radius: var(--asgardeo-border-radius-xs); + color: inherit; + opacity: 0.6; + flex-shrink: 0; + transition: opacity var(--asgardeo-transition-fast), background-color var(--asgardeo-transition-fast); +} +.asgardeo-alert__dismiss:hover { + opacity: 1; + background-color: var(--asgardeo-color-action-hover); +} +`; + +export default ALERT_CSS; diff --git a/packages/vue/src/components/primitives/Alert/Alert.ts b/packages/vue/src/components/primitives/Alert/Alert.ts new file mode 100644 index 000000000..41da5eec3 --- /dev/null +++ b/packages/vue/src/components/primitives/Alert/Alert.ts @@ -0,0 +1,71 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h, type PropType} from 'vue'; + +type AlertProps = Readonly<{ + dismissible: boolean; + severity: 'success' | 'error' | 'warning' | 'info'; +}>; + +const Alert: Component = defineComponent({ + emits: ['dismiss'], + name: 'Alert', + props: { + dismissible: {default: false, type: Boolean}, + severity: { + default: 'info', + type: String as PropType<'success' | 'error' | 'warning' | 'info'>, + }, + }, + setup(props: AlertProps, {slots, emit, attrs}: SetupContext): () => VNode { + return (): VNode => + h( + 'div', + { + class: [ + withVendorCSSClassPrefix('alert'), + withVendorCSSClassPrefix(`alert--${props.severity}`), + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '), + role: 'alert', + style: attrs['style'], + }, + [ + h('div', {class: withVendorCSSClassPrefix('alert__content')}, slots['default']?.()), + props.dismissible + ? h( + 'button', + { + 'aria-label': 'Dismiss', + class: withVendorCSSClassPrefix('alert__dismiss'), + onClick: () => emit('dismiss'), + type: 'button', + }, + '\u00d7', + ) + : null, + ], + ); + }, +}); + +export default Alert; diff --git a/packages/vue/src/components/primitives/Alert/index.ts b/packages/vue/src/components/primitives/Alert/index.ts new file mode 100644 index 000000000..7df718253 --- /dev/null +++ b/packages/vue/src/components/primitives/Alert/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './Alert'; diff --git a/packages/vue/src/components/primitives/Button/Button.css.ts b/packages/vue/src/components/primitives/Button/Button.css.ts new file mode 100644 index 000000000..72c2313fe --- /dev/null +++ b/packages/vue/src/components/primitives/Button/Button.css.ts @@ -0,0 +1,288 @@ +/** + * 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. + */ + +/** + * Styles for the Button primitive component. + * + * BEM block: `.asgardeo-button` + * + * Modifiers: + * Variant: --solid | --outline | --ghost | --text + * Color: --primary | --secondary | --danger + * Size: --small | --medium | --large + * State: --full-width | --loading + * + * Elements: + * __start-icon | __end-icon | __content | __spinner + * + * Note: The `asgardeo-spin` keyframe animation is defined in + * `styles/animations.css.ts` and shared with the Spinner component. + */ +const BUTTON_CSS: string = ` +/* ============================================================ + Button + ============================================================ */ + +.asgardeo-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: calc(var(--asgardeo-spacing-unit) * 0.75); + border-radius: var(--asgardeo-button-borderRadius); + font-family: var(--asgardeo-typography-fontFamily); + font-weight: var(--asgardeo-button-fontWeight); + letter-spacing: var(--asgardeo-typography-letterSpacing-normal); + cursor: pointer; + outline: none; + text-decoration: none; + white-space: nowrap; + border-width: 1px; + border-style: solid; + box-sizing: border-box; + transition: + background-color var(--asgardeo-transition-fast), + color var(--asgardeo-transition-fast), + border-color var(--asgardeo-transition-fast), + box-shadow var(--asgardeo-transition-fast), + opacity var(--asgardeo-transition-fast), + transform var(--asgardeo-transition-fast); + position: relative; + vertical-align: middle; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + user-select: none; +} + +.asgardeo-button:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--asgardeo-focus-ring-width) var(--asgardeo-focus-ring-color); +} + +/* -- Sizes -- */ + +.asgardeo-button--small { + padding: 0 var(--asgardeo-button-sm-paddingX); + font-size: var(--asgardeo-button-sm-fontSize); + height: var(--asgardeo-button-sm-height); +} + +.asgardeo-button--medium { + padding: 0 var(--asgardeo-button-md-paddingX); + font-size: var(--asgardeo-button-md-fontSize); + height: var(--asgardeo-button-md-height); +} + +.asgardeo-button--large { + padding: 0 var(--asgardeo-button-lg-paddingX); + font-size: var(--asgardeo-button-lg-fontSize); + height: var(--asgardeo-button-lg-height); +} + +/* -- Modifiers -- */ + +.asgardeo-button--full-width { + width: 100%; +} + +.asgardeo-button--loading, +.asgardeo-button:disabled { + cursor: not-allowed; + opacity: 0.55; + pointer-events: none; +} + +/* -- Solid variants -- */ + +.asgardeo-button--solid.asgardeo-button--primary { + background-color: var(--asgardeo-color-primary-main); + color: var(--asgardeo-color-primary-contrastText); + border-color: var(--asgardeo-color-primary-main); +} +.asgardeo-button--solid.asgardeo-button--primary:hover:not(:disabled) { + background-color: var(--asgardeo-color-primary-dark); + border-color: var(--asgardeo-color-primary-dark); +} +.asgardeo-button--solid.asgardeo-button--primary:active:not(:disabled) { + transform: scale(0.98); +} + +.asgardeo-button--solid.asgardeo-button--secondary { + background-color: var(--asgardeo-color-secondary-light); + color: var(--asgardeo-color-secondary-main); + border-color: var(--asgardeo-color-border); +} +.asgardeo-button--solid.asgardeo-button--secondary:hover:not(:disabled) { + background-color: var(--asgardeo-color-border); + border-color: var(--asgardeo-color-border); +} +.asgardeo-button--solid.asgardeo-button--secondary:active:not(:disabled) { + transform: scale(0.98); +} + +.asgardeo-button--solid.asgardeo-button--danger { + background-color: var(--asgardeo-color-error-main); + color: #ffffff; + border-color: var(--asgardeo-color-error-main); +} +.asgardeo-button--solid.asgardeo-button--danger:hover:not(:disabled) { + filter: brightness(0.92); +} +.asgardeo-button--solid.asgardeo-button--danger:active:not(:disabled) { + transform: scale(0.98); +} + +/* -- Outline variants -- */ + +.asgardeo-button--outline.asgardeo-button--primary { + background-color: transparent; + color: var(--asgardeo-color-primary-main); + border-color: var(--asgardeo-color-primary-main); +} +.asgardeo-button--outline.asgardeo-button--primary:hover:not(:disabled) { + background-color: var(--asgardeo-color-primary-light); +} +.asgardeo-button--outline.asgardeo-button--primary:active:not(:disabled) { + transform: scale(0.98); +} + +.asgardeo-button--outline.asgardeo-button--secondary { + background-color: transparent; + color: var(--asgardeo-color-secondary-main); + border-color: var(--asgardeo-color-border); +} +.asgardeo-button--outline.asgardeo-button--secondary:hover:not(:disabled) { + background-color: var(--asgardeo-color-secondary-light); + border-color: var(--asgardeo-color-secondary-main); +} +.asgardeo-button--outline.asgardeo-button--secondary:active:not(:disabled) { + transform: scale(0.98); +} + +.asgardeo-button--outline.asgardeo-button--danger { + background-color: transparent; + color: var(--asgardeo-color-error-main); + border-color: var(--asgardeo-color-error-main); +} +.asgardeo-button--outline.asgardeo-button--danger:hover:not(:disabled) { + background-color: var(--asgardeo-color-error-light); +} +.asgardeo-button--outline.asgardeo-button--danger:active:not(:disabled) { + transform: scale(0.98); +} + +/* -- Ghost variants -- */ + +.asgardeo-button--ghost.asgardeo-button--primary { + background-color: transparent; + color: var(--asgardeo-color-primary-main); + border-color: transparent; +} +.asgardeo-button--ghost.asgardeo-button--primary:hover:not(:disabled) { + background-color: var(--asgardeo-color-primary-light); + border-color: transparent; +} + +.asgardeo-button--ghost.asgardeo-button--secondary { + background-color: transparent; + color: var(--asgardeo-color-secondary-main); + border-color: transparent; +} +.asgardeo-button--ghost.asgardeo-button--secondary:hover:not(:disabled) { + background-color: var(--asgardeo-color-action-hover); + border-color: transparent; +} + +.asgardeo-button--ghost.asgardeo-button--danger { + background-color: transparent; + color: var(--asgardeo-color-error-main); + border-color: transparent; +} +.asgardeo-button--ghost.asgardeo-button--danger:hover:not(:disabled) { + background-color: var(--asgardeo-color-error-light); + border-color: transparent; +} + +/* -- Text variants -- */ + +.asgardeo-button--text { + border-color: transparent; + background-color: transparent; + padding-left: calc(var(--asgardeo-spacing-unit) * 0.25); + padding-right: calc(var(--asgardeo-spacing-unit) * 0.25); +} + +.asgardeo-button--text.asgardeo-button--primary { + color: var(--asgardeo-color-primary-main); +} +.asgardeo-button--text.asgardeo-button--primary:hover:not(:disabled) { + color: var(--asgardeo-color-primary-dark); + text-decoration: underline; + text-underline-offset: 2px; +} + +.asgardeo-button--text.asgardeo-button--secondary { + color: var(--asgardeo-color-secondary-main); +} +.asgardeo-button--text.asgardeo-button--secondary:hover:not(:disabled) { + color: var(--asgardeo-color-text-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.asgardeo-button--text.asgardeo-button--danger { + color: var(--asgardeo-color-error-main); +} +.asgardeo-button--text.asgardeo-button--danger:hover:not(:disabled) { + text-decoration: underline; + text-underline-offset: 2px; +} + +/* -- Inner elements -- */ + +.asgardeo-button__start-icon, +.asgardeo-button__end-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + line-height: 0; +} +.asgardeo-button--small .asgardeo-button__start-icon svg, +.asgardeo-button--small .asgardeo-button__end-icon svg { + width: 14px; + height: 14px; +} + +.asgardeo-button__content { + display: inline-flex; + align-items: center; +} + +.asgardeo-button__spinner { + display: inline-block; + width: 1em; + height: 1em; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: asgardeo-spin 0.6s linear infinite; + margin-left: calc(var(--asgardeo-spacing-unit) * 0.5); +} +`; + +export default BUTTON_CSS; diff --git a/packages/vue/src/components/primitives/Button/Button.ts b/packages/vue/src/components/primitives/Button/Button.ts new file mode 100644 index 000000000..a15ee0ea7 --- /dev/null +++ b/packages/vue/src/components/primitives/Button/Button.ts @@ -0,0 +1,96 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h, type PropType} from 'vue'; + +type ButtonProps = Readonly<{ + color: 'primary' | 'secondary' | 'danger'; + disabled: boolean; + endIcon: VNode | undefined; + fullWidth: boolean; + loading: boolean; + size: 'small' | 'medium' | 'large'; + startIcon: VNode | undefined; + type: 'button' | 'submit' | 'reset'; + variant: 'solid' | 'outline' | 'ghost' | 'text'; +}>; + +const Button: Component = defineComponent({ + emits: ['click'], + name: 'Button', + props: { + color: { + default: 'primary', + type: String as PropType<'primary' | 'secondary' | 'danger'>, + }, + disabled: {default: false, type: Boolean}, + endIcon: {default: undefined, type: Object as PropType}, + fullWidth: {default: false, type: Boolean}, + loading: {default: false, type: Boolean}, + size: { + default: 'medium', + type: String as PropType<'small' | 'medium' | 'large'>, + }, + startIcon: {default: undefined, type: Object as PropType}, + type: { + default: 'button', + type: String as PropType<'button' | 'submit' | 'reset'>, + }, + variant: { + default: 'solid', + type: String as PropType<'solid' | 'outline' | 'ghost' | 'text'>, + }, + }, + setup(props: ButtonProps, {slots, emit, attrs}: SetupContext): () => VNode { + return (): VNode => { + const cssClass: string = [ + withVendorCSSClassPrefix('button'), + withVendorCSSClassPrefix(`button--${props.variant}`), + withVendorCSSClassPrefix(`button--${props.color}`), + withVendorCSSClassPrefix(`button--${props.size}`), + props.fullWidth ? withVendorCSSClassPrefix('button--full-width') : '', + props.loading ? withVendorCSSClassPrefix('button--loading') : '', + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '); + + return h( + 'button', + { + class: cssClass, + disabled: props.disabled || props.loading, + onClick: (e: MouseEvent) => emit('click', e), + style: attrs['style'], + type: props.type, + }, + [ + props.startIcon + ? h('span', {class: withVendorCSSClassPrefix('button__start-icon')}, [props.startIcon]) + : null, + h('span', {class: withVendorCSSClassPrefix('button__content')}, slots['default']?.()), + props.endIcon ? h('span', {class: withVendorCSSClassPrefix('button__end-icon')}, [props.endIcon]) : null, + props.loading ? h('span', {'aria-hidden': 'true', class: withVendorCSSClassPrefix('button__spinner')}) : null, + ], + ); + }; + }, +}); + +export default Button; diff --git a/packages/vue/src/components/primitives/Button/index.ts b/packages/vue/src/components/primitives/Button/index.ts new file mode 100644 index 000000000..d6c72ca1e --- /dev/null +++ b/packages/vue/src/components/primitives/Button/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './Button'; diff --git a/packages/vue/src/components/primitives/Card/Card.css.ts b/packages/vue/src/components/primitives/Card/Card.css.ts new file mode 100644 index 000000000..e67901161 --- /dev/null +++ b/packages/vue/src/components/primitives/Card/Card.css.ts @@ -0,0 +1,53 @@ +/** + * 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. + */ + +/** + * Styles for the Card primitive component. + * + * BEM block: `.asgardeo-card` + * + * Modifiers: + * --elevated – medium drop shadow + * --outlined – 1px border, no shadow + * --flat – neither shadow nor border (default) + */ +const CARD_CSS: string = ` +/* ============================================================ + Card + ============================================================ */ + +.asgardeo-card { + background-color: var(--asgardeo-color-background-surface); + border-radius: var(--asgardeo-card-borderRadius); + padding: var(--asgardeo-card-padding); + box-sizing: border-box; + transition: box-shadow var(--asgardeo-transition-normal); +} + +.asgardeo-card--elevated { + box-shadow: var(--asgardeo-card-shadow); +} + +.asgardeo-card--outlined { + border: 1px solid var(--asgardeo-card-borderColor); +} + +/* .asgardeo-card--flat: no shadow or border */ +`; + +export default CARD_CSS; diff --git a/packages/vue/src/components/primitives/Card/Card.ts b/packages/vue/src/components/primitives/Card/Card.ts new file mode 100644 index 000000000..1fc1915e7 --- /dev/null +++ b/packages/vue/src/components/primitives/Card/Card.ts @@ -0,0 +1,53 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h, type PropType} from 'vue'; + +type CardProps = Readonly<{ + variant: 'elevated' | 'outlined' | 'flat'; +}>; + +const Card: Component = defineComponent({ + name: 'Card', + props: { + variant: { + default: 'elevated', + type: String as PropType<'elevated' | 'outlined' | 'flat'>, + }, + }, + setup(props: CardProps, {slots, attrs}: SetupContext): () => VNode { + return (): VNode => + h( + 'div', + { + class: [ + withVendorCSSClassPrefix('card'), + withVendorCSSClassPrefix(`card--${props.variant}`), + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '), + style: attrs['style'], + }, + slots['default']?.(), + ); + }, +}); + +export default Card; diff --git a/packages/vue/src/components/primitives/Card/index.ts b/packages/vue/src/components/primitives/Card/index.ts new file mode 100644 index 000000000..225ff2fd6 --- /dev/null +++ b/packages/vue/src/components/primitives/Card/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './Card'; diff --git a/packages/vue/src/components/primitives/Checkbox/Checkbox.css.ts b/packages/vue/src/components/primitives/Checkbox/Checkbox.css.ts new file mode 100644 index 000000000..aa22357f6 --- /dev/null +++ b/packages/vue/src/components/primitives/Checkbox/Checkbox.css.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ + +/** + * Styles for the Checkbox primitive component. + * + * BEM block: `.asgardeo-checkbox` + * + * Modifiers: + * --error – shows validation error state + * + * Elements: + * __wrapper | __input | __label | __error + */ +const CHECKBOX_CSS: string = ` +/* ============================================================ + Checkbox + ============================================================ */ + +.asgardeo-checkbox { + display: flex; + flex-direction: column; + gap: calc(var(--asgardeo-spacing-unit) * 0.5); + font-family: var(--asgardeo-typography-fontFamily); +} + +.asgardeo-checkbox__wrapper { + display: inline-flex; + align-items: center; + gap: calc(var(--asgardeo-spacing-unit) * 0.75); + cursor: pointer; + user-select: none; +} + +.asgardeo-checkbox__input { + width: var(--asgardeo-checkbox-size); + height: var(--asgardeo-checkbox-size); + cursor: pointer; + accent-color: var(--asgardeo-color-primary-main); + flex-shrink: 0; + border-radius: var(--asgardeo-border-radius-xs); +} +.asgardeo-checkbox__input:focus-visible { + outline: none; + box-shadow: 0 0 0 var(--asgardeo-focus-ring-width) var(--asgardeo-focus-ring-color); +} +.asgardeo-checkbox__input:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.asgardeo-checkbox__label { + font-size: var(--asgardeo-typography-fontSize-md); + color: var(--asgardeo-color-text-primary); + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +.asgardeo-checkbox__error { + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-error-contrastText); + line-height: var(--asgardeo-typography-lineHeight-normal); +} +`; + +export default CHECKBOX_CSS; diff --git a/packages/vue/src/components/primitives/Checkbox/Checkbox.ts b/packages/vue/src/components/primitives/Checkbox/Checkbox.ts new file mode 100644 index 000000000..7803ad45d --- /dev/null +++ b/packages/vue/src/components/primitives/Checkbox/Checkbox.ts @@ -0,0 +1,73 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h} from 'vue'; + +type CheckboxProps = Readonly<{ + disabled: boolean; + error: string | undefined; + label: string | undefined; + modelValue: boolean; + name: string | undefined; + required: boolean; +}>; + +const Checkbox: Component = defineComponent({ + emits: ['update:modelValue'], + name: 'AsgardeoCheckbox', + props: { + disabled: {default: false, type: Boolean}, + error: {default: undefined, type: String}, + label: {default: undefined, type: String}, + modelValue: {default: false, type: Boolean}, + name: {default: undefined, type: String}, + required: {default: false, type: Boolean}, + }, + setup(props: CheckboxProps, {emit, attrs}: SetupContext): () => VNode { + return (): VNode => { + const wrapperClass: string = [ + withVendorCSSClassPrefix('checkbox'), + props.error ? withVendorCSSClassPrefix('checkbox--error') : '', + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '); + + return h('div', {class: wrapperClass, style: attrs['style']}, [ + h('label', {class: withVendorCSSClassPrefix('checkbox__wrapper')}, [ + h('input', { + checked: props.modelValue, + class: withVendorCSSClassPrefix('checkbox__input'), + 'data-testid': attrs['data-testid'], + disabled: props.disabled, + id: props.name, + name: props.name, + onChange: (e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).checked), + required: props.required, + type: 'checkbox', + }), + props.label ? h('span', {class: withVendorCSSClassPrefix('checkbox__label')}, props.label) : null, + ]), + props.error ? h('span', {class: withVendorCSSClassPrefix('checkbox__error')}, props.error) : null, + ]); + }; + }, +}); + +export default Checkbox; diff --git a/packages/vue/src/components/primitives/Checkbox/index.ts b/packages/vue/src/components/primitives/Checkbox/index.ts new file mode 100644 index 000000000..c150262dc --- /dev/null +++ b/packages/vue/src/components/primitives/Checkbox/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './Checkbox'; diff --git a/packages/vue/src/components/primitives/DatePicker/DatePicker.css.ts b/packages/vue/src/components/primitives/DatePicker/DatePicker.css.ts new file mode 100644 index 000000000..01464464e --- /dev/null +++ b/packages/vue/src/components/primitives/DatePicker/DatePicker.css.ts @@ -0,0 +1,97 @@ +/** + * 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. + */ + +/** + * Styles for the DatePicker primitive component. + * + * BEM block: `.asgardeo-date-picker` + * + * Modifiers: + * --error – shows validation error state + * + * Elements: + * __label | __required | __input | __error + */ +const DATE_PICKER_CSS: string = ` +/* ============================================================ + DatePicker + ============================================================ */ + +.asgardeo-date-picker { + display: flex; + flex-direction: column; + gap: calc(var(--asgardeo-spacing-unit) * 0.5); + font-family: var(--asgardeo-typography-fontFamily); + width: 100%; + box-sizing: border-box; +} + +.asgardeo-date-picker__label { + font-size: var(--asgardeo-typography-fontSize-sm); + font-weight: var(--asgardeo-typography-fontWeight-medium); + color: var(--asgardeo-color-text-primary); + display: block; + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +.asgardeo-date-picker__required { + color: var(--asgardeo-color-error-main); + margin-left: 2px; +} + +.asgardeo-date-picker__input { + width: 100%; + height: var(--asgardeo-input-height); + padding: 0 var(--asgardeo-input-paddingX); + border: 1px solid var(--asgardeo-input-borderColor); + border-radius: var(--asgardeo-input-borderRadius); + font-family: var(--asgardeo-typography-fontFamily); + font-size: var(--asgardeo-input-fontSize); + color: var(--asgardeo-color-text-primary); + background-color: var(--asgardeo-color-background-surface); + box-sizing: border-box; + transition: + border-color var(--asgardeo-transition-fast), + box-shadow var(--asgardeo-transition-fast); + outline: none; + cursor: pointer; +} +.asgardeo-date-picker__input:focus { + border-color: var(--asgardeo-input-focusBorderColor); + box-shadow: var(--asgardeo-input-focusRing); +} +.asgardeo-date-picker--error .asgardeo-date-picker__input { + border-color: var(--asgardeo-color-error-main); +} +.asgardeo-date-picker--error .asgardeo-date-picker__input:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} +.asgardeo-date-picker__input:disabled { + background-color: var(--asgardeo-color-background-disabled); + color: var(--asgardeo-color-action-disabled); + cursor: not-allowed; +} + +.asgardeo-date-picker__error { + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-error-contrastText); + line-height: var(--asgardeo-typography-lineHeight-normal); +} +`; + +export default DATE_PICKER_CSS; diff --git a/packages/vue/src/components/primitives/DatePicker/DatePicker.ts b/packages/vue/src/components/primitives/DatePicker/DatePicker.ts new file mode 100644 index 000000000..dd8052fbf --- /dev/null +++ b/packages/vue/src/components/primitives/DatePicker/DatePicker.ts @@ -0,0 +1,80 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h} from 'vue'; + +type DatePickerProps = Readonly<{ + disabled: boolean; + error: string | undefined; + label: string | undefined; + modelValue: string; + name: string | undefined; + placeholder: string | undefined; + required: boolean; +}>; + +const DatePicker: Component = defineComponent({ + emits: ['update:modelValue'], + name: 'AsgardeoDatePicker', + props: { + disabled: {default: false, type: Boolean}, + error: {default: undefined, type: String}, + label: {default: undefined, type: String}, + modelValue: {default: '', type: String}, + name: {default: undefined, type: String}, + placeholder: {default: undefined, type: String}, + required: {default: false, type: Boolean}, + }, + setup(props: DatePickerProps, {emit, attrs}: SetupContext): () => VNode { + return (): VNode => { + const hasError: boolean = !!props.error; + const wrapperClass: string = [ + withVendorCSSClassPrefix('date-picker'), + hasError ? withVendorCSSClassPrefix('date-picker--error') : '', + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '); + + return h('div', {class: wrapperClass, style: attrs['style']}, [ + props.label + ? h('label', {class: withVendorCSSClassPrefix('date-picker__label'), for: props.name}, [ + props.label, + props.required ? h('span', {class: withVendorCSSClassPrefix('date-picker__required')}, ' *') : null, + ]) + : null, + h('input', { + class: withVendorCSSClassPrefix('date-picker__input'), + 'data-testid': attrs['data-testid'], + disabled: props.disabled, + id: props.name, + name: props.name, + onInput: (e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value), + placeholder: props.placeholder, + required: props.required, + type: 'date', + value: props.modelValue, + }), + hasError ? h('span', {class: withVendorCSSClassPrefix('date-picker__error')}, props.error) : null, + ]); + }; + }, +}); + +export default DatePicker; diff --git a/packages/vue/src/components/primitives/DatePicker/index.ts b/packages/vue/src/components/primitives/DatePicker/index.ts new file mode 100644 index 000000000..99404f32b --- /dev/null +++ b/packages/vue/src/components/primitives/DatePicker/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './DatePicker'; diff --git a/packages/vue/src/components/primitives/Divider/Divider.css.ts b/packages/vue/src/components/primitives/Divider/Divider.css.ts new file mode 100644 index 000000000..dc6ded569 --- /dev/null +++ b/packages/vue/src/components/primitives/Divider/Divider.css.ts @@ -0,0 +1,85 @@ +/** + * 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. + */ + +/** + * Styles for the Divider primitive component. + * + * BEM block: `.asgardeo-divider` + * + * Modifiers: + * --horizontal – full-width horizontal rule + * --vertical – inline vertical bar + * --with-content – flex row with centred label between two lines + * + * Elements: + * __line | __content + */ +const DIVIDER_CSS: string = ` +/* ============================================================ + Divider + ============================================================ */ + +.asgardeo-divider { + box-sizing: border-box; +} + +.asgardeo-divider--horizontal { + width: 100%; + border: none; + border-top: 1px solid var(--asgardeo-color-border); + margin: calc(var(--asgardeo-spacing-unit) * 1) 0; +} + +.asgardeo-divider--vertical { + display: inline-block; + width: 1px; + height: 100%; + min-height: 1em; + border: none; + background-color: var(--asgardeo-color-border); + margin: 0 calc(var(--asgardeo-spacing-unit) * 1); + align-self: stretch; +} + +.asgardeo-divider--with-content { + display: flex; + align-items: center; + gap: calc(var(--asgardeo-spacing-unit) * 1); + border: none; + margin: calc(var(--asgardeo-spacing-unit) * 1) 0; +} + +.asgardeo-divider__line { + flex: 1; + height: 1px; + background-color: var(--asgardeo-color-border); +} + +.asgardeo-divider__content { + flex-shrink: 0; + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-text-secondary); + padding: 0 calc(var(--asgardeo-spacing-unit) * 0.5); + font-family: var(--asgardeo-typography-fontFamily); + text-transform: uppercase; + letter-spacing: var(--asgardeo-typography-letterSpacing-wide); + font-weight: var(--asgardeo-typography-fontWeight-medium); +} +`; + +export default DIVIDER_CSS; diff --git a/packages/vue/src/components/primitives/Divider/Divider.ts b/packages/vue/src/components/primitives/Divider/Divider.ts new file mode 100644 index 000000000..8e91bec16 --- /dev/null +++ b/packages/vue/src/components/primitives/Divider/Divider.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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h, type PropType} from 'vue'; + +type DividerProps = Readonly<{ + orientation: 'horizontal' | 'vertical'; +}>; + +const Divider: Component = defineComponent({ + name: 'Divider', + props: { + orientation: { + default: 'horizontal', + type: String as PropType<'horizontal' | 'vertical'>, + }, + }, + setup(props: DividerProps, {slots, attrs}: SetupContext): () => VNode { + return (): VNode => { + const hasContent: boolean = !!slots['default']; + const cssClass: string = [ + withVendorCSSClassPrefix('divider'), + withVendorCSSClassPrefix(`divider--${props.orientation}`), + hasContent ? withVendorCSSClassPrefix('divider--with-content') : '', + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '); + + if (hasContent) { + return h('div', {class: cssClass, role: 'separator', style: attrs['style']}, [ + h('span', {class: withVendorCSSClassPrefix('divider__line')}), + h('span', {class: withVendorCSSClassPrefix('divider__content')}, slots['default']?.()), + h('span', {class: withVendorCSSClassPrefix('divider__line')}), + ]); + } + + return h('hr', {class: cssClass, role: 'separator', style: attrs['style']}); + }; + }, +}); + +export default Divider; diff --git a/packages/vue/src/components/primitives/Divider/index.ts b/packages/vue/src/components/primitives/Divider/index.ts new file mode 100644 index 000000000..2be7e7b81 --- /dev/null +++ b/packages/vue/src/components/primitives/Divider/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './Divider'; diff --git a/packages/vue/src/components/primitives/Icons.ts b/packages/vue/src/components/primitives/Icons.ts new file mode 100644 index 000000000..7d6466402 --- /dev/null +++ b/packages/vue/src/components/primitives/Icons.ts @@ -0,0 +1,116 @@ +/** + * 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 {h, type VNode} from 'vue'; + +const defaultProps: Record = { + fill: 'none', + height: '16', + stroke: 'currentColor', + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'stroke-width': '2', + viewBox: '0 0 24 24', + width: '16', + xmlns: 'http://www.w3.org/2000/svg', +}; + +const icon = (paths: VNode[]): VNode => h('svg', {...defaultProps}, paths); + +export const CheckIcon = (): VNode => icon([h('polyline', {points: '20 6 9 17 4 12'})]); + +export const XIcon = (): VNode => + icon([h('line', {x1: '18', x2: '6', y1: '6', y2: '18'}), h('line', {x1: '6', x2: '18', y1: '6', y2: '18'})]); + +export const EyeIcon = (): VNode => + icon([h('path', {d: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'}), h('circle', {cx: '12', cy: '12', r: '3'})]); + +export const EyeOffIcon = (): VNode => + icon([ + h('path', {d: 'M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94'}), + h('path', {d: 'M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19'}), + h('line', {x1: '1', x2: '23', y1: '1', y2: '23'}), + ]); + +export const CircleAlertIcon = (): VNode => + icon([ + h('circle', {cx: '12', cy: '12', r: '10'}), + h('line', {x1: '12', x2: '12', y1: '8', y2: '12'}), + h('line', {x1: '12', x2: '12.01', y1: '16', y2: '16'}), + ]); + +export const CircleCheckIcon = (): VNode => + icon([h('path', {d: 'M22 11.08V12a10 10 0 1 1-5.93-9.14'}), h('polyline', {points: '22 4 12 14.01 9 11.01'})]); + +export const InfoIcon = (): VNode => + icon([ + h('circle', {cx: '12', cy: '12', r: '10'}), + h('line', {x1: '12', x2: '12', y1: '16', y2: '12'}), + h('line', {x1: '12', x2: '12.01', y1: '8', y2: '8'}), + ]); + +export const TriangleAlertIcon = (): VNode => + icon([ + h('path', {d: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'}), + h('line', {x1: '12', x2: '12', y1: '9', y2: '13'}), + h('line', {x1: '12', x2: '12.01', y1: '17', y2: '17'}), + ]); + +export const PlusIcon = (): VNode => + icon([h('line', {x1: '12', x2: '12', y1: '5', y2: '19'}), h('line', {x1: '5', x2: '19', y1: '12', y2: '12'})]); + +export const LogOutIcon = (): VNode => + icon([ + h('path', {d: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4'}), + h('polyline', {points: '16 17 21 12 16 7'}), + h('line', {x1: '21', x2: '9', y1: '12', y2: '12'}), + ]); + +export const UserIcon = (): VNode => + icon([h('path', {d: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'}), h('circle', {cx: '12', cy: '7', r: '4'})]); + +export const ArrowLeftRightIcon = (): VNode => + icon([ + h('polyline', {points: '7 16 3 12 7 8'}), + h('line', {x1: '21', x2: '3', y1: '12', y2: '12'}), + h('polyline', {points: '17 8 21 12 17 16'}), + ]); + +export const BuildingIcon = (): VNode => + icon([ + h('rect', {height: '20', rx: '2', ry: '2', width: '16', x: '4', y: '2'}), + h('line', {x1: '9', x2: '9', y1: '6', y2: '6.01'}), + h('line', {x1: '15', x2: '15', y1: '6', y2: '6.01'}), + h('line', {x1: '9', x2: '9', y1: '10', y2: '10.01'}), + h('line', {x1: '15', x2: '15', y1: '10', y2: '10.01'}), + h('line', {x1: '9', x2: '9', y1: '14', y2: '14.01'}), + h('line', {x1: '15', x2: '15', y1: '14', y2: '14.01'}), + h('line', {x1: '9', x2: '15', y1: '18', y2: '18'}), + ]); + +export const ChevronDownIcon = (): VNode => icon([h('polyline', {points: '6 9 12 15 18 9'})]); + +export const GlobeIcon = (): VNode => + icon([ + h('circle', {cx: '12', cy: '12', r: '10'}), + h('line', {x1: '2', x2: '22', y1: '12', y2: '12'}), + h('path', {d: 'M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z'}), + ]); + +export const PencilIcon = (): VNode => + icon([h('path', {d: 'M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z'})]); diff --git a/packages/vue/src/composables/useAsgardeoContext.ts b/packages/vue/src/components/primitives/Logo/Logo.css.ts similarity index 51% rename from packages/vue/src/composables/useAsgardeoContext.ts rename to packages/vue/src/components/primitives/Logo/Logo.css.ts index 45d18075c..0514f787b 100644 --- a/packages/vue/src/composables/useAsgardeoContext.ts +++ b/packages/vue/src/components/primitives/Logo/Logo.css.ts @@ -16,22 +16,37 @@ * under the License. */ -import {inject} from 'vue'; -import {ASGARDEO_INJECTION_KEY} from '../plugins/AsgardeoPlugin'; -import {AuthContextInterface} from '../types'; - /** - * Retrieves the Asgardeo authentication context from Vue's dependency injection system. + * Styles for the Logo primitive component. + * + * BEM block: `.asgardeo-logo` * - * @throws {Error} Throws an error if the Vue plugin is not installed. - * @returns {AuthContextInterface} The authentication context containing authentication methods and state. + * Elements: + * __image */ -export function useAsgardeoContext(): AuthContextInterface { - const ctx: AuthContextInterface = inject(ASGARDEO_INJECTION_KEY); +const LOGO_CSS: string = ` +/* ============================================================ + Logo + ============================================================ */ + +.asgardeo-logo { + display: inline-flex; + align-items: center; + text-decoration: none; + transition: opacity var(--asgardeo-transition-fast); +} - if (!ctx) { - throw new Error('This can be only used when vue plugin is installed'); - } +.asgardeo-logo:hover { + opacity: 0.85; +} - return ctx; +.asgardeo-logo__image { + display: block; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; } +`; + +export default LOGO_CSS; diff --git a/packages/vue/src/components/primitives/Logo/Logo.ts b/packages/vue/src/components/primitives/Logo/Logo.ts new file mode 100644 index 000000000..9bf5163e4 --- /dev/null +++ b/packages/vue/src/components/primitives/Logo/Logo.ts @@ -0,0 +1,73 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h} from 'vue'; + +type LogoProps = Readonly<{ + alt: string; + height: string | number | undefined; + href: string | undefined; + src: string | undefined; + width: string | number | undefined; +}>; + +const Logo: Component = defineComponent({ + name: 'Logo', + props: { + alt: {default: 'Logo', type: String}, + height: {default: undefined, type: [String, Number]}, + href: {default: undefined, type: String}, + src: {default: undefined, type: String}, + width: {default: undefined, type: [String, Number]}, + }, + setup(props: LogoProps, {attrs}: SetupContext): () => VNode { + return (): VNode => { + const img: VNode = h('img', { + alt: props.alt, + class: withVendorCSSClassPrefix('logo__image'), + height: props.height, + src: props.src, + width: props.width, + }); + + if (props.href) { + return h( + 'a', + { + class: [withVendorCSSClassPrefix('logo'), (attrs['class'] as string) || ''].filter(Boolean).join(' '), + href: props.href, + style: attrs['style'], + }, + [img], + ); + } + + return h( + 'div', + { + class: [withVendorCSSClassPrefix('logo'), (attrs['class'] as string) || ''].filter(Boolean).join(' '), + style: attrs['style'], + }, + [img], + ); + }; + }, +}); + +export default Logo; diff --git a/packages/vue/src/components/primitives/Logo/index.ts b/packages/vue/src/components/primitives/Logo/index.ts new file mode 100644 index 000000000..e6b035b01 --- /dev/null +++ b/packages/vue/src/components/primitives/Logo/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './Logo'; diff --git a/packages/vue/src/components/primitives/OtpField/OtpField.css.ts b/packages/vue/src/components/primitives/OtpField/OtpField.css.ts new file mode 100644 index 000000000..960f38c94 --- /dev/null +++ b/packages/vue/src/components/primitives/OtpField/OtpField.css.ts @@ -0,0 +1,91 @@ +/** + * 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. + */ + +/** + * Styles for the OtpField primitive component. + * + * BEM block: `.asgardeo-otp-field` + * + * Elements: + * __label | __required | __inputs | __digit | __error + */ +const OTP_FIELD_CSS: string = ` +/* ============================================================ + OtpField + ============================================================ */ + +.asgardeo-otp-field { + display: flex; + flex-direction: column; + gap: calc(var(--asgardeo-spacing-unit) * 0.75); + font-family: var(--asgardeo-typography-fontFamily); +} + +.asgardeo-otp-field__label { + font-size: var(--asgardeo-typography-fontSize-sm); + font-weight: var(--asgardeo-typography-fontWeight-medium); + color: var(--asgardeo-color-text-primary); + display: block; + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +.asgardeo-otp-field__required { + color: var(--asgardeo-color-error-main); + margin-left: 2px; +} + +.asgardeo-otp-field__inputs { + display: flex; + gap: calc(var(--asgardeo-spacing-unit) * 0.75); +} + +.asgardeo-otp-field__digit { + width: var(--asgardeo-input-height); + height: var(--asgardeo-input-height); + text-align: center; + border: 1px solid var(--asgardeo-input-borderColor); + border-radius: var(--asgardeo-input-borderRadius); + font-family: var(--asgardeo-typography-fontFamily); + font-size: var(--asgardeo-typography-fontSize-lg); + font-weight: var(--asgardeo-typography-fontWeight-semibold); + color: var(--asgardeo-color-text-primary); + background-color: var(--asgardeo-color-background-surface); + box-sizing: border-box; + outline: none; + transition: + border-color var(--asgardeo-transition-fast), + box-shadow var(--asgardeo-transition-fast); +} +.asgardeo-otp-field__digit:focus { + border-color: var(--asgardeo-input-focusBorderColor); + box-shadow: var(--asgardeo-input-focusRing); +} +.asgardeo-otp-field__digit:disabled { + background-color: var(--asgardeo-color-background-disabled); + color: var(--asgardeo-color-action-disabled); + cursor: not-allowed; +} + +.asgardeo-otp-field__error { + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-error-contrastText); + line-height: var(--asgardeo-typography-lineHeight-normal); +} +`; + +export default OTP_FIELD_CSS; diff --git a/packages/vue/src/components/primitives/OtpField/OtpField.ts b/packages/vue/src/components/primitives/OtpField/OtpField.ts new file mode 100644 index 000000000..164b04a60 --- /dev/null +++ b/packages/vue/src/components/primitives/OtpField/OtpField.ts @@ -0,0 +1,115 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type Ref, type SetupContext, type VNode, defineComponent, h, nextTick, ref} from 'vue'; + +type OtpFieldProps = Readonly<{ + disabled: boolean; + error: string | undefined; + label: string | undefined; + length: number; + modelValue: string; + name: string | undefined; + required: boolean; +}>; + +const OtpField: Component = defineComponent({ + emits: ['update:modelValue'], + name: 'OtpField', + props: { + disabled: {default: false, type: Boolean}, + error: {default: undefined, type: String}, + label: {default: undefined, type: String}, + length: {default: 6, type: Number}, + modelValue: {default: '', type: String}, + name: {default: undefined, type: String}, + required: {default: false, type: Boolean}, + }, + setup(props: OtpFieldProps, {emit, attrs}: SetupContext): () => VNode { + const inputRefs: Ref = ref([]); + + const setRef = (el: unknown, index: number): void => { + if (el) inputRefs.value[index] = el as HTMLInputElement; + }; + + const handleInput = (index: number, e: Event): void => { + const target: HTMLInputElement = e.target as HTMLInputElement; + const val: string = target.value.replace(/\D/g, '').slice(0, 1); + target.value = val; + + const current: string[] = (props.modelValue || '').split(''); + while (current.length < props.length) current.push(''); + current[index] = val; + emit('update:modelValue', current.join('')); + + if (val && index < props.length - 1) { + nextTick(() => inputRefs.value[index + 1]?.focus()); + } + }; + + const handleKeydown = (index: number, e: KeyboardEvent): void => { + if (e.key === 'Backspace' && !(e.target as HTMLInputElement).value && index > 0) { + nextTick(() => inputRefs.value[index - 1]?.focus()); + } + }; + + return (): VNode => { + const digits: string[] = (props.modelValue || '').split(''); + while (digits.length < props.length) digits.push(''); + + return h( + 'div', + { + class: [withVendorCSSClassPrefix('otp-field'), (attrs['class'] as string) || ''].filter(Boolean).join(' '), + style: attrs['style'], + }, + [ + props.label + ? h('label', {class: withVendorCSSClassPrefix('otp-field__label')}, [ + props.label, + props.required ? h('span', {class: withVendorCSSClassPrefix('otp-field__required')}, ' *') : null, + ]) + : null, + h( + 'div', + {class: withVendorCSSClassPrefix('otp-field__inputs')}, + Array.from({length: props.length}, (_: unknown, i: number) => + h('input', { + 'aria-label': `Digit ${i + 1}`, + class: withVendorCSSClassPrefix('otp-field__digit'), + disabled: props.disabled, + inputmode: 'numeric', + key: i, + maxlength: 1, + onInput: (e: Event) => handleInput(i, e), + onKeydown: (e: KeyboardEvent) => handleKeydown(i, e), + ref: (el: unknown) => setRef(el, i), + type: 'text', + value: digits[i], + }), + ), + ), + props.error ? h('span', {class: withVendorCSSClassPrefix('otp-field__error')}, props.error) : null, + ], + ); + }; + }, +}); + +export default OtpField; diff --git a/packages/vue/src/components/primitives/OtpField/index.ts b/packages/vue/src/components/primitives/OtpField/index.ts new file mode 100644 index 000000000..2ea36160a --- /dev/null +++ b/packages/vue/src/components/primitives/OtpField/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './OtpField'; diff --git a/packages/vue/src/components/primitives/PasswordField/PasswordField.css.ts b/packages/vue/src/components/primitives/PasswordField/PasswordField.css.ts new file mode 100644 index 000000000..679ee12e1 --- /dev/null +++ b/packages/vue/src/components/primitives/PasswordField/PasswordField.css.ts @@ -0,0 +1,127 @@ +/** + * 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. + */ + +/** + * Styles for the PasswordField primitive component. + * + * BEM block: `.asgardeo-password-field` + * + * Modifiers: + * --error – shows validation error state + * + * Elements: + * __label | __required | __wrapper | __input | __toggle | __error + */ +const PASSWORD_FIELD_CSS: string = ` +/* ============================================================ + PasswordField + ============================================================ */ + +.asgardeo-password-field { + display: flex; + flex-direction: column; + gap: calc(var(--asgardeo-spacing-unit) * 0.5); + font-family: var(--asgardeo-typography-fontFamily); + width: 100%; + box-sizing: border-box; +} + +.asgardeo-password-field__label { + font-size: var(--asgardeo-typography-fontSize-sm); + font-weight: var(--asgardeo-typography-fontWeight-medium); + color: var(--asgardeo-color-text-primary); + display: block; + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +.asgardeo-password-field__required { + color: var(--asgardeo-color-error-main); + margin-left: 2px; +} + +.asgardeo-password-field__wrapper { + display: flex; + align-items: center; + height: var(--asgardeo-input-height); + border: 1px solid var(--asgardeo-input-borderColor); + border-radius: var(--asgardeo-input-borderRadius); + background-color: var(--asgardeo-color-background-surface); + transition: + border-color var(--asgardeo-transition-fast), + box-shadow var(--asgardeo-transition-fast); + overflow: hidden; + box-sizing: border-box; +} +.asgardeo-password-field__wrapper:focus-within { + border-color: var(--asgardeo-input-focusBorderColor); + box-shadow: var(--asgardeo-input-focusRing); +} +.asgardeo-password-field--error .asgardeo-password-field__wrapper { + border-color: var(--asgardeo-color-error-main); +} +.asgardeo-password-field--error .asgardeo-password-field__wrapper:focus-within { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.asgardeo-password-field__input { + flex: 1; + padding: 0 var(--asgardeo-input-paddingX); + border: none; + outline: none; + font-family: var(--asgardeo-typography-fontFamily); + font-size: var(--asgardeo-input-fontSize); + color: var(--asgardeo-color-text-primary); + background: transparent; + width: 100%; + height: 100%; + box-sizing: border-box; + min-width: 0; +} +.asgardeo-password-field__input::placeholder { + color: var(--asgardeo-color-text-secondary); +} +.asgardeo-password-field__input:disabled { + cursor: not-allowed; +} + +.asgardeo-password-field__toggle { + background: none; + border: none; + cursor: pointer; + padding: 0 var(--asgardeo-input-paddingX); + color: var(--asgardeo-color-text-secondary); + font-size: var(--asgardeo-typography-fontSize-md); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + height: 100%; + transition: color var(--asgardeo-transition-fast); +} +.asgardeo-password-field__toggle:hover { + color: var(--asgardeo-color-text-primary); +} + +.asgardeo-password-field__error { + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-error-contrastText); + line-height: var(--asgardeo-typography-lineHeight-normal); +} +`; + +export default PASSWORD_FIELD_CSS; diff --git a/packages/vue/src/components/primitives/PasswordField/PasswordField.ts b/packages/vue/src/components/primitives/PasswordField/PasswordField.ts new file mode 100644 index 000000000..3fef53514 --- /dev/null +++ b/packages/vue/src/components/primitives/PasswordField/PasswordField.ts @@ -0,0 +1,106 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type Ref, type SetupContext, type VNode, defineComponent, h, ref} from 'vue'; +import {EyeIcon, EyeOffIcon} from '../Icons'; + +type PasswordFieldProps = Readonly<{ + disabled: boolean; + error: string | undefined; + label: string | undefined; + modelValue: string; + name: string | undefined; + placeholder: string | undefined; + required: boolean; +}>; + +const PasswordField: Component = defineComponent({ + emits: ['update:modelValue', 'blur'], + name: 'PasswordField', + props: { + disabled: {default: false, type: Boolean}, + error: {default: undefined, type: String}, + label: {default: undefined, type: String}, + modelValue: {default: '', type: String}, + name: {default: undefined, type: String}, + placeholder: {default: undefined, type: String}, + required: {default: false, type: Boolean}, + }, + setup(props: PasswordFieldProps, {emit, attrs}: SetupContext): () => VNode { + const visible: Ref = ref(false); + + return (): VNode => { + const hasError: boolean = !!props.error; + const wrapperClass: string = [ + withVendorCSSClassPrefix('password-field'), + hasError ? withVendorCSSClassPrefix('password-field--error') : '', + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '); + + return h('div', {class: wrapperClass, style: attrs['style']}, [ + props.label + ? h( + 'label', + { + class: withVendorCSSClassPrefix('password-field__label'), + for: props.name, + }, + [ + props.label, + props.required ? h('span', {class: withVendorCSSClassPrefix('password-field__required')}, ' *') : null, + ], + ) + : null, + h('div', {class: withVendorCSSClassPrefix('password-field__wrapper')}, [ + h('input', { + class: withVendorCSSClassPrefix('password-field__input'), + 'data-testid': attrs['data-testid'], + disabled: props.disabled, + id: props.name, + name: props.name, + onBlur: () => emit('blur'), + onInput: (e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value), + placeholder: props.placeholder, + required: props.required, + type: visible.value ? 'text' : 'password', + value: props.modelValue, + }), + h( + 'button', + { + 'aria-label': visible.value ? 'Hide password' : 'Show password', + class: withVendorCSSClassPrefix('password-field__toggle'), + onClick: () => { + visible.value = !visible.value; + }, + tabindex: -1, + type: 'button', + }, + visible.value ? EyeOffIcon() : EyeIcon(), + ), + ]), + hasError ? h('span', {class: withVendorCSSClassPrefix('password-field__error')}, props.error) : null, + ]); + }; + }, +}); + +export default PasswordField; diff --git a/packages/vue/src/components/primitives/PasswordField/index.ts b/packages/vue/src/components/primitives/PasswordField/index.ts new file mode 100644 index 000000000..b55f0809c --- /dev/null +++ b/packages/vue/src/components/primitives/PasswordField/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './PasswordField'; diff --git a/packages/vue/src/components/primitives/Select/Select.css.ts b/packages/vue/src/components/primitives/Select/Select.css.ts new file mode 100644 index 000000000..c207dab8e --- /dev/null +++ b/packages/vue/src/components/primitives/Select/Select.css.ts @@ -0,0 +1,110 @@ +/** + * 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. + */ + +/** + * Styles for the Select primitive component. + * + * BEM block: `.asgardeo-select` + * + * Modifiers: + * --error – shows validation error state + * + * Elements: + * __label | __required | __input | __error | __helper + */ +const SELECT_CSS: string = ` +/* ============================================================ + Select + ============================================================ */ + +.asgardeo-select { + display: flex; + flex-direction: column; + gap: calc(var(--asgardeo-spacing-unit) * 0.5); + font-family: var(--asgardeo-typography-fontFamily); + width: 100%; + box-sizing: border-box; +} + +.asgardeo-select__label { + font-size: var(--asgardeo-typography-fontSize-sm); + font-weight: var(--asgardeo-typography-fontWeight-medium); + color: var(--asgardeo-color-text-primary); + display: block; + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +.asgardeo-select__required { + color: var(--asgardeo-color-error-main); + margin-left: 2px; +} + +.asgardeo-select__input { + width: 100%; + height: var(--asgardeo-input-height); + padding: 0 calc(var(--asgardeo-spacing-unit) * 4) 0 var(--asgardeo-input-paddingX); + border: 1px solid var(--asgardeo-input-borderColor); + border-radius: var(--asgardeo-input-borderRadius); + font-family: var(--asgardeo-typography-fontFamily); + font-size: var(--asgardeo-input-fontSize); + color: var(--asgardeo-color-text-primary); + background-color: var(--asgardeo-color-background-surface); + appearance: none; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--asgardeo-input-paddingX) center; + cursor: pointer; + box-sizing: border-box; + transition: + border-color var(--asgardeo-transition-fast), + box-shadow var(--asgardeo-transition-fast); + outline: none; + line-height: var(--asgardeo-typography-lineHeight-normal); +} +.asgardeo-select__input:focus { + border-color: var(--asgardeo-input-focusBorderColor); + box-shadow: var(--asgardeo-input-focusRing); +} +.asgardeo-select__input:disabled { + background-color: var(--asgardeo-color-background-disabled); + color: var(--asgardeo-color-action-disabled); + cursor: not-allowed; +} + +.asgardeo-select--error .asgardeo-select__input { + border-color: var(--asgardeo-color-error-main); +} +.asgardeo-select--error .asgardeo-select__input:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.asgardeo-select__error { + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-error-contrastText); + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +.asgardeo-select__helper { + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-text-secondary); + line-height: var(--asgardeo-typography-lineHeight-normal); +} +`; + +export default SELECT_CSS; diff --git a/packages/vue/src/components/primitives/Select/Select.ts b/packages/vue/src/components/primitives/Select/Select.ts new file mode 100644 index 000000000..a73b5030d --- /dev/null +++ b/packages/vue/src/components/primitives/Select/Select.ts @@ -0,0 +1,104 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h, type PropType} from 'vue'; + +export interface SelectOption { + disabled?: boolean; + label: string; + value: string; +} + +type SelectProps = Readonly<{ + disabled: boolean; + error: string | undefined; + helperText: string | undefined; + label: string | undefined; + modelValue: string; + name: string | undefined; + options: SelectOption[]; + placeholder: string | undefined; + required: boolean; +}>; + +const Select: Component = defineComponent({ + emits: ['update:modelValue'], + name: 'AsgardeoSelect', + props: { + disabled: {default: false, type: Boolean}, + error: {default: undefined, type: String}, + helperText: {default: undefined, type: String}, + label: {default: undefined, type: String}, + modelValue: {default: '', type: String}, + name: {default: undefined, type: String}, + options: {default: () => [], type: Array as PropType}, + placeholder: {default: undefined, type: String}, + required: {default: false, type: Boolean}, + }, + setup(props: SelectProps, {emit, attrs}: SetupContext): () => VNode { + return (): VNode => { + const hasError: boolean = !!props.error; + const wrapperClass: string = [ + withVendorCSSClassPrefix('select'), + hasError ? withVendorCSSClassPrefix('select--error') : '', + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '); + + let helperContent: VNode | null; + if (hasError) { + helperContent = h('span', {class: withVendorCSSClassPrefix('select__error')}, props.error); + } else if (props.helperText) { + helperContent = h('span', {class: withVendorCSSClassPrefix('select__helper')}, props.helperText); + } else { + helperContent = null; + } + + return h('div', {class: wrapperClass, style: attrs['style']}, [ + props.label + ? h('label', {class: withVendorCSSClassPrefix('select__label'), for: props.name}, [ + props.label, + props.required ? h('span', {class: withVendorCSSClassPrefix('select__required')}, ' *') : null, + ]) + : null, + h( + 'select', + { + class: withVendorCSSClassPrefix('select__input'), + 'data-testid': attrs['data-testid'], + disabled: props.disabled, + id: props.name, + name: props.name, + onChange: (e: Event) => emit('update:modelValue', (e.target as HTMLSelectElement).value), + required: props.required, + value: props.modelValue, + }, + [ + props.placeholder ? h('option', {disabled: true, value: ''}, props.placeholder) : null, + ...props.options.map((opt: SelectOption) => h('option', {key: opt.value, value: opt.value}, opt.label)), + ], + ), + helperContent, + ]); + }; + }, +}); + +export default Select; diff --git a/packages/vue/src/components/primitives/Select/index.ts b/packages/vue/src/components/primitives/Select/index.ts new file mode 100644 index 000000000..3821a426f --- /dev/null +++ b/packages/vue/src/components/primitives/Select/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './Select'; diff --git a/packages/vue/src/components/primitives/Spinner/Spinner.css.ts b/packages/vue/src/components/primitives/Spinner/Spinner.css.ts new file mode 100644 index 000000000..8c2525156 --- /dev/null +++ b/packages/vue/src/components/primitives/Spinner/Spinner.css.ts @@ -0,0 +1,73 @@ +/** + * 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. + */ + +/** + * Styles for the Spinner primitive component. + * + * BEM block: `.asgardeo-spinner` + * + * Modifiers: + * Size: --small | --medium | --large + * + * Elements: + * __svg | __circle + * + * Note: The `asgardeo-spin` and `asgardeo-spinner-dash` keyframe animations + * are defined in `styles/animations.css.ts` and shared with the Button component. + */ +const SPINNER_CSS: string = ` +/* ============================================================ + Spinner + ============================================================ */ + +.asgardeo-spinner { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--asgardeo-color-primary-main); +} + +.asgardeo-spinner--small { + width: calc(var(--asgardeo-spacing-unit) * 2); + height: calc(var(--asgardeo-spacing-unit) * 2); +} + +.asgardeo-spinner--medium { + width: calc(var(--asgardeo-spacing-unit) * 2.5); + height: calc(var(--asgardeo-spacing-unit) * 2.5); +} + +.asgardeo-spinner--large { + width: calc(var(--asgardeo-spacing-unit) * 3.5); + height: calc(var(--asgardeo-spacing-unit) * 3.5); +} + +.asgardeo-spinner__svg { + width: 100%; + height: 100%; + animation: asgardeo-spin 1.4s linear infinite; +} + +.asgardeo-spinner__circle { + stroke-dasharray: 80, 200; + stroke-dashoffset: 0; + animation: asgardeo-spinner-dash 1.4s ease-in-out infinite; +} +`; + +export default SPINNER_CSS; diff --git a/packages/vue/src/components/primitives/Spinner/Spinner.ts b/packages/vue/src/components/primitives/Spinner/Spinner.ts new file mode 100644 index 000000000..0241512ac --- /dev/null +++ b/packages/vue/src/components/primitives/Spinner/Spinner.ts @@ -0,0 +1,77 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h, type PropType} from 'vue'; + +type SpinnerProps = Readonly<{ + size: 'small' | 'medium' | 'large'; +}>; + +const Spinner: Component = defineComponent({ + name: 'Spinner', + props: { + size: { + default: 'medium', + type: String as PropType<'small' | 'medium' | 'large'>, + }, + }, + setup(props: SpinnerProps, {attrs}: SetupContext): () => VNode { + return (): VNode => + h( + 'div', + { + 'aria-label': 'Loading', + class: [ + withVendorCSSClassPrefix('spinner'), + withVendorCSSClassPrefix(`spinner--${props.size}`), + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '), + role: 'status', + style: attrs['style'], + }, + [ + h( + 'svg', + { + class: withVendorCSSClassPrefix('spinner__svg'), + fill: 'none', + viewBox: '0 0 24 24', + xmlns: 'http://www.w3.org/2000/svg', + }, + [ + h('circle', { + class: withVendorCSSClassPrefix('spinner__circle'), + cx: '12', + cy: '12', + r: '10', + stroke: 'currentColor', + 'stroke-dasharray': '31.4 31.4', + 'stroke-linecap': 'round', + 'stroke-width': '3', + }), + ], + ), + ], + ); + }, +}); + +export default Spinner; diff --git a/packages/vue/src/components/primitives/Spinner/index.ts b/packages/vue/src/components/primitives/Spinner/index.ts new file mode 100644 index 000000000..08c39988c --- /dev/null +++ b/packages/vue/src/components/primitives/Spinner/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './Spinner'; diff --git a/packages/vue/src/components/primitives/TextField/TextField.css.ts b/packages/vue/src/components/primitives/TextField/TextField.css.ts new file mode 100644 index 000000000..345a60861 --- /dev/null +++ b/packages/vue/src/components/primitives/TextField/TextField.css.ts @@ -0,0 +1,106 @@ +/** + * 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. + */ + +/** + * Styles for the TextField primitive component. + * + * BEM block: `.asgardeo-text-field` + * + * Modifiers: + * --error – shows validation error state + * + * Elements: + * __label | __required | __input | __error | __helper + */ +const TEXT_FIELD_CSS: string = ` +/* ============================================================ + TextField + ============================================================ */ + +.asgardeo-text-field { + display: flex; + flex-direction: column; + gap: calc(var(--asgardeo-spacing-unit) * 0.5); + font-family: var(--asgardeo-typography-fontFamily); + width: 100%; + box-sizing: border-box; +} + +.asgardeo-text-field__label { + font-size: var(--asgardeo-typography-fontSize-sm); + font-weight: var(--asgardeo-typography-fontWeight-medium); + color: var(--asgardeo-color-text-primary); + display: block; + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +.asgardeo-text-field__required { + color: var(--asgardeo-color-error-main); + margin-left: 2px; +} + +.asgardeo-text-field__input { + width: 100%; + height: var(--asgardeo-input-height); + padding: 0 var(--asgardeo-input-paddingX); + border: 1px solid var(--asgardeo-input-borderColor); + border-radius: var(--asgardeo-input-borderRadius); + font-family: var(--asgardeo-typography-fontFamily); + font-size: var(--asgardeo-input-fontSize); + color: var(--asgardeo-color-text-primary); + background-color: var(--asgardeo-color-background-surface); + box-sizing: border-box; + transition: + border-color var(--asgardeo-transition-fast), + box-shadow var(--asgardeo-transition-fast); + outline: none; +} +.asgardeo-text-field__input:focus { + border-color: var(--asgardeo-input-focusBorderColor); + box-shadow: var(--asgardeo-input-focusRing); +} +.asgardeo-text-field__input::placeholder { + color: var(--asgardeo-color-text-secondary); +} +.asgardeo-text-field__input:disabled { + background-color: var(--asgardeo-color-background-disabled); + color: var(--asgardeo-color-action-disabled); + cursor: not-allowed; +} + +.asgardeo-text-field--error .asgardeo-text-field__input { + border-color: var(--asgardeo-color-error-main); +} +.asgardeo-text-field--error .asgardeo-text-field__input:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + +.asgardeo-text-field__error { + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-error-contrastText); + line-height: var(--asgardeo-typography-lineHeight-normal); +} + +.asgardeo-text-field__helper { + font-size: var(--asgardeo-typography-fontSize-xs); + color: var(--asgardeo-color-text-secondary); + line-height: var(--asgardeo-typography-lineHeight-normal); +} +`; + +export default TEXT_FIELD_CSS; diff --git a/packages/vue/src/components/primitives/TextField/TextField.ts b/packages/vue/src/components/primitives/TextField/TextField.ts new file mode 100644 index 000000000..3704d47b3 --- /dev/null +++ b/packages/vue/src/components/primitives/TextField/TextField.ts @@ -0,0 +1,107 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h, type PropType} from 'vue'; + +type TextFieldProps = Readonly<{ + autoComplete: string | undefined; + disabled: boolean; + error: string | undefined; + helperText: string | undefined; + label: string | undefined; + modelValue: string; + name: string | undefined; + placeholder: string | undefined; + required: boolean; + type: 'text' | 'email' | 'number' | 'tel' | 'url'; +}>; + +const TextField: Component = defineComponent({ + emits: ['update:modelValue', 'blur'], + name: 'TextField', + props: { + autoComplete: {default: undefined, type: String}, + disabled: {default: false, type: Boolean}, + error: {default: undefined, type: String}, + helperText: {default: undefined, type: String}, + label: {default: undefined, type: String}, + modelValue: {default: '', type: String}, + name: {default: undefined, type: String}, + placeholder: {default: undefined, type: String}, + required: {default: false, type: Boolean}, + type: { + default: 'text', + type: String as PropType<'text' | 'email' | 'number' | 'tel' | 'url'>, + }, + }, + setup(props: TextFieldProps, {emit, attrs}: SetupContext): () => VNode { + return (): VNode => { + const hasError: boolean = !!props.error; + const wrapperClass: string = [ + withVendorCSSClassPrefix('text-field'), + hasError ? withVendorCSSClassPrefix('text-field--error') : '', + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '); + + let helperContent: VNode | null; + if (hasError) { + helperContent = h('span', {class: withVendorCSSClassPrefix('text-field__error')}, props.error); + } else if (props.helperText) { + helperContent = h('span', {class: withVendorCSSClassPrefix('text-field__helper')}, props.helperText); + } else { + helperContent = null; + } + + return h('div', {class: wrapperClass, style: attrs['style']}, [ + props.label + ? h( + 'label', + { + class: withVendorCSSClassPrefix('text-field__label'), + for: props.name, + }, + [ + props.label, + props.required ? h('span', {class: withVendorCSSClassPrefix('text-field__required')}, ' *') : null, + ], + ) + : null, + h('input', { + autocomplete: props.autoComplete, + class: withVendorCSSClassPrefix('text-field__input'), + 'data-testid': attrs['data-testid'], + disabled: props.disabled, + id: props.name, + name: props.name, + onBlur: () => emit('blur'), + onInput: (e: Event) => emit('update:modelValue', (e.target as HTMLInputElement).value), + placeholder: props.placeholder, + required: props.required, + type: props.type, + value: props.modelValue, + }), + helperContent, + ]); + }; + }, +}); + +export default TextField; diff --git a/packages/vue/src/components/primitives/TextField/index.ts b/packages/vue/src/components/primitives/TextField/index.ts new file mode 100644 index 000000000..313da8c1c --- /dev/null +++ b/packages/vue/src/components/primitives/TextField/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './TextField'; diff --git a/packages/vue/src/components/primitives/Typography/Typography.css.ts b/packages/vue/src/components/primitives/Typography/Typography.css.ts new file mode 100644 index 000000000..d4e7847d9 --- /dev/null +++ b/packages/vue/src/components/primitives/Typography/Typography.css.ts @@ -0,0 +1,120 @@ +/** + * 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. + */ + +/** + * Styles for the Typography primitive component. + * + * BEM block: `.asgardeo-typography` + * + * Modifiers (variant): + * --h1 | --h2 | --h3 | --h4 | --h5 | --h6 + * --subtitle1 | --subtitle2 + * --body1 | --body2 + * --caption | --overline + */ +const TYPOGRAPHY_CSS: string = ` +/* ============================================================ + Typography + ============================================================ */ + +.asgardeo-typography { + font-family: var(--asgardeo-typography-fontFamily); + color: var(--asgardeo-color-text-primary); + margin: 0; + line-height: var(--asgardeo-typography-lineHeight-normal); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.asgardeo-typography--h1 { + font-size: var(--asgardeo-typography-fontSize-3xl); + font-weight: var(--asgardeo-typography-fontWeight-bold); + line-height: var(--asgardeo-typography-lineHeight-tight); + letter-spacing: var(--asgardeo-typography-letterSpacing-tight); +} + +.asgardeo-typography--h2 { + font-size: var(--asgardeo-typography-fontSize-2xl); + font-weight: var(--asgardeo-typography-fontWeight-bold); + line-height: var(--asgardeo-typography-lineHeight-tight); + letter-spacing: var(--asgardeo-typography-letterSpacing-tight); +} + +.asgardeo-typography--h3 { + font-size: var(--asgardeo-typography-fontSize-xl); + font-weight: var(--asgardeo-typography-fontWeight-semibold); + line-height: var(--asgardeo-typography-lineHeight-tight); +} + +.asgardeo-typography--h4 { + font-size: var(--asgardeo-typography-fontSize-lg); + font-weight: var(--asgardeo-typography-fontWeight-semibold); +} + +.asgardeo-typography--h5 { + font-size: var(--asgardeo-typography-fontSize-md); + font-weight: var(--asgardeo-typography-fontWeight-semibold); +} + +.asgardeo-typography--h6 { + font-size: var(--asgardeo-typography-fontSize-sm); + font-weight: var(--asgardeo-typography-fontWeight-semibold); + text-transform: uppercase; + letter-spacing: var(--asgardeo-typography-letterSpacing-wide); +} + +.asgardeo-typography--subtitle1 { + font-size: var(--asgardeo-typography-fontSize-lg); + font-weight: var(--asgardeo-typography-fontWeight-medium); +} + +.asgardeo-typography--subtitle2 { + font-size: var(--asgardeo-typography-fontSize-md); + font-weight: var(--asgardeo-typography-fontWeight-medium); + color: var(--asgardeo-color-text-secondary); +} + +.asgardeo-typography--body1 { + font-size: var(--asgardeo-typography-fontSize-md); + font-weight: var(--asgardeo-typography-fontWeight-normal); + line-height: var(--asgardeo-typography-lineHeight-relaxed); +} + +.asgardeo-typography--body2 { + font-size: var(--asgardeo-typography-fontSize-sm); + font-weight: var(--asgardeo-typography-fontWeight-normal); + line-height: var(--asgardeo-typography-lineHeight-relaxed); + color: var(--asgardeo-color-text-secondary); +} + +.asgardeo-typography--caption { + font-size: var(--asgardeo-typography-fontSize-xs); + font-weight: var(--asgardeo-typography-fontWeight-normal); + color: var(--asgardeo-color-text-secondary); +} + +.asgardeo-typography--overline { + font-size: var(--asgardeo-typography-fontSize-xs); + font-weight: var(--asgardeo-typography-fontWeight-medium); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--asgardeo-color-text-secondary); +} +`; + +export default TYPOGRAPHY_CSS; diff --git a/packages/vue/src/components/primitives/Typography/Typography.ts b/packages/vue/src/components/primitives/Typography/Typography.ts new file mode 100644 index 000000000..cc694c6bc --- /dev/null +++ b/packages/vue/src/components/primitives/Typography/Typography.ts @@ -0,0 +1,90 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {type Component, type SetupContext, type VNode, defineComponent, h, type PropType} from 'vue'; + +type TypographyProps = Readonly<{ + component: string | undefined; + variant: + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'h6' + | 'subtitle1' + | 'subtitle2' + | 'body1' + | 'body2' + | 'caption' + | 'overline'; +}>; + +const Typography: Component = defineComponent({ + name: 'Typography', + props: { + component: { + default: undefined, + type: String as PropType, + }, + variant: { + default: 'body1', + type: String as PropType< + 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'subtitle1' | 'subtitle2' | 'body1' | 'body2' | 'caption' | 'overline' + >, + }, + }, + setup(props: TypographyProps, {slots, attrs}: SetupContext): () => VNode { + return (): VNode => { + const tagMap: Record = { + body1: 'p', + body2: 'p', + caption: 'span', + h1: 'h1', + h2: 'h2', + h3: 'h3', + h4: 'h4', + h5: 'h5', + h6: 'h6', + overline: 'span', + subtitle1: 'h6', + subtitle2: 'h6', + }; + + const tag: string = props.component || tagMap[props.variant] || 'p'; + + return h( + tag, + { + class: [ + withVendorCSSClassPrefix('typography'), + withVendorCSSClassPrefix(`typography--${props.variant}`), + (attrs['class'] as string) || '', + ] + .filter(Boolean) + .join(' '), + style: attrs['style'], + }, + slots['default']?.(), + ); + }; + }, +}); + +export default Typography; diff --git a/packages/vue/src/components/primitives/Typography/index.ts b/packages/vue/src/components/primitives/Typography/index.ts new file mode 100644 index 000000000..7bcde370b --- /dev/null +++ b/packages/vue/src/components/primitives/Typography/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, 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. + */ + +export {default} from './Typography'; diff --git a/packages/vue/src/composables/useAsgardeo.ts b/packages/vue/src/composables/useAsgardeo.ts index 0f29f297d..53b39d390 100644 --- a/packages/vue/src/composables/useAsgardeo.ts +++ b/packages/vue/src/composables/useAsgardeo.ts @@ -16,15 +16,68 @@ * under the License. */ -import {useAsgardeoContext} from './useAsgardeoContext'; -import {AuthContextInterface} from '../types'; +import {resolveFlowTemplateLiterals, type FlowMetadataResponse} from '@asgardeo/browser'; +import {inject, ref, type Ref} from 'vue'; +import {ASGARDEO_KEY, FLOW_META_KEY, I18N_KEY} from '../keys'; +import type {AsgardeoContext, FlowMetaContextValue, I18nContextValue} from '../models/contexts'; /** - * Hook to access the Asgardeo authentication context. + * Primary composable for Asgardeo authentication. * - * @returns {AuthContextInterface} The authentication context containing authentication methods and state. + * Must be called inside a component that is a descendant of ``. + * Returns all auth-related reactive state and action methods. + * + * @throws Error if called outside of ``. + * + * @example + * ```vue + * + * + * + * ``` */ -export function useAsgardeo(): AuthContextInterface { - const asgardeo: AuthContextInterface = useAsgardeoContext(); - return asgardeo; -} +const useAsgardeo = (): AsgardeoContext => { + const context: unknown = inject(ASGARDEO_KEY); + + if (!context) { + throw new Error( + '[Asgardeo] useAsgardeo() was called outside of . ' + + 'Make sure to install the AsgardeoPlugin or wrap your app with .', + ); + } + + // FlowMetaContext lives inside AsgardeoProvider, so it is always present in + // normal usage. Optional chaining keeps the composable safe in unit tests that + // don't render FlowMetaProvider. + const flowMetaContext: FlowMetaContextValue | null = inject(FLOW_META_KEY, null) as FlowMetaContextValue | null; + + // I18nContext provides the translation function. + const i18nContext: I18nContextValue | null = inject(I18N_KEY, null) as I18nContextValue | null; + + const meta: Ref = flowMetaContext?.meta ?? ref(null); + + return { + ...(context as AsgardeoContext), + meta: meta as Readonly>, + resolveFlowTemplateLiterals: (text: string | undefined): string => + resolveFlowTemplateLiterals(text, { + meta: meta.value, + t: i18nContext?.t ?? ((key: string): string => key), + }), + }; +}; + +export default useAsgardeo; diff --git a/packages/vue/src/composables/useBranding.ts b/packages/vue/src/composables/useBranding.ts new file mode 100644 index 000000000..0f7726f69 --- /dev/null +++ b/packages/vue/src/composables/useBranding.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 {inject} from 'vue'; +import {BRANDING_KEY} from '../keys'; +import type {BrandingContextValue} from '../models/contexts'; + +/** + * Composable for accessing branding preference data. + * + * Must be called inside a component that is a descendant of ``. + * + * @returns {BrandingContextValue} The branding context with preferences, theme, and fetch operations. + * @throws {Error} If called outside of ``. + * + * @example + * ```vue + * + * + * + * ``` + */ +const useBranding = (): BrandingContextValue => { + const context: unknown = inject(BRANDING_KEY); + + if (!context) { + throw new Error( + '[Asgardeo] useBranding() was called outside of . ' + + 'Make sure to install the AsgardeoPlugin or wrap your app with .', + ); + } + + return context as BrandingContextValue; +}; + +export default useBranding; diff --git a/packages/vue/src/composables/useFlow.ts b/packages/vue/src/composables/useFlow.ts new file mode 100644 index 000000000..6c046d534 --- /dev/null +++ b/packages/vue/src/composables/useFlow.ts @@ -0,0 +1,61 @@ +/** + * 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 {inject} from 'vue'; +import {FLOW_KEY} from '../keys'; +import type {FlowContextValue} from '../models/contexts'; + +/** + * Composable for managing authentication flow UI state. + * + * Must be called inside a component that is a descendant of ``. + * + * @returns {FlowContextValue} The flow context with step navigation, messages, and loading state. + * @throws {Error} If called outside of ``. + * + * @example + * ```vue + * + * + * + * ``` + */ +const useFlow = (): FlowContextValue => { + const context: unknown = inject(FLOW_KEY); + + if (!context) { + throw new Error( + '[Asgardeo] useFlow() was called outside of . ' + + 'Make sure to install the AsgardeoPlugin or wrap your app with .', + ); + } + + return context as FlowContextValue; +}; + +export default useFlow; diff --git a/packages/vue/src/composables/useFlowMeta.ts b/packages/vue/src/composables/useFlowMeta.ts new file mode 100644 index 000000000..924c57f20 --- /dev/null +++ b/packages/vue/src/composables/useFlowMeta.ts @@ -0,0 +1,57 @@ +/** + * 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 {inject} from 'vue'; +import {FLOW_META_KEY} from '../keys'; +import type {FlowMetaContextValue} from '../models/contexts'; + +/** + * Composable for accessing flow metadata. + * + * Must be called inside a component that is a descendant of ``. + * + * @returns {FlowMetaContextValue} The flow meta context with metadata, loading state, and language switching. + * @throws {Error} If called outside of ``. + * + * @example + * ```vue + * + * ``` + */ +const useFlowMeta = (): FlowMetaContextValue => { + const context: unknown = inject(FLOW_META_KEY); + + if (!context) { + throw new Error( + '[Asgardeo] useFlowMeta() was called outside of . ' + + 'Make sure to install the AsgardeoPlugin or wrap your app with .', + ); + } + + return context as FlowMetaContextValue; +}; + +export default useFlowMeta; diff --git a/packages/vue/src/composables/useI18n.ts b/packages/vue/src/composables/useI18n.ts new file mode 100644 index 000000000..5e7911dd7 --- /dev/null +++ b/packages/vue/src/composables/useI18n.ts @@ -0,0 +1,61 @@ +/** + * 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 {inject} from 'vue'; +import {I18N_KEY} from '../keys'; +import type {I18nContextValue} from '../models/contexts'; + +/** + * Composable for accessing internationalization utilities. + * + * Must be called inside a component that is a descendant of ``. + * + * @returns {I18nContextValue} The i18n context with translation function, language management, and bundle injection. + * @throws {Error} If called outside of ``. + * + * @example + * ```vue + * + * + * + * ``` + */ +const useI18n = (): I18nContextValue => { + const context: unknown = inject(I18N_KEY); + + if (!context) { + throw new Error( + '[Asgardeo] useI18n() was called outside of . ' + + 'Make sure to install the AsgardeoPlugin or wrap your app with .', + ); + } + + return context as I18nContextValue; +}; + +export default useI18n; diff --git a/packages/vue/src/composables/useOAuthCallback.ts b/packages/vue/src/composables/useOAuthCallback.ts new file mode 100644 index 000000000..638cf1cbb --- /dev/null +++ b/packages/vue/src/composables/useOAuthCallback.ts @@ -0,0 +1,193 @@ +/** + * Copyright (c) 2026, 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 {watch, type Ref} from 'vue'; + +export interface UseOAuthCallbackOptions { + /** Current flowId from component state */ + currentFlowId: Ref; + + /** SessionStorage key for flowId (defaults to 'asgardeo_flow_id') */ + flowIdStorageKey?: string; + + /** Whether the component is initialized and ready to process OAuth callback */ + isInitialized: Ref; + + /** Whether a submission is currently in progress */ + isSubmitting?: Ref; + + /** Callback when OAuth flow completes successfully */ + onComplete?: () => void; + + /** Callback when OAuth flow encounters an error */ + onError?: (error: any) => void; + + /** Callback to handle flow response after submission */ + onFlowChange?: (response: any) => void; + + /** Callback to set loading state at the start of OAuth processing */ + onProcessingStart?: () => void; + + /** Function to submit OAuth code to the server */ + onSubmit: (payload: OAuthCallbackPayload) => Promise; + + /** Mutable flag to track whether OAuth has already been processed */ + processedFlag?: {value: boolean}; + + /** Additional handler for setting state (e.g., setFlowId) */ + setFlowId?: (flowId: string) => void; + + /** + * Mutable flag for token validation tracking. + * Used in AcceptInvite to coordinate between OAuth callback and token validation. + */ + tokenValidationAttemptedFlag?: {value: boolean}; +} + +export interface OAuthCallbackPayload { + flowId: string; + inputs: { + code: string; + nonce?: string; + }; +} + +function cleanupUrlParams(): void { + if (typeof window === 'undefined') return; + + const url: URL = new URL(window.location.href); + url.searchParams.delete('code'); + url.searchParams.delete('nonce'); + url.searchParams.delete('state'); + url.searchParams.delete('error'); + url.searchParams.delete('error_description'); + + window.history.replaceState({}, '', url.toString()); +} + +/** + * Processes OAuth callbacks by detecting auth code in URL, resolving flowId, and submitting to server. + * Used by SignIn, SignUp, and AcceptInvite components. + * + * Vue composable equivalent of React's useOAuthCallback hook. + */ +export function useOAuthCallback({ + currentFlowId, + flowIdStorageKey = 'asgardeo_flow_id', + isInitialized, + isSubmitting, + onComplete, + onError, + onFlowChange, + onProcessingStart, + onSubmit, + processedFlag, + setFlowId, + tokenValidationAttemptedFlag, +}: UseOAuthCallbackOptions): void { + const internalFlag: {value: boolean} = {value: false}; + const oauthCodeProcessedFlag: {value: boolean} = processedFlag ?? internalFlag; + const tokenValidationFlag: {value: boolean} | undefined = tokenValidationAttemptedFlag; + + watch( + () => [isInitialized.value, currentFlowId.value, isSubmitting?.value] as const, + ([initialized, , submitting]: readonly [boolean, string | null, boolean | undefined]) => { + if (!initialized || submitting) { + return; + } + + const urlParams: URLSearchParams = new URLSearchParams(window.location.search); + const code: string | null = urlParams.get('code'); + const nonce: string | null = urlParams.get('nonce'); + const state: string | null = urlParams.get('state'); + const flowIdFromUrl: string | null = urlParams.get('flowId'); + const error: string | null = urlParams.get('error'); + const errorDescription: string | null = urlParams.get('error_description'); + + if (error) { + oauthCodeProcessedFlag.value = true; + if (tokenValidationFlag) { + tokenValidationFlag.value = true; + } + onError?.(new Error(errorDescription || error || 'OAuth authentication failed')); + cleanupUrlParams(); + return; + } + + if (!code || oauthCodeProcessedFlag.value) { + return; + } + + if (tokenValidationFlag?.value) { + return; + } + + const storedFlowId: string | null = sessionStorage.getItem(flowIdStorageKey); + const flowIdToUse: string | null = currentFlowId.value || storedFlowId || flowIdFromUrl || state || null; + + if (!flowIdToUse) { + oauthCodeProcessedFlag.value = true; + onError?.(new Error('Invalid flow. Missing flowId.')); + cleanupUrlParams(); + return; + } + + oauthCodeProcessedFlag.value = true; + + if (tokenValidationFlag) { + tokenValidationFlag.value = true; + } + + onProcessingStart?.(); + + if (!currentFlowId.value && setFlowId) { + setFlowId(flowIdToUse); + } + + (async (): Promise => { + try { + const payload: OAuthCallbackPayload = { + flowId: flowIdToUse, + inputs: { + code, + ...(nonce && {nonce}), + }, + }; + + const response: any = await onSubmit(payload); + + onFlowChange?.(response); + + if (response?.flowStatus === 'COMPLETE' || response?.status === 'COMPLETE') { + onComplete?.(); + } + + if (response?.flowStatus === 'ERROR' || response?.status === 'ERROR') { + onError?.(response); + } + + cleanupUrlParams(); + } catch (err) { + onError?.(err); + cleanupUrlParams(); + } + })(); + }, + {immediate: true}, + ); +} diff --git a/packages/vue/src/composables/useOrganization.ts b/packages/vue/src/composables/useOrganization.ts new file mode 100644 index 000000000..7a72d2627 --- /dev/null +++ b/packages/vue/src/composables/useOrganization.ts @@ -0,0 +1,57 @@ +/** + * 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 {inject} from 'vue'; +import {ORGANIZATION_KEY} from '../keys'; +import type {OrganizationContextValue} from '../models/contexts'; + +/** + * Composable for accessing organization data and operations. + * + * Must be called inside a component that is a descendant of ``. + * + * @returns {OrganizationContextValue} The organization context. + * @throws {Error} If called outside of ``. + * + * @example + * ```vue + * + * ``` + */ +const useOrganization = (): OrganizationContextValue => { + const context: unknown = inject(ORGANIZATION_KEY); + + if (!context) { + throw new Error( + '[Asgardeo] useOrganization() was called outside of . ' + + 'Make sure to install the AsgardeoPlugin or wrap your app with .', + ); + } + + return context as OrganizationContextValue; +}; + +export default useOrganization; diff --git a/packages/vue/src/composables/useTheme.ts b/packages/vue/src/composables/useTheme.ts new file mode 100644 index 000000000..9b187c5fe --- /dev/null +++ b/packages/vue/src/composables/useTheme.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 {inject} from 'vue'; +import {THEME_KEY} from '../keys'; +import type {ThemeContextValue} from '../models/contexts'; + +/** + * Composable for accessing and controlling the active theme. + * + * Must be called inside a component that is a descendant of ``. + * + * @returns {ThemeContextValue} The theme context with the active theme, color scheme, and toggle function. + * @throws {Error} If called outside of ``. + * + * @example + * ```vue + * + * + * + * ``` + */ +const useTheme = (): ThemeContextValue => { + const context: unknown = inject(THEME_KEY); + + if (!context) { + throw new Error( + '[Asgardeo] useTheme() was called outside of . ' + + 'Make sure to install the AsgardeoPlugin or wrap your app with .', + ); + } + + return context as ThemeContextValue; +}; + +export default useTheme; diff --git a/packages/vue/src/composables/useUser.ts b/packages/vue/src/composables/useUser.ts new file mode 100644 index 000000000..b490fa813 --- /dev/null +++ b/packages/vue/src/composables/useUser.ts @@ -0,0 +1,60 @@ +/** + * 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 {inject} from 'vue'; +import {USER_KEY} from '../keys'; +import type {UserContextValue} from '../models/contexts'; + +/** + * Composable for accessing user profile data. + * + * Must be called inside a component that is a descendant of ``. + * + * @returns {UserContextValue} The user context containing profile, schemas, and update operations. + * @throws {Error} If called outside of ``. + * + * @example + * ```vue + * + * + * + * ``` + */ +const useUser = (): UserContextValue => { + const context: unknown = inject(USER_KEY); + + if (!context) { + throw new Error( + '[Asgardeo] useUser() was called outside of . ' + + 'Make sure to install the AsgardeoPlugin or wrap your app with .', + ); + } + + return context as UserContextValue; +}; + +export default useUser; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 6afdfd2f1..ea7925d59 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -16,7 +16,229 @@ * under the License. */ -export * from './public-api'; -export * from './types'; +// ── Plugin ── +export {default as AsgardeoPlugin} from './plugins/AsgardeoPlugin'; -export * from '@asgardeo/auth-spa'; +// ── Components ── +export {default as AsgardeoProvider} from './providers/AsgardeoProvider'; + +// ── Providers ── +export {default as BrandingProvider} from './providers/BrandingProvider'; +export {default as FlowMetaProvider} from './providers/FlowMetaProvider'; +export {default as FlowProvider} from './providers/FlowProvider'; +export {default as I18nProvider} from './providers/I18nProvider'; +export {default as OrganizationProvider} from './providers/OrganizationProvider'; +export {default as ThemeProvider} from './providers/ThemeProvider'; +export {default as UserProvider} from './providers/UserProvider'; + +// ── Composables ── +export {default as useAsgardeo} from './composables/useAsgardeo'; +export {default as useBranding} from './composables/useBranding'; +export {default as useFlow} from './composables/useFlow'; +export {default as useFlowMeta} from './composables/useFlowMeta'; +export {default as useI18n} from './composables/useI18n'; +export {default as useOrganization} from './composables/useOrganization'; +export {default as useTheme} from './composables/useTheme'; +export {default as useUser} from './composables/useUser'; +export {useOAuthCallback} from './composables/useOAuthCallback'; +export type {UseOAuthCallbackOptions, OAuthCallbackPayload} from './composables/useOAuthCallback'; + +// ── Client ── +export {default as AsgardeoVueClient} from './AsgardeoVueClient'; + +// ── Keys ── +export { + ASGARDEO_KEY, + BRANDING_KEY, + FLOW_KEY, + FLOW_META_KEY, + I18N_KEY, + ORGANIZATION_KEY, + THEME_KEY, + USER_KEY, +} from './keys'; + +// ── Models / Types ── +export type {AsgardeoVueConfig} from './models/config'; +export type { + AsgardeoContext, + BrandingContextValue, + FlowContextValue, + FlowMessage, + FlowMetaContextValue, + FlowStep, + I18nContextValue, + OrganizationContextValue, + ThemeContextValue, + UserContextValue, +} from './models/contexts'; + +// ── UI Components β€” Primitives ── +export {default as Button} from './components/primitives/Button/Button'; +export {default as Card} from './components/primitives/Card/Card'; +export {default as Alert} from './components/primitives/Alert/Alert'; +export {default as TextField} from './components/primitives/TextField/TextField'; +export {default as PasswordField} from './components/primitives/PasswordField/PasswordField'; +export {default as Select} from './components/primitives/Select/Select'; +export type {SelectOption} from './components/primitives/Select/Select'; +export {default as Checkbox} from './components/primitives/Checkbox/Checkbox'; +export {default as DatePicker} from './components/primitives/DatePicker/DatePicker'; +export {default as OtpField} from './components/primitives/OtpField/OtpField'; +export {default as Typography} from './components/primitives/Typography/Typography'; +export {default as Divider} from './components/primitives/Divider/Divider'; +export {default as Logo} from './components/primitives/Logo/Logo'; +export {default as Spinner} from './components/primitives/Spinner/Spinner'; +export { + UserIcon, + EyeIcon, + EyeOffIcon, + ChevronDownIcon, + CheckIcon, + CircleAlertIcon, + CircleCheckIcon, + InfoIcon, + TriangleAlertIcon, + XIcon, + PlusIcon, + LogOutIcon, + ArrowLeftRightIcon, + BuildingIcon, + GlobeIcon, + PencilIcon, +} from './components/primitives/Icons'; + +// ── UI Components β€” Actions ── +export {default as SignInButton} from './components/actions/SignInButton'; +export {default as BaseSignInButton} from './components/actions/BaseSignInButton'; +export {default as SignOutButton} from './components/actions/SignOutButton'; +export {default as BaseSignOutButton} from './components/actions/BaseSignOutButton'; +export {default as SignUpButton} from './components/actions/SignUpButton'; +export {default as BaseSignUpButton} from './components/actions/BaseSignUpButton'; + +// ── UI Components β€” Auth Flow ── +export {default as Callback} from './components/auth/Callback'; + +// ── UI Components β€” Control ── +export {default as SignedIn} from './components/control/SignedIn'; +export {default as SignedOut} from './components/control/SignedOut'; +export {default as Loading} from './components/control/Loading'; +export {default as UserComponent} from './components/control/user/User'; +export {default as OrganizationComponent} from './components/control/organization/Organization'; + +// ── UI Components β€” Presentation ── +export {default as SignIn} from './components/presentation/sign-in/SignIn'; +export type {SignInRenderProps} from './components/presentation/sign-in/SignIn'; +export {default as BaseSignIn} from './components/presentation/sign-in/BaseSignIn'; +export type {BaseSignInRenderProps, BaseSignInProps} from './components/presentation/sign-in/BaseSignIn'; +export {default as SignUp} from './components/presentation/sign-up/SignUp'; +export type {SignUpRenderProps} from './components/presentation/sign-up/SignUp'; +export {default as BaseSignUp} from './components/presentation/sign-up/BaseSignUp'; +export type {BaseSignUpRenderProps, BaseSignUpProps} from './components/presentation/sign-up/BaseSignUp'; +export {default as UserProfile} from './components/presentation/user-profile/UserProfile'; +export {default as BaseUserProfile} from './components/presentation/user-profile/BaseUserProfile'; +export {default as UserDropdown} from './components/presentation/user-dropdown/UserDropdown'; +export {default as BaseUserDropdown} from './components/presentation/user-dropdown/BaseUserDropdown'; +export {default as AcceptInvite} from './components/presentation/accept-invite/AcceptInvite'; +export type {AcceptInviteRenderProps} from './components/presentation/accept-invite/AcceptInvite'; +export {default as BaseAcceptInvite} from './components/presentation/accept-invite/BaseAcceptInvite'; +export type { + BaseAcceptInviteRenderProps, + BaseAcceptInviteProps, +} from './components/presentation/accept-invite/BaseAcceptInvite'; +export {default as InviteUser} from './components/presentation/invite-user/InviteUser'; +export type {InviteUserRenderProps} from './components/presentation/invite-user/InviteUser'; +export {default as BaseInviteUser} from './components/presentation/invite-user/BaseInviteUser'; +export type { + BaseInviteUserRenderProps, + BaseInviteUserProps, +} from './components/presentation/invite-user/BaseInviteUser'; +export {default as OrganizationList} from './components/presentation/organization-list/OrganizationList'; +export {default as BaseOrganizationList} from './components/presentation/organization-list/BaseOrganizationList'; +export {default as OrganizationProfile} from './components/presentation/organization-profile/OrganizationProfile'; +export {default as BaseOrganizationProfile} from './components/presentation/organization-profile/BaseOrganizationProfile'; +export {default as OrganizationSwitcher} from './components/presentation/organization-switcher/OrganizationSwitcher'; +export {default as BaseOrganizationSwitcher} from './components/presentation/organization-switcher/BaseOrganizationSwitcher'; +export {default as CreateOrganization} from './components/presentation/create-organization/CreateOrganization'; +export {default as BaseCreateOrganization} from './components/presentation/create-organization/BaseCreateOrganization'; +export {default as LanguageSwitcher} from './components/presentation/language-switcher/LanguageSwitcher'; +export {default as BaseLanguageSwitcher} from './components/presentation/language-switcher/BaseLanguageSwitcher'; + +// ── UI Components β€” Adapters ── +export {default as GoogleButton} from './components/adapters/GoogleButton'; +export {default as GitHubButton} from './components/adapters/GitHubButton'; +export {default as MicrosoftButton} from './components/adapters/MicrosoftButton'; +export {default as FacebookButton} from './components/adapters/FacebookButton'; + +// ── Factories ── +export {default as FieldFactory, createField, validateFieldValue} from './components/factories/FieldFactory'; +export type {FieldConfig} from './components/factories/FieldFactory'; + +// ── Utilities ── +export {default as buildThemeConfigFromFlowMeta} from './utils/v2/buildThemeConfigFromFlowMeta'; +export {default as getAuthComponentHeadings} from './utils/v2/getAuthComponentHeadings'; +export type {HeadingExtractionResult, AuthComponentHeadingsResult} from './utils/v2/getAuthComponentHeadings'; + +// ── Re-exports from @asgardeo/browser ── +export { + FieldType, + type AllOrganizationsApiResponse, + type Config, + type EmbeddedFlowExecuteRequestPayload, + type EmbeddedFlowExecuteResponse, + type EmbeddedSignInFlowHandleRequestPayload, + type HttpRequestConfig, + type HttpResponse, + type IdToken, + type Organization, + type SignInOptions, + type SignOutOptions, + type SignUpOptions, + type TokenExchangeRequestConfig, + type TokenResponse, + type User, // type UserProfile, +} from '@asgardeo/browser'; + +// ── Phase 4 β€” Utilities ── +export {handleWebAuthnAuthentication} from './utils/handleWebAuthnAuthentication'; +export {hasAuthParamsInUrl} from './utils/hasAuthParamsInUrl'; +export {navigate} from './utils/navigate'; +export {http} from './utils/http'; +export {initiateOAuthRedirect} from './utils/oauth'; + +// ── Phase 4 β€” Router Helpers ── +export {createAsgardeoGuard} from './router/guard'; +export type {GuardOptions, AsgardeoNavigationGuard, NavigationGuardReturn} from './router/guard'; +export {createCallbackRoute} from './router/callbackRoute'; +export type {CallbackRouteOptions, AsgardeoRouteRecord} from './router/callbackRoute'; + +// ── Phase 4 β€” Theme Utilities ── +export {getActiveTheme} from './theme/getActiveTheme'; +export {detectThemeMode, createClassObserver, createMediaQueryListener} from './theme/themeDetection'; +export type {BrowserThemeDetection} from './theme/themeDetection'; + +// ── Phase 4 β€” Re-exports from @asgardeo/browser (V2 embedded flow models) ── +export { + AsgardeoRuntimeError, + EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, + EmbeddedFlowActionVariantV2 as EmbeddedFlowActionVariant, + EmbeddedFlowTextVariantV2 as EmbeddedFlowTextVariant, + EmbeddedFlowEventTypeV2 as EmbeddedFlowEventType, + type EmbeddedFlowComponentV2 as EmbeddedFlowComponent, + type EmbeddedFlowResponseDataV2 as EmbeddedFlowResponseData, + type EmbeddedFlowExecuteRequestConfigV2 as EmbeddedFlowExecuteRequestConfig, + EmbeddedSignInFlowStatusV2 as EmbeddedSignInFlowStatus, + EmbeddedSignInFlowTypeV2 as EmbeddedSignInFlowType, + type ExtendedEmbeddedSignInFlowResponseV2 as ExtendedEmbeddedSignInFlowResponse, + type EmbeddedSignInFlowResponseV2 as EmbeddedSignInFlowResponse, + type EmbeddedSignInFlowCompleteResponseV2 as EmbeddedSignInFlowCompleteResponse, + type EmbeddedSignInFlowInitiateRequestV2 as EmbeddedSignInFlowInitiateRequest, + type EmbeddedSignInFlowRequestV2 as EmbeddedSignInFlowRequest, + type EmbeddedSignUpFlowStatusV2 as EmbeddedSignUpFlowStatus, + type EmbeddedSignUpFlowTypeV2 as EmbeddedSignUpFlowType, + type ExtendedEmbeddedSignUpFlowResponseV2 as ExtendedEmbeddedSignUpFlowResponse, + type EmbeddedSignUpFlowResponseV2 as EmbeddedSignUpFlowResponse, + type EmbeddedSignUpFlowCompleteResponseV2 as EmbeddedSignUpFlowCompleteResponse, + type EmbeddedSignUpFlowInitiateRequestV2 as EmbeddedSignUpFlowInitiateRequest, + type EmbeddedSignUpFlowRequestV2 as EmbeddedSignUpFlowRequest, + type EmbeddedSignUpFlowErrorResponseV2 as EmbeddedSignUpFlowErrorResponse, +} from '@asgardeo/browser'; diff --git a/packages/vue/src/keys.ts b/packages/vue/src/keys.ts new file mode 100644 index 000000000..86f910463 --- /dev/null +++ b/packages/vue/src/keys.ts @@ -0,0 +1,69 @@ +/** + * 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 type {InjectionKey} from 'vue'; +import type { + AsgardeoContext, + BrandingContextValue, + FlowContextValue, + FlowMetaContextValue, + I18nContextValue, + OrganizationContextValue, + ThemeContextValue, + UserContextValue, +} from './models/contexts'; + +/** + * Injection key for the core Asgardeo authentication context. + */ +export const ASGARDEO_KEY: InjectionKey = Symbol('asgardeo'); + +/** + * Injection key for the User context (profile, schemas, update operations). + */ +export const USER_KEY: InjectionKey = Symbol('asgardeo-user'); + +/** + * Injection key for the Organization context (list, current org, switching). + */ +export const ORGANIZATION_KEY: InjectionKey = Symbol('asgardeo-organization'); + +/** + * Injection key for the Flow context (embedded flow UI state). + */ +export const FLOW_KEY: InjectionKey = Symbol('asgardeo-flow'); + +/** + * Injection key for the FlowMeta context (server-driven flow metadata). + */ +export const FLOW_META_KEY: InjectionKey = Symbol('asgardeo-flow-meta'); + +/** + * Injection key for the Theme context (color scheme, CSS variables, toggle). + */ +export const THEME_KEY: InjectionKey = Symbol('asgardeo-theme'); + +/** + * Injection key for the Branding context (branding preferences from server). + */ +export const BRANDING_KEY: InjectionKey = Symbol('asgardeo-branding'); + +/** + * Injection key for the I18n context (translation function, language switching). + */ +export const I18N_KEY: InjectionKey = Symbol('asgardeo-i18n'); diff --git a/packages/vue/src/public-api.ts b/packages/vue/src/models/config.ts similarity index 85% rename from packages/vue/src/public-api.ts rename to packages/vue/src/models/config.ts index c22503085..078fe769d 100644 --- a/packages/vue/src/public-api.ts +++ b/packages/vue/src/models/config.ts @@ -16,5 +16,6 @@ * under the License. */ -export {asgardeoPlugin} from './plugins/AsgardeoPlugin'; -export {useAsgardeo} from './composables/useAsgardeo'; +import {AsgardeoBrowserConfig} from '@asgardeo/browser'; + +export type AsgardeoVueConfig = AsgardeoBrowserConfig; diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts new file mode 100644 index 000000000..f39989cb4 --- /dev/null +++ b/packages/vue/src/models/contexts.ts @@ -0,0 +1,313 @@ +/** + * 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 type { + AllOrganizationsApiResponse, + BrandingPreference, + CreateOrganizationPayload, + FlowMetadataResponse, + HttpRequestConfig, + HttpResponse, + IdToken, + Organization, + Schema, + SignInOptions, + Theme, + TokenExchangeRequestConfig, + TokenResponse, + UpdateMeProfileConfig, + User, + UserProfile, +} from '@asgardeo/browser'; +import type {I18nBundle} from '@asgardeo/i18n'; +import type {Ref} from 'vue'; +import type {AsgardeoVueConfig} from './config'; +import type AsgardeoVueClient from '../AsgardeoVueClient'; + +/** + * Shape of the core Asgardeo context provided via `provide`/`inject`. + * + * Reactive refs are exposed as `Readonly>` so consumers can read + * them in templates and `watch()` calls but cannot mutate them directly. + */ +export interface AsgardeoContext { + /** The `afterSignInUrl` from the config. */ + afterSignInUrl: string | undefined; + /** The Asgardeo application ID from the config. */ + applicationId: string | undefined; + /** The base URL of the Asgardeo tenant. */ + baseUrl: string | undefined; + clearSession: (...args: any[]) => void; + /** The OAuth2 client ID. */ + clientId: string | undefined; + + exchangeToken: (config: TokenExchangeRequestConfig) => Promise; + // ── Token ── + getAccessToken: () => Promise; + getDecodedIdToken: () => Promise; + getIdToken: () => Promise; + // ── HTTP ── + http: { + request: (requestConfig?: HttpRequestConfig) => Promise>; + requestAll: (requestConfigs?: HttpRequestConfig[]) => Promise[]>; + }; + + /** The instance ID for multi-instance support. */ + instanceId: number; + // ── Reactive Auth State ── + /** Whether the SDK has finished initializing. */ + isInitialized: Readonly>; + /** Whether the SDK is performing a background operation. */ + isLoading: Readonly>; + /** Whether the user is currently signed in. */ + isSignedIn: Readonly>; + + // ── FlowMeta (injected by useAsgardeo) ── + /** Flow metadata from the FlowMeta context, or `null` while loading/unavailable. */ + meta?: Readonly>; + /** The current organization, or `null`. */ + organization: Readonly>; + organizationHandle: string | undefined; + platform: AsgardeoVueConfig['platform'] | undefined; + + // ── Lifecycle ── + reInitialize: (config: Partial) => Promise; + + /** Resolve `{{t(...)}}` and `{{meta(...)}}` template literals inside a string. */ + resolveFlowTemplateLiterals?: (text: string | undefined) => string; + + // ── Auth Actions ── + signIn: (...args: any[]) => Promise; + // ── Config ── + signInOptions: SignInOptions | undefined; + + signInSilently: (options?: SignInOptions) => Promise; + signInUrl: string | undefined; + signOut: (...args: any[]) => Promise; + signUp: (...args: any[]) => Promise; + signUpUrl: string | undefined; + storage: AsgardeoVueConfig['storage'] | undefined; + + // ── Organization ── + switchOrganization: AsgardeoVueClient['switchOrganization']; + + /** The current user object, or `null` if not signed in. */ + user: Readonly>; +} + +// ───────────────────────────────────────────────────────────────────────────── +// User Context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shape of the User context exposed by `useUser()`. + */ +export interface UserContextValue { + /** The flattened user profile (top-level attribute map). */ + flattenedProfile: Readonly>; + /** The raw nested user profile from the SCIM2/ME endpoint. */ + profile: Readonly>; + /** Refetch the user profile from the server. */ + revalidateProfile: () => Promise; + /** The SCIM2 schemas describing the user profile attributes. */ + schemas: Readonly>; + /** + * Update the user profile. Accepts the standard SCIM2 patch request config. + */ + updateProfile: ( + requestConfig: UpdateMeProfileConfig, + sessionId?: string, + ) => Promise<{data: {user: User}; error: string; success: boolean}>; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Organization Context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shape of the Organization context exposed by `useOrganization()`. + */ +export interface OrganizationContextValue { + /** Optional function to create a new sub-organization. */ + createOrganization?: (payload: CreateOrganizationPayload, sessionId: string) => Promise; + /** The organization the user is currently operating in. */ + currentOrganization: Readonly>; + /** Last error message from an organization operation, if any. */ + error: Readonly>; + /** Fetch all organizations (paginated). */ + getAllOrganizations: () => Promise; + /** Whether an organization operation is in-flight. */ + isLoading: Readonly>; + /** The list of organizations the signed-in user is a member of. */ + myOrganizations: Readonly>; + /** Re-fetch the user's organization list from the server. */ + revalidateMyOrganizations: () => Promise; + /** Switch to the given organization (performs token exchange). */ + switchOrganization: (organization: Organization) => Promise; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Flow Context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Types of authentication flow steps that can be displayed. + */ +export type FlowStep = { + canGoBack?: boolean; + id: string; + metadata?: Record; + subtitle?: string; + title: string; + type: 'signin' | 'signup' | 'organization-signin' | 'forgot-password' | 'reset-password' | 'verify-email' | 'mfa'; +} | null; + +/** + * A message that can be displayed inside an authentication flow UI. + */ +export interface FlowMessage { + dismissible?: boolean; + id?: string; + message: string; + type: 'success' | 'error' | 'warning' | 'info'; +} + +/** + * Shape of the Flow context exposed by `useFlow()`. + */ +export interface FlowContextValue { + addMessage: (message: FlowMessage) => void; + clearMessages: () => void; + currentStep: Readonly>; + error: Readonly>; + isLoading: Readonly>; + messages: Readonly>; + navigateToFlow: ( + flowType: NonNullable['type'], + options?: {metadata?: Record; subtitle?: string; title?: string}, + ) => void; + onGoBack: Readonly void) | undefined>>; + removeMessage: (messageId: string) => void; + reset: () => void; + setCurrentStep: (step: FlowStep) => void; + setError: (error: string | null) => void; + setIsLoading: (loading: boolean) => void; + setOnGoBack: (callback?: () => void) => void; + setShowBackButton: (show: boolean) => void; + setSubtitle: (subtitle?: string) => void; + setTitle: (title: string) => void; + showBackButton: Readonly>; + subtitle: Readonly>; + title: Readonly>; +} + +// ───────────────────────────────────────────────────────────────────────────── +// FlowMeta Context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shape of the FlowMeta context exposed by `useFlowMeta()`. + */ +export interface FlowMetaContextValue { + /** Error from the flow metadata fetch, if any. */ + error: Readonly>; + /** Manually re-fetch flow metadata from the server. */ + fetchFlowMeta: () => Promise; + /** Whether the flow metadata is currently being fetched. */ + isLoading: Readonly>; + /** The fetched `FlowMetadataResponse`, or `null` while loading or on error. */ + meta: Readonly>; + /** + * Fetch flow metadata for the given language and activate it in the i18n system. + * Use this to switch the UI language at runtime. + */ + switchLanguage: (language: string) => Promise; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Theme Context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shape of the Theme context exposed by `useTheme()`. + */ +export interface ThemeContextValue { + /** Error from the branding theme fetch, if any. */ + brandingError: Readonly>; + /** The current color scheme ('light' | 'dark'). */ + colorScheme: Readonly>; + /** The text direction for the UI. */ + direction: Readonly>; + /** Whether the theme inherits from Asgardeo branding preferences. */ + inheritFromBranding: boolean; + /** Whether the branding theme is currently loading. */ + isBrandingLoading: Readonly>; + /** The resolved Theme object used by all styled components. */ + theme: Readonly>; + /** Toggle between light and dark mode. */ + toggleTheme: () => void; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Branding Context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shape of the Branding context exposed by `useBranding()`. + */ +export interface BrandingContextValue { + /** The active theme from the branding preference ('light' | 'dark'), or null. */ + activeTheme: Readonly>; + /** The raw branding preference data from the server. */ + brandingPreference: Readonly>; + /** Error from the branding fetch, if any. */ + error: Readonly>; + /** Trigger a branding preference fetch (deduplicated). */ + fetchBranding: () => Promise; + /** Whether the branding preference is currently loading. */ + isLoading: Readonly>; + /** Force a fresh branding preference fetch (bypasses dedup). */ + refetch: () => Promise; + /** The transformed `Theme` object derived from the branding preference. */ + theme: Readonly>; +} + +// ───────────────────────────────────────────────────────────────────────────── +// I18n Context +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Shape of the I18n context exposed by `useI18n()`. + */ +export interface I18nContextValue { + /** All available i18n bundles (default + injected + user-provided). */ + bundles: Readonly>>; + /** The current language code (e.g., 'en-US'). */ + currentLanguage: Readonly>; + /** The fallback language code. */ + fallbackLanguage: string; + /** + * Inject additional bundles into the i18n system (e.g., from flow metadata). + * Injected bundles take precedence over defaults but are overridden by prop-provided bundles. + */ + injectBundles: (bundles: Record) => void; + /** Change the current language. */ + setLanguage: (language: string) => void; + /** Translate a key with optional named parameters. */ + t: (key: string, params?: Record) => string; +} diff --git a/packages/vue/src/plugins/AsgardeoPlugin.ts b/packages/vue/src/plugins/AsgardeoPlugin.ts index 93b86cd37..06a38f58a 100644 --- a/packages/vue/src/plugins/AsgardeoPlugin.ts +++ b/packages/vue/src/plugins/AsgardeoPlugin.ts @@ -16,216 +16,41 @@ * under the License. */ -import { - AsgardeoAuthException, - AuthClientConfig, - Config, - TokenExchangeRequestConfig, - IdToken, - Hooks, - HttpClientInstance, - HttpRequestConfig, - HttpResponse, - OIDCEndpoints, - SignInConfig, - SPAUtils, - type BasicUserInfo, -} from '@asgardeo/auth-spa'; -import type {Plugin, Ref, App, Reactive} from 'vue'; -import {reactive, ref} from 'vue'; -import AuthAPI from '../auth-api'; -import type {AuthContextInterface, AuthParams, AuthStateInterface, AuthVueConfig} from '../types'; - -export type AsgardeoPluginOptions = AuthVueConfig; +import type {App, Plugin} from 'vue'; +import AsgardeoProvider from '../providers/AsgardeoProvider'; +import {injectStyles} from '../styles/injectStyles'; /** - * Default `AuthVueConfig` config. + * Vue plugin for Asgardeo authentication. + * + * Registers the `` component globally so it can be used + * anywhere in the application without explicit imports. + * + * @example + * ```ts + * import { createApp } from 'vue'; + * import { AsgardeoPlugin } from '@asgardeo/vue'; + * import App from './App.vue'; + * + * const app = createApp(App); + * app.use(AsgardeoPlugin); + * app.mount('#app'); + * ``` + * + * Then in your root component: + * ```vue + * + * ``` */ -const defaultConfig: Partial = { - disableAutoSignIn: true, - disableTrySignInSilently: true, -}; - -export const ASGARDEO_INJECTION_KEY: symbol = Symbol('asgardeo'); - -export const asgardeoPlugin: Plugin = { - install(app: App, options: AsgardeoPluginOptions): void { - const AuthClient: AuthAPI = new AuthAPI(); - const isInitialized: Ref = ref(false); - const error: Ref = ref(null); - - const state: Reactive = reactive({...AuthClient.getState()}); - - /* eslint-disable no-useless-catch */ - const withStateSync = async (cb: () => T | Promise, refreshState: boolean = true): Promise => { - let result: T; - try { - result = await cb(); - return result; - } catch (err) { - throw err; - } finally { - if (refreshState) { - const currentState: AuthStateInterface = AuthClient.getState(); - Object.assign(state, currentState); - } - } - }; - - const signInSilently = async ( - additionalParams?: Record, - tokenRequestConfig?: {params: Record}, - ): Promise => - withStateSync(async () => AuthClient.signInSilently(additionalParams, tokenRequestConfig)); - - const checkIsAuthenticated = async (): Promise => - withStateSync(async () => { - const isAuthenticatedState: boolean = await AuthClient.isSignedIn(); - if (!isAuthenticatedState) { - AuthClient.updateState({...state, isLoading: false, isSignedIn: false}); - return; - } - const response: BasicUserInfo = await AuthClient.getUser(); - const stateToUpdate: AuthStateInterface = response - ? { - allowedScopes: response.allowedScopes, - displayName: response.displayName, - email: response.email, - isLoading: false, - isSignedIn: true, - sub: response.sub, - username: response.username, - } - : {...state, isLoading: false, isSignedIn: isAuthenticatedState}; - AuthClient.updateState(stateToUpdate); - }); - - const initialize = async (): Promise => { - await withStateSync(async () => { - if (isInitialized.value) return; - - try { - const config: AuthVueConfig = {...defaultConfig, ...options} as AuthVueConfig; - await AuthClient.init(config); - isInitialized.value = true; - - if (!config.skipRedirectCallback) { - const url: URL = new URL(window.location.href); - const authParams: AuthParams = null; - - if ( - (SPAUtils.hasAuthSearchParamsInURL() && - new URL(url.origin + url.pathname).toString() === new URL(config?.afterSignInUrl).toString()) || - authParams?.authorizationCode || - url.searchParams.get('error') - ) { - await AuthClient.signIn( - {callOnlyOnRedirect: true}, - authParams?.authorizationCode, - authParams?.sessionState, - authParams?.state, - ); - SPAUtils.removeAuthorizationCode(); - return; - } - } - - if (!config.disableAutoSignIn && (await AuthClient.isSessionActive())) { - await AuthClient.signIn(); - } - - await checkIsAuthenticated(); - - if (state.isSignedIn) { - return; - } - - if (!config.disableTrySignInSilently) { - await signInSilently(); - } - } catch (err) { - error.value = err; - throw err; - } - }); - }; - - initialize(); - - const authContext: AuthContextInterface = { - disableHttpHandler: (): Promise => AuthClient.disableHttpHandler(), - enableHttpHandler: (): Promise => AuthClient.enableHttpHandler(), - error: error.value, - exchangeToken: async ( - config: TokenExchangeRequestConfig, - callback?: (response: BasicUserInfo | Response) => void, - ): Promise => { - try { - const response: BasicUserInfo | Response = await AuthClient.exchangeToken(config); - callback?.(response); - return response; - } catch (err) { - error.value = err; - throw err; - } - }, - getAccessToken: (): Promise => AuthClient.getAccessToken(), - getDecodedIdToken: (): Promise => AuthClient.getDecodedIdToken(), - getHttpClient: (): Promise => AuthClient.getHttpClient(), - getIdToken: (): Promise => AuthClient.getIdToken(), - getOpenIDProviderEndpoints: (): Promise => AuthClient.getOpenIDProviderEndpoints(), - getUser: (): Promise => AuthClient.getUser(), - httpRequest: (config: HttpRequestConfig): Promise> => AuthClient.httpRequest(config), - httpRequestAll: (configs: HttpRequestConfig[]): Promise[]> => - AuthClient.httpRequestAll(configs), - isSignedIn: (): Promise => AuthClient.isSignedIn(), - on: (hook: Hooks, callback: (response?: any) => void, id?: string): void => { - if (hook === Hooks.CustomGrant && id) { - AuthClient.on(hook, callback, id); - } else { - AuthClient.on(hook as Exclude, callback); - } - }, - reInitialize: async (config: Partial>): Promise => - withStateSync(async () => { - await AuthClient.reInitialize(config); - }), - refreshAccessToken: (): Promise => AuthClient.refreshAccessToken(), - revokeAccessToken: (): Promise => AuthClient.revokeAccessToken(), - signIn: async ( - config?: SignInConfig, - authorizationCode?: string, - sessionState?: string, - authState?: string, - callback?: (response: BasicUserInfo) => void, - tokenRequestConfig?: {params: Record}, - ): Promise => - withStateSync(async () => { - const result: BasicUserInfo = await AuthClient.signIn( - config, - authorizationCode, - sessionState, - authState, - callback, - tokenRequestConfig, - ); - - if (result) { - error.value = null; - callback?.(result); - } - return result; - }), - signInSilently, - signOut: async (callback?: (response: boolean) => void): Promise => - withStateSync(async () => { - const result: boolean = await AuthClient.signOut(); - callback?.(result); - return result; - }), - state, - }; - - app.provide(ASGARDEO_INJECTION_KEY, authContext); +const AsgardeoPlugin: Plugin = { + install(app: App): void { + injectStyles(); + app.component('AsgardeoProvider', AsgardeoProvider); }, }; + +export default AsgardeoPlugin; diff --git a/packages/vue/src/providers/AsgardeoProvider.ts b/packages/vue/src/providers/AsgardeoProvider.ts new file mode 100644 index 000000000..665597f62 --- /dev/null +++ b/packages/vue/src/providers/AsgardeoProvider.ts @@ -0,0 +1,603 @@ +/** + * 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 { + AllOrganizationsApiResponse, + AsgardeoRuntimeError, + extractUserClaimsFromIdToken, + hasAuthParamsInUrl, + hasCalledForThisInstanceInUrl, + HttpResponse, + IdToken, + Organization, + Platform, + User, + UserProfile, + Schema, + SignInOptions, + TokenResponse, + EmbeddedSignInFlowResponseV2, +} from '@asgardeo/browser'; +import { + type Component, + defineComponent, + h, + onMounted, + onUnmounted, + provide, + type Ref, + ref, + type SetupContext, + type ShallowRef, + shallowRef, + type PropType, + type VNode, +} from 'vue'; +import BrandingProvider from './BrandingProvider'; +import FlowMetaProvider from './FlowMetaProvider'; +import FlowProvider from './FlowProvider'; +import I18nProvider from './I18nProvider'; +import OrganizationProvider from './OrganizationProvider'; +import ThemeProvider from './ThemeProvider'; +import UserProvider from './UserProvider'; +import AsgardeoVueClient from '../AsgardeoVueClient'; +import {ASGARDEO_KEY} from '../keys'; +import type {AsgardeoVueConfig} from '../models/config'; +import type {AsgardeoContext} from '../models/contexts'; + +interface AsgardeoProviderProps { + afterSignInUrl: string; + afterSignOutUrl: string; + applicationId: string | undefined; + baseUrl: string; + clientId: string; + instanceId: number; + organizationChain: object | undefined; + organizationHandle: string | undefined; + platform: string | undefined; + scopes: string[] | undefined; + signInOptions: SignInOptions | undefined; + signInUrl: string | undefined; + signUpUrl: string | undefined; + storage: string | undefined; + syncSession: boolean | undefined; +} + +/** + * Checks if the current URL contains authentication parameters. + */ +function hasAuthParams(url: URL, afterSignInUrl: string): boolean { + return ( + (hasAuthParamsInUrl() && new URL(url.origin + url.pathname).toString() === new URL(afterSignInUrl).toString()) || + url.searchParams.get('error') !== null + ); +} + +/** + * Root provider component for the Asgardeo Vue SDK. + * + * This component initializes the client, manages authentication state, + * and provides the Asgardeo context to child components via Vue's provide/inject. + * + * @example + * ```vue + * + * ``` + */ +const AsgardeoProvider: Component = defineComponent({ + name: 'AsgardeoProvider', + props: { + /** The URL to redirect to after sign in. Defaults to `window.location.origin`. */ + afterSignInUrl: { + default: () => window.location.origin, + type: String, + }, + /** The URL to redirect to after sign out. Defaults to `window.location.origin`. */ + afterSignOutUrl: { + default: () => window.location.origin, + type: String, + }, + /** The Asgardeo application ID. */ + applicationId: { + default: undefined, + type: String, + }, + /** The base URL of the Asgardeo tenant. */ + baseUrl: { + required: true, + type: String, + }, + /** The OAuth2 client ID. */ + clientId: { + required: true, + type: String, + }, + /** Instance ID for multi-instance support. */ + instanceId: { + default: 0, + type: Number, + }, + /** Organization chain config. */ + organizationChain: { + default: undefined, + type: Object, + }, + /** The organization handle. */ + organizationHandle: { + default: undefined, + type: String, + }, + /** Platform type. */ + platform: { + default: undefined, + type: String, + }, + /** The scopes to request. */ + scopes: { + default: undefined, + type: Array as PropType, + }, + /** Additional sign-in options. */ + signInOptions: { + default: undefined, + type: Object as PropType, + }, + /** The sign-in URL. */ + signInUrl: { + default: undefined, + type: String, + }, + /** The sign-up URL. */ + signUpUrl: { + default: undefined, + type: String, + }, + /** Storage type. */ + storage: { + default: undefined, + type: String, + }, + /** Whether to sync sessions across tabs. */ + syncSession: { + default: undefined, + type: Boolean, + }, + }, + setup(props: AsgardeoProviderProps, {slots}: SetupContext): () => VNode { + // ── Client ── + const asgardeo: AsgardeoVueClient = new AsgardeoVueClient(props.instanceId); + + // ── Reactive State ── + const isSignedIn: Ref = ref(false); + const isInitialized: Ref = ref(false); + const isLoading: Ref = ref(true); + const user: ShallowRef = shallowRef(null); + const currentOrganization: ShallowRef = shallowRef(null); + const myOrganizations: ShallowRef = shallowRef([]); + const userProfile: ShallowRef = shallowRef(null); + const flattenedProfile: ShallowRef = shallowRef(null); + const schemas: ShallowRef = shallowRef([]); + const resolvedBaseUrl: Ref = ref(props.baseUrl); + + let isUpdatingSession: boolean = false; + let signInCheckInterval: ReturnType | undefined; + let loadingCheckInterval: ReturnType | undefined; + + // ── Build config from props ── + function buildConfig(): AsgardeoVueConfig { + return { + afterSignInUrl: props.afterSignInUrl, + afterSignOutUrl: props.afterSignOutUrl, + applicationId: props.applicationId, + baseUrl: props.baseUrl, + clientId: props.clientId, + organizationChain: props.organizationChain, + organizationHandle: props.organizationHandle, + platform: props.platform, + scopes: props.scopes, + signInOptions: props.signInOptions, + signInUrl: props.signInUrl, + signUpUrl: props.signUpUrl, + storage: props.storage, + syncSession: props.syncSession, + } as AsgardeoVueConfig; + } + + // ── Session Update ── + async function updateSession(): Promise { + try { + isUpdatingSession = true; + isLoading.value = true; + let baseUrl: string = resolvedBaseUrl.value; + + const decodedToken: IdToken = await asgardeo.getDecodedIdToken(); + + if (decodedToken?.['user_org']) { + baseUrl = `${(await asgardeo.getConfiguration()).baseUrl}/o`; + resolvedBaseUrl.value = baseUrl; + } + + const config: AsgardeoVueConfig = buildConfig(); + + if (config.platform === Platform.AsgardeoV2) { + const claims: User = extractUserClaimsFromIdToken(decodedToken); + user.value = claims; + } else { + try { + const fetchedUser: User = await asgardeo.getUser({baseUrl}); + user.value = fetchedUser; + } catch { + // silent + } + + try { + const fetchedOrg: Organization = await asgardeo.getCurrentOrganization(); + currentOrganization.value = fetchedOrg; + } catch { + // silent + } + + // Fetch user's organizations for organization components + try { + const orgs: Organization[] = await asgardeo.getMyOrganizations({baseUrl}); + myOrganizations.value = orgs || []; + } catch { + // silent + } + + // Fetch user profile details for profile components + try { + const profileData: UserProfile = await asgardeo.getUserProfile({baseUrl}); + userProfile.value = profileData; + flattenedProfile.value = profileData.flattenedProfile || null; + schemas.value = profileData.schemas || []; + } catch { + // silent + } + } + + const currentSignInStatus: boolean = await asgardeo.isSignedIn(); + isSignedIn.value = currentSignInStatus; + } catch { + // silent + } finally { + isUpdatingSession = false; + isLoading.value = asgardeo.isLoading(); + } + } + + // ── Sign In (wrapper) ── + async function signIn(...args: any[]): Promise { + const arg1: any = args[0]; + const config: AsgardeoVueConfig = buildConfig(); + const isV2FlowRequest: boolean = + config.platform === Platform.AsgardeoV2 && + typeof arg1 === 'object' && + arg1 !== null && + ('flowId' in arg1 || 'applicationId' in arg1); + + try { + if (!isV2FlowRequest) { + isUpdatingSession = true; + isLoading.value = true; + } + + const response: User | EmbeddedSignInFlowResponseV2 = await asgardeo.signIn(...args); + + if (isV2FlowRequest || (response && typeof response === 'object' && 'flowStatus' in response)) { + return response; + } + + if (await asgardeo.isSignedIn()) { + await updateSession(); + } + + return response as User; + } catch (error) { + throw new AsgardeoRuntimeError( + `Sign in failed: ${error instanceof Error ? error.message : String(JSON.stringify(error))}`, + 'asgardeo-signIn-Error', + 'vue', + 'An error occurred while trying to sign in.', + ); + } finally { + if (!isV2FlowRequest) { + isUpdatingSession = false; + isLoading.value = asgardeo.isLoading(); + } + } + } + + // ── Sign Out ── + async function signOut(...args: any[]): Promise { + return asgardeo.signOut(...args); + } + + // ── Sign Up ── + async function signUp(...args: any[]): Promise { + return asgardeo.signUp(...args); + } + + // ── Sign In Silently ── + async function signInSilently(options?: SignInOptions): Promise { + try { + isUpdatingSession = true; + isLoading.value = true; + const response: User | boolean = await asgardeo.signInSilently(options); + + if (await asgardeo.isSignedIn()) { + await updateSession(); + } + + return response; + } catch (error) { + throw new AsgardeoRuntimeError( + `Error while signing in silently: ${error instanceof Error ? error.message : String(JSON.stringify(error))}`, + 'asgardeo-signInSilently-Error', + 'vue', + 'An error occurred while trying to sign in silently.', + ); + } finally { + isUpdatingSession = false; + isLoading.value = asgardeo.isLoading(); + } + } + + // ── Switch Organization ── + async function switchOrganization(organization: Organization): Promise { + try { + isUpdatingSession = true; + isLoading.value = true; + const response: TokenResponse | Response = await asgardeo.switchOrganization(organization); + + if (await asgardeo.isSignedIn()) { + await updateSession(); + } + + return response; + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to switch organization: ${error instanceof Error ? error.message : String(JSON.stringify(error))}`, + 'asgardeo-switchOrganization-Error', + 'vue', + 'An error occurred while switching to the specified organization.', + ); + } finally { + isUpdatingSession = false; + isLoading.value = asgardeo.isLoading(); + } + } + + // ── Provide Context ── + const context: AsgardeoContext = { + afterSignInUrl: props.afterSignInUrl, + applicationId: props.applicationId, + baseUrl: props.baseUrl, + clearSession: async (...args: any[]): Promise => { + await asgardeo.clearSession(...args); + }, + clientId: props.clientId, + exchangeToken: (config: any): Promise => asgardeo.exchangeToken(config), + getAccessToken: (): Promise => asgardeo.getAccessToken(), + getDecodedIdToken: (): Promise => asgardeo.getDecodedIdToken(), + getIdToken: (): Promise => asgardeo.getIdToken(), + http: { + request: (requestConfig?: any): Promise> => asgardeo.request(requestConfig), + requestAll: (requestConfigs?: any[]): Promise[]> => asgardeo.requestAll(requestConfigs), + }, + instanceId: props.instanceId, + isInitialized, + isLoading, + isSignedIn, + organization: currentOrganization, + organizationHandle: props.organizationHandle, + platform: props.platform as AsgardeoVueConfig['platform'], + reInitialize: async (config: any): Promise => { + const result: boolean = await asgardeo.reInitialize(config); + return typeof result === 'boolean' ? result : true; + }, + signIn, + signInOptions: props.signInOptions, + signInSilently, + signInUrl: props.signInUrl, + signOut, + signUp, + signUpUrl: props.signUpUrl, + storage: props.storage as AsgardeoVueConfig['storage'], + switchOrganization, + user, + }; + + provide(ASGARDEO_KEY, context); + + // ── Lifecycle ── + onMounted(async (): Promise => { + // 1. Initialize the client + const config: AsgardeoVueConfig = buildConfig(); + await asgardeo.initialize(config); + + const initializedConfig: any = asgardeo.getConfiguration(); + + if (initializedConfig?.platform) { + sessionStorage.setItem('asgardeo_platform', initializedConfig.platform); + } + if (initializedConfig?.baseUrl) { + sessionStorage.setItem('asgardeo_base_url', initializedConfig.baseUrl); + } + + // 2. Check initialization status + try { + const status: boolean = await asgardeo.isInitialized(); + isInitialized.value = status; + } catch { + isInitialized.value = false; + } + + // 3. Try to sign in if already authenticated or if URL has auth params + const alreadySignedIn: boolean = await asgardeo.isSignedIn(); + + if (alreadySignedIn) { + await updateSession(); + } else { + const currentUrl: URL = new URL(window.location.href); + const hasParams: boolean = + hasAuthParams(currentUrl, props.afterSignInUrl) && + hasCalledForThisInstanceInUrl(props.instanceId ?? 0, currentUrl.search); + + if (hasParams) { + try { + const isV2Platform: boolean = config.platform === Platform.AsgardeoV2; + + if (isV2Platform) { + const urlParams: URLSearchParams = currentUrl.searchParams; + const code: string | null = urlParams.get('code'); + const flowIdFromUrl: string | null = urlParams.get('flowId'); + const storedFlowId: string | null = sessionStorage.getItem('asgardeo_flow_id'); + + if (code && !flowIdFromUrl && !storedFlowId) { + await signIn(); + } + } else { + await signIn({callOnlyOnRedirect: true}); + } + } catch (error) { + throw new AsgardeoRuntimeError( + `Sign in failed: ${error instanceof Error ? error.message : String(JSON.stringify(error))}`, + 'asgardeo-signIn-Error', + 'vue', + 'An error occurred while trying to sign in.', + ); + } + } + } + + // 4. Set up polling for sign-in status + try { + const status: boolean = await asgardeo.isSignedIn(); + isSignedIn.value = status; + + if (!status) { + signInCheckInterval = setInterval(async (): Promise => { + const newStatus: boolean = await asgardeo.isSignedIn(); + if (newStatus) { + isSignedIn.value = true; + if (signInCheckInterval) { + clearInterval(signInCheckInterval); + signInCheckInterval = undefined; + } + } + }, 1000); + } + } catch { + isSignedIn.value = false; + } + + // 5. Set up polling for loading state + loadingCheckInterval = setInterval((): void => { + if (isUpdatingSession) return; + + const currentUrl: URL = new URL(window.location.href); + if (!isSignedIn.value && hasAuthParams(currentUrl, props.afterSignInUrl)) return; + + isLoading.value = asgardeo.isLoading(); + }, 100); + }); + + onUnmounted((): void => { + if (signInCheckInterval) { + clearInterval(signInCheckInterval); + } + if (loadingCheckInterval) { + clearInterval(loadingCheckInterval); + } + }); + + // ── Render ── + return (): any => + h(I18nProvider, null, { + default: (): any => + h( + FlowMetaProvider, + {enabled: props.platform === Platform.AsgardeoV2}, + { + default: (): any => + h(BrandingProvider, null, { + default: (): any => + h(ThemeProvider, null, { + default: (): any => + h(FlowProvider, null, { + default: (): any => + h( + UserProvider, + { + flattenedProfile: flattenedProfile.value, + profile: userProfile.value, + revalidateProfile: async (): Promise => { + const baseUrl: string = resolvedBaseUrl.value; + try { + const profileData: UserProfile = await asgardeo.getUserProfile({baseUrl}); + userProfile.value = profileData; + flattenedProfile.value = profileData.flattenedProfile || null; + schemas.value = profileData.schemas || []; + } catch { + // silent + } + }, + schemas: schemas.value, + }, + { + default: (): any => + h( + OrganizationProvider, + { + currentOrganization: currentOrganization.value, + getAllOrganizations: async (): Promise => + asgardeo.getAllOrganizations({baseUrl: resolvedBaseUrl.value}), + myOrganizations: myOrganizations.value, + onOrganizationSwitch: switchOrganization, + revalidateMyOrganizations: async (): Promise => { + const baseUrl: string = resolvedBaseUrl.value; + try { + const orgs: Organization[] = await asgardeo.getMyOrganizations({baseUrl}); + myOrganizations.value = orgs || []; + return orgs || []; + } catch { + return []; + } + }, + }, + { + default: (): any => slots['default']?.(), + }, + ), + }, + ), + }), + }), + }), + }, + ), + }); + }, +}); + +export default AsgardeoProvider; diff --git a/packages/vue/src/providers/BrandingProvider.ts b/packages/vue/src/providers/BrandingProvider.ts new file mode 100644 index 000000000..79f889c4c --- /dev/null +++ b/packages/vue/src/providers/BrandingProvider.ts @@ -0,0 +1,142 @@ +/** + * 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 {BrandingPreference, Theme, transformBrandingPreferenceToTheme} from '@asgardeo/browser'; +import { + computed, + defineComponent, + h, + provide, + readonly, + shallowReadonly, + ref, + watch, + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, +} from 'vue'; +import {BRANDING_KEY} from '../keys'; +import type {BrandingContextValue} from '../models/contexts'; + +interface BrandingProviderProps { + brandingPreference: BrandingPreference | null; + enabled: boolean; + error: Error | null; + forceTheme: 'light' | 'dark' | undefined; + isLoading: boolean; + refetch: (() => Promise) | undefined; +} + +/** + * BrandingProvider manages branding preference state and makes branding data + * available to child components via `useBranding()`. + * + * It receives branding preferences from a parent component (typically + * ``) and transforms them into `Theme` objects. + * + * @internal β€” This provider is mounted automatically by ``. + */ +const BrandingProvider: Component = defineComponent({ + name: 'BrandingProvider', + props: { + /** Whether branding is enabled. When false the context provides null. */ + brandingPreference: { + default: null, + type: Object as PropType, + }, + /** Loading state from the parent. */ + enabled: { + default: true, + type: Boolean, + }, + error: { + default: null, + type: Object as PropType, + }, + /** Force a specific theme mode, overriding the one declared in branding. */ + forceTheme: { + default: undefined, + type: String as PropType<'light' | 'dark'>, + }, + /** Re-fetch callback from the parent (bypasses dedup). */ + isLoading: { + default: false, + type: Boolean, + }, + refetch: { + default: undefined, + type: Function as PropType<() => Promise>, + }, + }, + setup(props: BrandingProviderProps, {slots}: SetupContext): () => VNode { + const theme: Ref = ref(null); + const activeTheme: Ref<'light' | 'dark' | null> = ref(null); + + // Process branding preference whenever it changes + const processBranding = (): void => { + if (!props.enabled || !props.brandingPreference) { + theme.value = null; + activeTheme.value = null; + return; + } + + const activeThemeFromBranding: string | undefined = (props.brandingPreference as any)?.preference?.theme + ?.activeTheme; + if (activeThemeFromBranding) { + const mode: string = activeThemeFromBranding.toLowerCase(); + activeTheme.value = mode === 'light' || mode === 'dark' ? mode : null; + } else { + activeTheme.value = null; + } + + const transformedTheme: Theme | null = transformBrandingPreferenceToTheme( + props.brandingPreference, + props.forceTheme, + ); + theme.value = transformedTheme; + }; + + watch(() => [props.brandingPreference, props.forceTheme, props.enabled], processBranding, {immediate: true}); + + const fetchBranding = async (): Promise => { + if (props.refetch) { + await props.refetch(); + } + }; + + const context: BrandingContextValue = { + activeTheme: readonly(activeTheme), + brandingPreference: readonly(computed(() => props.brandingPreference)) as Readonly< + Ref + >, + error: readonly(computed(() => props.error)) as Readonly>, + fetchBranding, + isLoading: readonly(computed(() => props.isLoading)) as Readonly>, + refetch: props.refetch ?? fetchBranding, + theme: shallowReadonly(theme), + }; + + provide(BRANDING_KEY, context); + + return (): VNode => h('div', {style: 'display:contents'}, slots['default']?.()); + }, +}); + +export default BrandingProvider; diff --git a/packages/vue/src/providers/FlowMetaProvider.ts b/packages/vue/src/providers/FlowMetaProvider.ts new file mode 100644 index 000000000..14de9828e --- /dev/null +++ b/packages/vue/src/providers/FlowMetaProvider.ts @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2026, 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 {FlowMetadataResponse, FlowMetaType, getFlowMetaV2} from '@asgardeo/browser'; +import {I18nBundle, TranslationBundleConstants} from '@asgardeo/i18n'; +import { + defineComponent, + h, + inject, + onMounted, + provide, + readonly, + shallowReadonly, + ref, + watch, + type Component, + type Ref, + type SetupContext, + type VNode, +} from 'vue'; +import {ASGARDEO_KEY, FLOW_META_KEY, I18N_KEY} from '../keys'; +import type {AsgardeoContext, FlowMetaContextValue, I18nContextValue} from '../models/contexts'; + +/** + * FlowMetaProvider fetches flow metadata from the `GET /flow/meta` endpoint + * (v2 API) and makes it available via `useFlowMeta()`. + * + * It also integrates with `I18nProvider` so that server-side translations + * from the metadata are automatically injected into the i18n system. + * + * @internal β€” This provider is mounted automatically by ``. + */ +interface FlowMetaProviderProps { + enabled: boolean; +} + +const FlowMetaProvider: Component = defineComponent({ + name: 'FlowMetaProvider', + props: { + /** + * When false the provider skips fetching and provides null meta. + * @default true + */ + enabled: {default: true, type: Boolean}, + }, + setup(props: FlowMetaProviderProps, {slots}: SetupContext): () => VNode { + const asgardeoContext: AsgardeoContext | undefined = inject(ASGARDEO_KEY); + const i18nContext: I18nContextValue | null = inject(I18N_KEY, null); + + const meta: Ref = ref(null); + const isLoading: Ref = ref(false); + const error: Ref = ref(null); + const pendingLanguage: Ref = ref(null); + + const baseUrl: string | undefined = asgardeoContext?.baseUrl; + const applicationId: string | undefined = asgardeoContext?.applicationId; + + const fetchFlowMeta = async (): Promise => { + if (!props.enabled) { + meta.value = null; + return; + } + + isLoading.value = true; + error.value = null; + + try { + const result: FlowMetadataResponse = await getFlowMetaV2({ + baseUrl, + id: applicationId, + type: FlowMetaType.App, + }); + meta.value = result; + } catch (err: unknown) { + error.value = err instanceof Error ? err : new Error(String(err)); + } finally { + isLoading.value = false; + } + }; + + const switchLanguage = async (language: string): Promise => { + if (!props.enabled) return; + + isLoading.value = true; + error.value = null; + + try { + const result: FlowMetadataResponse = await getFlowMetaV2({ + baseUrl, + id: applicationId, + language, + type: FlowMetaType.App, + }); + + // Inject translations before switching language so the i18n state is updated + if (result.i18n?.translations && i18nContext?.injectBundles) { + const flatTranslations: Record = {}; + Object.entries(result.i18n.translations).forEach(([namespace, keys]: [string, Record]) => { + Object.entries(keys).forEach(([key, value]: [string, string]) => { + flatTranslations[`${namespace}.${key}`] = value; + }); + }); + const bundle: I18nBundle = {translations: flatTranslations} as unknown as I18nBundle; + i18nContext.injectBundles({[language]: bundle}); + } + + // Defer setLanguage so that injectBundles' state is committed first + pendingLanguage.value = language; + meta.value = result; + } catch (err: unknown) { + error.value = err instanceof Error ? err : new Error(String(err)); + } finally { + isLoading.value = false; + } + }; + + // After injectBundles + pendingLanguage are committed, call setLanguage + watch(pendingLanguage, (lang: string | null) => { + if (lang && i18nContext?.setLanguage) { + i18nContext.setLanguage(lang); + pendingLanguage.value = null; + } + }); + + // When meta loads with i18n translations, inject them into the i18n system + watch( + () => meta.value?.i18n?.translations, + (translations: Record> | undefined) => { + if (!translations || !i18nContext?.injectBundles) return; + + const metaLanguage: string = (meta.value?.i18n as any)?.language || TranslationBundleConstants.FALLBACK_LOCALE; + + const flatTranslations: Record = {}; + Object.entries(translations).forEach(([namespace, keys]: [string, Record]) => { + Object.entries(keys).forEach(([key, value]: [string, string]) => { + flatTranslations[`${namespace}.${key}`] = value; + }); + }); + + const bundle: I18nBundle = {translations: flatTranslations} as unknown as I18nBundle; + const bundlesToInject: Record = {[metaLanguage]: bundle}; + + const currentLang: string = i18nContext.currentLanguage.value; + const fallbackLang: string = i18nContext.fallbackLanguage; + + if (currentLang && currentLang !== metaLanguage) { + bundlesToInject[currentLang] = bundle; + } + if (fallbackLang && fallbackLang !== metaLanguage) { + bundlesToInject[fallbackLang] = bundle; + } + + i18nContext.injectBundles(bundlesToInject); + }, + ); + + onMounted(() => { + fetchFlowMeta(); + }); + + const context: FlowMetaContextValue = { + error: readonly(error), + fetchFlowMeta, + isLoading: readonly(isLoading), + meta: shallowReadonly(meta), + switchLanguage, + }; + + provide(FLOW_META_KEY, context); + + return () => h('div', {style: 'display:contents'}, slots['default']?.()); + }, +}); + +export default FlowMetaProvider; diff --git a/packages/vue/src/providers/FlowProvider.ts b/packages/vue/src/providers/FlowProvider.ts new file mode 100644 index 000000000..cd800d860 --- /dev/null +++ b/packages/vue/src/providers/FlowProvider.ts @@ -0,0 +1,181 @@ +/** + * 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 { + defineComponent, + h, + provide, + readonly, + shallowReadonly, + ref, + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, +} from 'vue'; +import {FLOW_KEY} from '../keys'; +import type {FlowContextValue, FlowMessage, FlowStep} from '../models/contexts'; + +/** + * FlowProvider manages authentication flow UI state and makes it available + * via `useFlow()`. + * + * It tracks the current step, title, subtitle, messages, loading state, + * and back-navigation callback for embedded authentication flows. + * + * @internal β€” This provider is mounted automatically by ``. + */ +interface FlowProviderProps { + initialStep: FlowStep | null; + initialSubtitle: string | undefined; + initialTitle: string; + onFlowChange: ((step: FlowStep) => void) | undefined; +} + +const FlowProvider: Component = defineComponent({ + name: 'FlowProvider', + props: { + /** Initial step to start with. */ + initialStep: {default: null, type: Object as PropType}, + /** Initial subtitle. */ + initialSubtitle: {default: undefined, type: String}, + /** Initial title. */ + initialTitle: {default: '', type: String}, + /** Callback when the flow step changes. */ + onFlowChange: {default: undefined, type: Function as PropType<(step: FlowStep) => void>}, + }, + setup(props: FlowProviderProps, {slots}: SetupContext): () => VNode { + const currentStep: Ref = ref(props.initialStep ?? null); + const title: Ref = ref(props.initialTitle ?? ''); + const subtitle: Ref = ref(props.initialSubtitle); + const messages: Ref = ref([]); + const error: Ref = ref(null); + const isLoading: Ref = ref(false); + const showBackButton: Ref = ref(false); + const onGoBack: Ref<(() => void) | undefined> = ref(undefined); + + const setCurrentStep = (step: FlowStep): void => { + currentStep.value = step; + if (step) { + title.value = step.title; + subtitle.value = step.subtitle; + showBackButton.value = step.canGoBack ?? false; + } + props.onFlowChange?.(step); + }; + + const setTitle = (newTitle: string): void => { + title.value = newTitle; + }; + + const setSubtitle = (newSubtitle?: string): void => { + subtitle.value = newSubtitle; + }; + + const setError = (newError: string | null): void => { + error.value = newError; + }; + + const setIsLoading = (loading: boolean): void => { + isLoading.value = loading; + }; + + const setShowBackButton = (show: boolean): void => { + showBackButton.value = show; + }; + + const setOnGoBack = (callback?: () => void): void => { + onGoBack.value = callback; + }; + + const addMessage = (message: FlowMessage): void => { + const messageWithId: FlowMessage = { + ...message, + id: message.id ?? `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + }; + messages.value = [...messages.value, messageWithId]; + }; + + const removeMessage = (messageId: string): void => { + messages.value = messages.value.filter((msg: FlowMessage): boolean => msg.id !== messageId); + }; + + const clearMessages = (): void => { + messages.value = []; + }; + + const reset = (): void => { + currentStep.value = props.initialStep ?? null; + title.value = props.initialTitle ?? ''; + subtitle.value = props.initialSubtitle; + messages.value = []; + error.value = null; + isLoading.value = false; + showBackButton.value = false; + onGoBack.value = undefined; + }; + + const navigateToFlow = ( + flowType: NonNullable['type'], + options?: {metadata?: Record; subtitle?: string; title?: string}, + ): void => { + const stepId: string = `${flowType}-${Date.now()}`; + const step: NonNullable = { + canGoBack: flowType !== 'signin', + id: stepId, + metadata: options?.metadata, + subtitle: options?.subtitle, + title: options?.title ?? '', + type: flowType, + }; + setCurrentStep(step); + clearMessages(); + error.value = null; + }; + + const context: FlowContextValue = { + addMessage, + clearMessages, + currentStep: readonly(currentStep), + error: readonly(error), + isLoading: readonly(isLoading), + messages: shallowReadonly(messages), + navigateToFlow, + onGoBack: readonly(onGoBack), + removeMessage, + reset, + setCurrentStep, + setError, + setIsLoading, + setOnGoBack, + setShowBackButton, + setSubtitle, + setTitle, + showBackButton: readonly(showBackButton), + subtitle: readonly(subtitle), + title: readonly(title), + }; + + provide(FLOW_KEY, context); + + return () => h('div', {style: 'display:contents'}, slots['default']?.()); + }, +}); + +export default FlowProvider; diff --git a/packages/vue/src/providers/I18nProvider.ts b/packages/vue/src/providers/I18nProvider.ts new file mode 100644 index 000000000..553d0a69a --- /dev/null +++ b/packages/vue/src/providers/I18nProvider.ts @@ -0,0 +1,326 @@ +/** + * 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 {deepMerge, I18nPreferences, I18nStorageStrategy} from '@asgardeo/browser'; +import { + I18nBundle, + I18nTranslations, + TranslationBundleConstants, + getDefaultI18nBundles, + normalizeTranslations, +} from '@asgardeo/i18n'; +import { + computed, + defineComponent, + h, + provide, + readonly, + ref, + watch, + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, +} from 'vue'; +import {I18N_KEY} from '../keys'; +import type {I18nContextValue} from '../models/contexts'; +import {createVueLogger} from '../utils/logger'; + +const logger: ReturnType = createVueLogger('I18nProvider'); + +const DEFAULT_STORAGE_KEY: string = 'asgardeo-i18n-language'; +const DEFAULT_URL_PARAM: string = 'lang'; + +// ── Storage helpers ────────────────────────────────────────────────────────── + +const deriveRootDomain = (hostname: string): string => { + const parts: string[] = hostname.split('.'); + return parts.length > 1 ? parts.slice(-2).join('.') : hostname; +}; + +const getCookie = (name: string): string | null => { + if (typeof document === 'undefined') return null; + const match: RegExpMatchArray | null = document.cookie.match( + new RegExp(`(?:^|; )${name.replace(/([.*+?^${}()|[\]\\])/g, '\\$1')}=([^;]*)`), + ); + return match ? decodeURIComponent(match[1]) : null; +}; + +const setCookie = (name: string, value: string, domain: string): void => { + if (typeof document === 'undefined') return; + const maxAge: number = 365 * 24 * 60 * 60; + const secure: string = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = + `${encodeURIComponent(name)}=${encodeURIComponent(value)}` + + `; Max-Age=${maxAge}` + + `; Path=/` + + `; Domain=${domain}` + + `; SameSite=Lax${secure}`; +}; + +interface StorageAdapter { + read: () => string | null; + write: (language: string) => void; +} + +const createStorageAdapter = (strategy: I18nStorageStrategy, key: string, cookieDomain?: string): StorageAdapter => { + switch (strategy) { + case 'cookie': + return { + read: (): string | null => getCookie(key), + write: (language: string): void => { + const domain: string = + cookieDomain ?? (typeof window !== 'undefined' ? deriveRootDomain(window.location.hostname) : ''); + if (domain) setCookie(key, language, domain); + }, + }; + case 'localStorage': + return { + read: (): string | null => { + if (typeof window === 'undefined' || !window.localStorage) return null; + try { + return window.localStorage.getItem(key); + } catch { + return null; + } + }, + write: (language: string): void => { + if (typeof window === 'undefined' || !window.localStorage) return; + try { + window.localStorage.setItem(key, language); + } catch { + logger.warn('Failed to persist language preference to localStorage.'); + } + }, + }; + case 'none': + default: + return {read: (): null => null, write: (): void => {}}; + } +}; + +const detectUrlParamLanguage = (paramName: string): string | null => { + if (typeof window === 'undefined') return null; + try { + return new URLSearchParams(window.location.search).get(paramName); + } catch { + return null; + } +}; + +const detectBrowserLanguage = (): string => { + if (typeof window !== 'undefined' && window.navigator) { + return window.navigator.language || TranslationBundleConstants.FALLBACK_LOCALE; + } + return TranslationBundleConstants.FALLBACK_LOCALE; +}; + +// ── Component ──────────────────────────────────────────────────────────────── + +/** + * I18nProvider manages internationalization state and provides translation + * functions to child components via `useI18n()`. + * + * Language resolution order: + * URL param β†’ stored preference β†’ browser language β†’ fallback locale + * + * @internal β€” This provider is mounted automatically by ``. + */ +interface I18nProviderProps { + preferences: I18nPreferences | undefined; +} + +const I18nProvider: Component = defineComponent({ + name: 'I18nProvider', + props: { + /** i18n preferences passed down from the AsgardeoProvider config. */ + preferences: {default: undefined, type: Object as PropType}, + }, + setup(props: I18nProviderProps, {slots}: SetupContext): () => VNode { + const defaultBundles: Record = getDefaultI18nBundles(); + + const storageStrategy: I18nStorageStrategy = props.preferences?.storageStrategy ?? 'cookie'; + const storageKey: string = props.preferences?.storageKey ?? DEFAULT_STORAGE_KEY; + const urlParamConfig: string | false = + props.preferences?.urlParam === undefined ? DEFAULT_URL_PARAM : props.preferences.urlParam; + + const resolvedCookieDomain: string | undefined = + storageStrategy === 'cookie' + ? props.preferences?.cookieDomain ?? + (typeof window !== 'undefined' ? deriveRootDomain(window.location.hostname) : undefined) + : undefined; + + const storage: StorageAdapter = createStorageAdapter(storageStrategy, storageKey, resolvedCookieDomain); + + // Determine initial language + const determineInitialLanguage = (): string => { + if (props.preferences?.language) return props.preferences.language; + if (urlParamConfig !== false) { + const urlLanguage: string | null = detectUrlParamLanguage(urlParamConfig); + if (urlLanguage) { + storage.write(urlLanguage); + return urlLanguage; + } + } + const storedLanguage: string | null = storage.read(); + if (storedLanguage) return storedLanguage; + const browserLanguage: string = detectBrowserLanguage(); + if (browserLanguage) return browserLanguage; + return props.preferences?.fallbackLanguage || TranslationBundleConstants.FALLBACK_LOCALE; + }; + + const currentLanguage: Ref = ref(determineInitialLanguage()); + const fallbackLanguage: string = props.preferences?.fallbackLanguage || TranslationBundleConstants.FALLBACK_LOCALE; + + // Bundles injected at runtime (e.g., from flow metadata translations). + const injectedBundles: Ref> = ref({}); + + const injectBundles = (newBundles: Record): void => { + const mergedBundles: Record = {...injectedBundles.value}; + Object.entries(newBundles).forEach(([languageKey, bundle]: [key: string, bundle: I18nBundle]): void => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + bundle.translations as unknown as Record>, + ); + if (mergedBundles[languageKey]) { + mergedBundles[languageKey] = { + ...mergedBundles[languageKey], + translations: deepMerge(mergedBundles[languageKey].translations, normalizedTranslations), + }; + } else { + mergedBundles[languageKey] = {...bundle, translations: normalizedTranslations}; + } + }); + injectedBundles.value = mergedBundles; + }; + + /** + * Merge bundles: defaults β†’ injected (meta) β†’ prop-provided (highest priority) + */ + const mergedBundlesComputed: Ref> = computed>(() => { + const merged: Record = {}; + + // 1. Default bundles + Object.entries(defaultBundles).forEach(([key, bundle]: [key: string, bundle: I18nBundle]): void => { + const languageKey: string = key.replace('_', '-'); + merged[languageKey] = bundle; + }); + + // 2. Injected bundles (from flow metadata) + Object.entries(injectedBundles.value).forEach(([key, bundle]: [key: string, bundle: I18nBundle]): void => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + bundle.translations as unknown as Record>, + ); + if (merged[key]) { + merged[key] = { + ...merged[key], + translations: deepMerge(merged[key].translations, normalizedTranslations), + }; + } else { + merged[key] = {...bundle, translations: normalizedTranslations}; + } + }); + + // 3. User-provided bundles (highest priority) + if (props.preferences?.bundles) { + Object.entries(props.preferences.bundles).forEach( + ([key, userBundle]: [key: string, userBundle: I18nBundle]): void => { + const normalizedTranslations: I18nTranslations = normalizeTranslations( + userBundle.translations as unknown as Record>, + ); + if (merged[key]) { + merged[key] = { + ...merged[key], + metadata: userBundle.metadata + ? {...merged[key].metadata, ...userBundle.metadata} + : merged[key].metadata, + translations: deepMerge(merged[key].translations, normalizedTranslations), + }; + } else { + merged[key] = {...userBundle, translations: normalizedTranslations}; + } + }, + ); + } + + return merged; + }); + + // Persist language changes to storage + watch(currentLanguage, (lang: string): void => { + storage.write(lang); + }); + + const t = (key: string, params?: Record): string => { + let translation: string | undefined; + + const currentBundle: I18nBundle | undefined = mergedBundlesComputed.value[currentLanguage.value]; + if (currentBundle?.translations[key]) { + translation = currentBundle.translations[key]; + } + + if (!translation && currentLanguage.value !== fallbackLanguage) { + const fallbackBundle: I18nBundle | undefined = mergedBundlesComputed.value[fallbackLanguage]; + if (fallbackBundle?.translations[key]) { + translation = fallbackBundle.translations[key]; + } + } + + if (!translation) { + translation = key; + } + + if (params && Object.keys(params).length > 0) { + return Object.entries(params).reduce( + (acc: string, [paramKey, paramValue]: [key: string, value: string | number]): string => + acc.replaceAll(`{${paramKey}}`, String(paramValue)), + translation, + ); + } + + return translation; + }; + + const setLanguage = (language: string): void => { + if (mergedBundlesComputed.value[language]) { + currentLanguage.value = language; + } else { + logger.warn( + `Language '${language}' is not available. Available languages: ${Object.keys( + mergedBundlesComputed.value, + ).join(', ')}`, + ); + } + }; + + const context: I18nContextValue = { + bundles: readonly(mergedBundlesComputed), + currentLanguage: readonly(currentLanguage), + fallbackLanguage, + injectBundles, + setLanguage, + t, + }; + + provide(I18N_KEY, context); + + return () => h('div', {style: 'display:contents'}, slots['default']?.()); + }, +}); + +export default I18nProvider; diff --git a/packages/vue/src/providers/OrganizationProvider.ts b/packages/vue/src/providers/OrganizationProvider.ts new file mode 100644 index 000000000..cdd46a16b --- /dev/null +++ b/packages/vue/src/providers/OrganizationProvider.ts @@ -0,0 +1,148 @@ +/** + * 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 { + AllOrganizationsApiResponse, + AsgardeoRuntimeError, + CreateOrganizationPayload, + Organization, + TokenResponse, +} from '@asgardeo/browser'; +import { + computed, + defineComponent, + h, + provide, + readonly, + ref, + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, +} from 'vue'; +import {ORGANIZATION_KEY} from '../keys'; +import type {OrganizationContextValue} from '../models/contexts'; + +/** + * OrganizationProvider manages organization state and makes it available + * via `useOrganization()`. + * + * @internal β€” This provider is mounted automatically by ``. + */ +interface OrganizationProviderProps { + createOrganization: ((payload: CreateOrganizationPayload, sessionId: string) => Promise) | undefined; + currentOrganization: Organization | null; + getAllOrganizations: (() => Promise) | undefined; + myOrganizations: Organization[]; + onError: ((error: string) => void) | undefined; + onOrganizationSwitch: ((organization: Organization) => Promise) | undefined; + revalidateMyOrganizations: () => Promise; +} + +const OrganizationProvider: Component = defineComponent({ + name: 'OrganizationProvider', + props: { + /** Optional factory for creating a new sub-organization. */ + createOrganization: { + default: undefined, + type: Function as PropType<(payload: CreateOrganizationPayload, sessionId: string) => Promise>, + }, + /** The organization the user is currently operating in. */ + currentOrganization: {default: null, type: Object as PropType}, + /** Callback to fetch all organizations (paginated). */ + getAllOrganizations: { + default: undefined, + type: Function as PropType<() => Promise>, + }, + /** The list of organizations the user is a member of. */ + myOrganizations: {default: () => [], type: Array as PropType}, + /** Callback when an error occurs. */ + onError: {default: undefined, type: Function as PropType<(error: string) => void>}, + /** Callback that performs the actual organization switch (token exchange). */ + onOrganizationSwitch: { + default: undefined, + type: Function as PropType<(organization: Organization) => Promise>, + }, + /** Callback to re-fetch the user's organization list. */ + revalidateMyOrganizations: { + default: async () => [], + type: Function as PropType<() => Promise>, + }, + }, + setup(props: OrganizationProviderProps, {slots}: SetupContext): () => VNode { + const isLoading: Ref = ref(false); + const error: Ref = ref(null); + + const switchOrganization = async (organization: Organization): Promise => { + if (!props.onOrganizationSwitch) { + throw new AsgardeoRuntimeError( + 'onOrganizationSwitch callback is required', + 'OrganizationProvider-SwitchError-001', + 'vue', + 'The onOrganizationSwitch callback must be provided to handle organization switching.', + ); + } + + isLoading.value = true; + error.value = null; + + try { + await props.onOrganizationSwitch(organization); + } catch (switchError: unknown) { + const errorMessage: string = + switchError instanceof Error ? switchError.message : 'Failed to switch organization'; + error.value = errorMessage; + if (props.onError) { + props.onError(errorMessage); + } + throw switchError; + } finally { + isLoading.value = false; + } + }; + + const getAllOrgs = async (): Promise => { + if (props.getAllOrganizations) { + return props.getAllOrganizations(); + } + return {organizations: []}; + }; + + // Use computed refs so context stays in sync when props change + const currentOrganizationRef: Ref = computed(() => props.currentOrganization); + const myOrganizationsRef: Ref = computed(() => props.myOrganizations); + + const context: OrganizationContextValue = { + createOrganization: props.createOrganization, + currentOrganization: currentOrganizationRef as unknown as Readonly>, + error: readonly(error), + getAllOrganizations: getAllOrgs, + isLoading: readonly(isLoading), + myOrganizations: myOrganizationsRef as unknown as Readonly>, + revalidateMyOrganizations: props.revalidateMyOrganizations, + switchOrganization, + }; + + provide(ORGANIZATION_KEY, context); + + return () => h('div', {style: 'display:contents'}, slots['default']?.()); + }, +}); + +export default OrganizationProvider; diff --git a/packages/vue/src/providers/ThemeProvider.ts b/packages/vue/src/providers/ThemeProvider.ts new file mode 100644 index 000000000..6120bfcc1 --- /dev/null +++ b/packages/vue/src/providers/ThemeProvider.ts @@ -0,0 +1,249 @@ +/** + * 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 { + Theme, + ThemeConfig, + ThemeMode, + ThemePreferences, + RecursivePartial, + BrowserThemeDetection, + DEFAULT_THEME, + createTheme, + detectThemeMode, + createClassObserver, + createMediaQueryListener, +} from '@asgardeo/browser'; +import { + computed, + defineComponent, + h, + inject, + onBeforeUnmount, + onMounted, + provide, + readonly, + shallowReadonly, + ref, + watch, + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, +} from 'vue'; +import {BRANDING_KEY, THEME_KEY} from '../keys'; +import type {BrandingContextValue, ThemeContextValue} from '../models/contexts'; +import {createVueLogger} from '../utils/logger'; + +const logger: ReturnType = createVueLogger('ThemeProvider'); + +/** + * ThemeProvider manages theme state and provides it to child components via `useTheme()`. + * + * It supports: + * - Fixed color schemes (`light` | `dark`) + * - System preference detection (`system`) + * - CSS-class-based detection (`class`) + * - Branding-driven mode (`branding`) β€” inherits the active theme from `BrandingProvider` + * - Merging server branding theme with local overrides + * - CSS variable injection onto `document.documentElement` + * + * @example + * ```vue + * + * + * + * ``` + */ +interface ThemeProviderProps { + detection: BrowserThemeDetection; + inheritFromBranding: boolean; + mode: ThemeMode | 'branding'; + theme: RecursivePartial | undefined; +} + +const ThemeProvider: Component = defineComponent({ + name: 'ThemeProvider', + props: { + /** Theme detection configuration (for 'class' or 'system' mode). */ + detection: {default: () => ({}), type: Object as PropType}, + /** Whether to inherit theme from Asgardeo branding preference. */ + inheritFromBranding: {default: true as ThemePreferences['inheritFromBranding'], type: Boolean}, + /** + * The theme mode: + * - `'light'` | `'dark'`: Fixed color scheme. + * - `'system'`: Follows OS preference. + * - `'class'`: Detects theme from CSS classes on ``. + * - `'branding'`: Follows the active theme from branding preference. + */ + mode: { + default: DEFAULT_THEME as ThemeMode | 'branding', + type: String as PropType, + }, + /** Optional partial theme overrides applied on top of the resolved theme. */ + theme: {default: undefined, type: Object as PropType>}, + }, + setup(props: ThemeProviderProps, {slots}: SetupContext): () => VNode { + // Try to consume branding context – it is optional (BrandingProvider may not be mounted) + const brandingContext: BrandingContextValue | null = inject(BRANDING_KEY, null); + + const initColorScheme = (): 'light' | 'dark' => { + if (props.mode === 'light' || props.mode === 'dark') return props.mode; + if (props.mode === 'branding') return detectThemeMode('system', props.detection); + return detectThemeMode(props.mode as ThemeMode, props.detection); + }; + + const colorScheme: Ref<'light' | 'dark'> = ref(initColorScheme()); + + // Update color scheme when branding's active theme is available + watch( + () => (brandingContext as any)?.activeTheme.value, + (brandingActiveTheme: string | 'light' | 'dark' | undefined): void => { + if (!props.inheritFromBranding || !brandingActiveTheme) return; + if (props.mode === 'branding') { + colorScheme.value = brandingActiveTheme as 'light' | 'dark'; + } else if (props.mode === 'system' && !(brandingContext as any)?.isLoading.value) { + colorScheme.value = brandingActiveTheme as 'light' | 'dark'; + } + }, + ); + + // Warn if inheritFromBranding is true but no BrandingProvider is present + if (props.inheritFromBranding && !brandingContext) { + logger.warn( + 'ThemeProvider: inheritFromBranding is enabled but BrandingProvider is not available. ' + + 'Make sure to wrap your app with BrandingProvider or AsgardeoProvider.', + ); + } + + // Merge branding theme with user-provided overrides + const finalThemeConfig: Ref | undefined> = computed< + RecursivePartial | undefined + >(() => { + const themeConfig: RecursivePartial | undefined = props.theme; + const brandingTheme: RecursivePartial | null | undefined = props.inheritFromBranding + ? (brandingContext as any)?.theme.value + : null; + + if (!brandingTheme) return themeConfig; + + const brandingThemeConfig: RecursivePartial = { + borderRadius: brandingTheme.borderRadius, + colors: brandingTheme.colors, + components: brandingTheme.components, + images: brandingTheme.images, + shadows: brandingTheme.shadows, + spacing: brandingTheme.spacing, + }; + + return { + ...brandingThemeConfig, + ...themeConfig, + borderRadius: {...brandingThemeConfig.borderRadius, ...themeConfig?.borderRadius}, + colors: {...brandingThemeConfig.colors, ...themeConfig?.colors}, + components: {...brandingThemeConfig.components, ...themeConfig?.components}, + images: {...brandingThemeConfig.images, ...themeConfig?.images}, + shadows: {...brandingThemeConfig.shadows, ...themeConfig?.shadows}, + spacing: {...brandingThemeConfig.spacing, ...themeConfig?.spacing}, + }; + }); + + const resolvedTheme: Ref = computed(() => + createTheme(finalThemeConfig.value, colorScheme.value === 'dark'), + ); + + const direction: Ref<'ltr' | 'rtl'> = computed<'ltr' | 'rtl'>( + () => ((finalThemeConfig.value as any)?.direction as 'ltr' | 'rtl') || 'ltr', + ); + + const toggleTheme = (): void => { + colorScheme.value = colorScheme.value === 'light' ? 'dark' : 'light'; + }; + + // Apply CSS variables to DOM + const applyToDom = (theme: Theme): void => { + if (typeof document === 'undefined') return; + const root: HTMLElement = document.documentElement; + // Use the pre-computed cssVariables map from createTheme() which contains + // correctly-named CSS variables (e.g. --asgardeo-color-primary-main). + Object.entries(theme.cssVariables).forEach(([key, value]: [key: string, value: string]): void => { + root.style.setProperty(key, value); + }); + }; + + watch(resolvedTheme, (theme: Theme): void => applyToDom(theme), {immediate: true}); + + // Apply direction to document + watch( + direction, + (dir: 'ltr' | 'rtl'): void => { + if (typeof document !== 'undefined') { + document.documentElement.dir = dir; + } + }, + {immediate: true}, + ); + + // Set up automatic theme detection listeners + let classObserver: MutationObserver | null = null; + let mediaQuery: MediaQueryList | null = null; + + const handleThemeChange = (isDark: boolean): void => { + colorScheme.value = isDark ? 'dark' : 'light'; + }; + + onMounted((): void => { + if (props.mode === 'branding') return; + + if (props.mode === 'class') { + const targetElement: HTMLElement = (props.detection as any).targetElement || document.documentElement; + if (targetElement) { + classObserver = createClassObserver(targetElement, handleThemeChange, props.detection); + } + } else if (props.mode === 'system') { + if (!props.inheritFromBranding || !(brandingContext as any)?.activeTheme.value) { + mediaQuery = createMediaQueryListener(handleThemeChange); + } + } + }); + + onBeforeUnmount((): void => { + if (classObserver) classObserver.disconnect(); + if (mediaQuery?.removeEventListener) { + mediaQuery.removeEventListener('change', handleThemeChange as any); + } + }); + + const context: ThemeContextValue = { + brandingError: brandingContext?.error ?? readonly(ref(null)), + colorScheme: readonly(colorScheme), + direction: readonly(direction) as Readonly>, + inheritFromBranding: props.inheritFromBranding, + isBrandingLoading: brandingContext?.isLoading ?? readonly(ref(false)), + theme: shallowReadonly(resolvedTheme), + toggleTheme, + }; + + provide(THEME_KEY, context); + + return () => h('div', {style: 'display:contents'}, slots['default']?.()); + }, +}); + +export default ThemeProvider; diff --git a/packages/vue/src/providers/UserProvider.ts b/packages/vue/src/providers/UserProvider.ts new file mode 100644 index 000000000..3e92be725 --- /dev/null +++ b/packages/vue/src/providers/UserProvider.ts @@ -0,0 +1,106 @@ +/** + * 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 {Schema, UpdateMeProfileConfig, User, UserProfile} from '@asgardeo/browser'; +import { + computed, + defineComponent, + h, + provide, + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, +} from 'vue'; +import {USER_KEY} from '../keys'; +import type {UserContextValue} from '../models/contexts'; + +/** + * UserProvider manages user profile state and makes it available via `useUser()`. + * + * It is a thin wrapper that receives profile data from a parent (typically + * ``) and surfaces it through the Vue inject system. + * + * @internal β€” This provider is mounted automatically by ``. + */ +interface UserProviderProps { + flattenedProfile: User | null; + onUpdateProfile: ((payload: User) => void) | undefined; + profile: UserProfile | null; + revalidateProfile: () => Promise; + schemas: Schema[] | null; + updateProfile: + | (( + requestConfig: UpdateMeProfileConfig, + sessionId?: string, + ) => Promise<{data: {user: User}; error: string; success: boolean}>) + | undefined; +} + +const UserProvider: Component = defineComponent({ + name: 'UserProvider', + props: { + /** The flattened profile (top-level attribute map). */ + flattenedProfile: {default: null, type: Object as PropType}, + /** Optional callback run after the profile is updated locally. */ + onUpdateProfile: {default: undefined, type: Function as PropType<(payload: User) => void>}, + /** The full user profile data (nested + flat + schemas). */ + profile: {default: null, type: Object as PropType}, + /** Re-fetch the user profile from the server. */ + revalidateProfile: {default: async () => {}, type: Function as PropType<() => Promise>}, + /** The SCIM2 schemas describing user profile attributes. */ + schemas: {default: null, type: Array as PropType}, + /** Update the user profile via SCIM2 PATCH. */ + updateProfile: { + default: undefined, + type: Function as PropType< + ( + requestConfig: UpdateMeProfileConfig, + sessionId?: string, + ) => Promise<{data: {user: User}; error: string; success: boolean}> + >, + }, + }, + setup(props: UserProviderProps, {slots}: SetupContext): () => VNode { + // Use computed refs so context stays in sync when props change + const flattenedProfileRef: Ref = computed(() => props.flattenedProfile); + const profileRef: Ref = computed(() => props.profile); + const schemasRef: Ref = computed(() => props.schemas); + + const context: UserContextValue = { + flattenedProfile: flattenedProfileRef as unknown as Readonly>, + profile: profileRef as unknown as Readonly>, + revalidateProfile: props.revalidateProfile, + schemas: schemasRef as unknown as Readonly>, + updateProfile: + props.updateProfile ?? + (async (): Promise<{data: {user: User}; error: string; success: boolean}> => ({ + data: {user: {} as User}, + error: 'updateProfile callback not provided', + success: false, + })), + }; + + provide(USER_KEY, context); + + return (): VNode => h('div', {style: 'display:contents'}, slots['default']?.()); + }, +}); + +export default UserProvider; diff --git a/packages/vue/src/router/callbackRoute.ts b/packages/vue/src/router/callbackRoute.ts new file mode 100644 index 000000000..5dcd858aa --- /dev/null +++ b/packages/vue/src/router/callbackRoute.ts @@ -0,0 +1,114 @@ +/** + * 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 {type Component, defineComponent, h} from 'vue'; +import Callback from '../components/auth/Callback'; + +/** + * Options for creating a callback route. + */ +export interface CallbackRouteOptions { + /** + * The route name. If not provided, no name is set on the route record. + */ + name?: string; + + /** + * Optional error handler called when the OAuth callback encounters an error. + */ + onError?: (error: Error) => void; + + /** + * The URL path for the callback route. + * @default '/callback' + */ + path?: string; +} + +/** + * A minimal route record type compatible with Vue Router's `RouteRecordRaw`. + * + * This avoids a hard dependency on `vue-router` while remaining structurally compatible. + */ +export interface AsgardeoRouteRecord { + component: ReturnType; + meta?: Record; + name?: string; + path: string; +} + +/** + * Creates a Vue Router route record for the OAuth2 callback. + * + * The generated route renders the `` component which extracts OAuth parameters + * (code, state, error) from the URL and redirects the user back to the original path. + * + * **Requires `vue-router` as a peer dependency.** + * + * @param options - Callback route configuration. + * @returns A route record compatible with Vue Router's `RouteRecordRaw`. + * + * @example + * ```typescript + * import { createRouter, createWebHistory } from 'vue-router'; + * import { createCallbackRoute } from '@asgardeo/vue'; + * + * const router = createRouter({ + * history: createWebHistory(), + * routes: [ + * createCallbackRoute({ path: '/callback' }), + * { path: '/', component: Home }, + * { path: '/dashboard', component: Dashboard }, + * ], + * }); + * ``` + * + * @example + * ```typescript + * // With error handling and Vue Router navigation + * import { useRouter } from 'vue-router'; + * + * createCallbackRoute({ + * path: '/auth/callback', + * name: 'oauth-callback', + * onError: (error) => console.error('OAuth error:', error), + * }); + * ``` + */ +export const createCallbackRoute = (options: CallbackRouteOptions = {}): AsgardeoRouteRecord => { + const {path = '/callback', name, onError} = options; + + const CallbackWrapper: Component = defineComponent({ + name: 'AsgardeoCallbackRoute', + setup() { + return (): ReturnType => + h(Callback, { + ...(onError && {onError}), + }); + }, + }); + + return { + ...(name && {name}), + component: CallbackWrapper, + meta: {isAsgardeoCallback: true}, + path, + }; +}; + +export default createCallbackRoute; diff --git a/packages/vue/src/router/guard.ts b/packages/vue/src/router/guard.ts new file mode 100644 index 000000000..f010956d9 --- /dev/null +++ b/packages/vue/src/router/guard.ts @@ -0,0 +1,168 @@ +/** + * 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 {inject} from 'vue'; +import {ASGARDEO_KEY} from '../keys'; +import type {AsgardeoContext} from '../models/contexts'; +import {createVueLogger} from '../utils/logger'; + +const logger: ReturnType = createVueLogger('Guard'); + +/** + * Options for the Asgardeo navigation guard. + */ +export interface GuardOptions { + /** + * Maximum time (in ms) to wait for SDK initialization before redirecting. + * Only applicable when `waitForInit` is `true`. + * @default 10000 + */ + initTimeout?: number; + + /** + * The path to redirect unauthenticated users to. + * @default '/' + */ + redirectTo?: string; + + /** + * If `true`, the guard will wait for the SDK to finish initializing before + * evaluating the authentication state. If `false`, the guard will reject + * immediately when the SDK is not yet initialized. + * @default true + */ + waitForInit?: boolean; +} + +/** + * A minimal navigation guard type compatible with Vue Router's `NavigationGuard`. + * + * This avoids a hard dependency on `vue-router` while remaining structurally compatible. + * When consumers pass this to `beforeEnter` or `router.beforeEach`, Vue Router will + * accept it because it satisfies the shape of `NavigationGuard`. + */ +export type NavigationGuardReturn = boolean | string | {path: string} | {name: string} | undefined; +export type AsgardeoNavigationGuard = ( + to: {fullPath: string; path: string; query: Record}, + from: {fullPath: string; path: string}, + next: (target?: NavigationGuardReturn) => void, +) => void | Promise; + +/** + * Creates a Vue Router navigation guard that protects routes by requiring authentication. + * + * The guard injects the Asgardeo context to check `isSignedIn` state. + * If the user is not authenticated, they are redirected to `redirectTo`. + * + * **Requires `vue-router` as a peer dependency.** + * + * @param options - Guard configuration options. + * @returns A navigation guard function compatible with Vue Router's `beforeEnter` or `router.beforeEach`. + * + * @example + * ```typescript + * import { createRouter, createWebHistory } from 'vue-router'; + * import { createAsgardeoGuard } from '@asgardeo/vue'; + * + * const router = createRouter({ + * history: createWebHistory(), + * routes: [ + * { + * path: '/dashboard', + * component: Dashboard, + * beforeEnter: createAsgardeoGuard({ redirectTo: '/login' }), + * }, + * ], + * }); + * ``` + * + * @example + * ```typescript + * // Global guard on all routes + * router.beforeEach(createAsgardeoGuard({ redirectTo: '/' })); + * ``` + */ +export const createAsgardeoGuard = (options: GuardOptions = {}): AsgardeoNavigationGuard => { + const {redirectTo = '/', waitForInit = true, initTimeout = 10000} = options; + + return async (_to: unknown, _from: unknown, next: (target?: {path: string}) => void): Promise => { + const ctx: AsgardeoContext | undefined = inject(ASGARDEO_KEY); + + if (!ctx) { + logger.error( + 'createAsgardeoGuard: Asgardeo context not found. ' + + 'Ensure the AsgardeoPlugin is installed before using the router guard.', + ); + next({path: redirectTo}); + + return; + } + + // If initialized and signed in, allow navigation + if (ctx.isInitialized.value && ctx.isSignedIn.value) { + next(); + + return; + } + + // If initialized and not signed in, redirect + if (ctx.isInitialized.value && !ctx.isSignedIn.value) { + next({path: redirectTo}); + + return; + } + + // SDK not yet initialized β€” optionally wait for it + if (!waitForInit) { + next({path: redirectTo}); + + return; + } + + // Wait for initialization to complete + try { + await new Promise((resolve: () => void, reject: (reason?: Error) => void) => { + const timeout: ReturnType = setTimeout(() => { + reject(new Error('Asgardeo SDK initialization timed out')); + }, initTimeout); + + const check = (): void => { + if (ctx.isInitialized.value) { + clearTimeout(timeout); + resolve(); + } else { + requestAnimationFrame(check); + } + }; + + check(); + }); + + if (ctx.isSignedIn.value) { + next(); + } else { + next({path: redirectTo}); + } + } catch { + // Timed out β€” redirect to fallback + next({path: redirectTo}); + } + }; +}; + +export default createAsgardeoGuard; diff --git a/packages/vue/src/styles/animations.css.ts b/packages/vue/src/styles/animations.css.ts new file mode 100644 index 000000000..1db0d43e4 --- /dev/null +++ b/packages/vue/src/styles/animations.css.ts @@ -0,0 +1,58 @@ +/** + * 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. + */ + +/** + * Shared CSS keyframe animations used by multiple primitive components. + * + * `asgardeo-spin` - used by Spinner (__svg) and Button (__spinner) + * `asgardeo-spinner-dash` - used by Spinner (__circle) + * + * Defined once here rather than in each component's CSS file to avoid + * duplicate `@keyframes` blocks in the injected stylesheet. + */ +const ANIMATIONS_CSS: string = ` +/* ============================================================ + Asgardeo Vue SDK – shared keyframe animations + ============================================================ */ + +@keyframes asgardeo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes asgardeo-spinner-dash { + 0% { + stroke-dasharray: 1, 200; + stroke-dashoffset: 0; + } + 50% { + stroke-dasharray: 89, 200; + stroke-dashoffset: -35px; + } + 100% { + stroke-dasharray: 89, 200; + stroke-dashoffset: -124px; + } +} +`; + +export default ANIMATIONS_CSS; diff --git a/packages/vue/src/styles/defaults.css.ts b/packages/vue/src/styles/defaults.css.ts new file mode 100644 index 000000000..946a61242 --- /dev/null +++ b/packages/vue/src/styles/defaults.css.ts @@ -0,0 +1,189 @@ +/** + * 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. + */ + +/** + * Default CSS custom property fallback values. + * + * These are written into a `:root` rule so that every Asgardeo Vue primitive + * renders correctly even when no ThemeProvider is mounted. When ThemeProvider + * IS present it calls `document.documentElement.style.setProperty(...)` which + * has higher specificity than a stylesheet `:root` rule and therefore wins + * automatically β€” no special cascade tricks required. + * + * Design token naming follows the pattern: + * --asgardeo-{category}-{sub}-{scale?} + */ +const DEFAULTS_CSS: string = ` +/* ============================================================ + Asgardeo Vue SDK – CSS variable defaults + (ThemeProvider overrides these at runtime via inline styles) + ============================================================ */ +:root { + /* --- Colors: Primary --- */ + --asgardeo-color-primary-main: #4b6ef5; + --asgardeo-color-primary-light: #eef1fe; + --asgardeo-color-primary-dark: #3451d1; + --asgardeo-color-primary-contrastText: #ffffff; + + /* --- Colors: Secondary --- */ + --asgardeo-color-secondary-main: #4b5563; + --asgardeo-color-secondary-light: #f3f4f6; + --asgardeo-color-secondary-contrastText: #ffffff; + + /* --- Colors: Background --- */ + --asgardeo-color-background-surface: #ffffff; + --asgardeo-color-background-body: #f9fafb; + --asgardeo-color-background-disabled: #f3f4f6; + --asgardeo-color-background-muted: #f1f3f5; + + /* --- Colors: Text --- */ + --asgardeo-color-text-primary: #111827; + --asgardeo-color-text-secondary: #6b7280; + + /* --- Colors: Border --- */ + --asgardeo-color-border: #e5e7eb; + --asgardeo-color-border-focus: var(--asgardeo-color-primary-main); + + /* --- Colors: Action states --- */ + --asgardeo-color-action-hover: rgba(0, 0, 0, 0.04); + --asgardeo-color-action-selected: rgba(75, 110, 245, 0.08); + --asgardeo-color-action-focus: rgba(75, 110, 245, 0.12); + --asgardeo-color-action-disabled: rgba(0, 0, 0, 0.26); + --asgardeo-color-action-disabledBackground: rgba(0, 0, 0, 0.08); + + /* --- Colors: Semantic --- */ + --asgardeo-color-error-main: #ef4444; + --asgardeo-color-error-light: #fef2f2; + --asgardeo-color-error-contrastText: #991b1b; + --asgardeo-color-success-main: #22c55e; + --asgardeo-color-success-light: #f0fdf4; + --asgardeo-color-success-contrastText: #166534; + --asgardeo-color-warning-main: #f59e0b; + --asgardeo-color-warning-light: #fffbeb; + --asgardeo-color-warning-contrastText: #92400e; + --asgardeo-color-info-main: #3b82f6; + --asgardeo-color-info-light: #eff6ff; + --asgardeo-color-info-contrastText: #1e40af; + + /* --- Spacing --- */ + --asgardeo-spacing-unit: 8px; + + /* --- Border Radius --- */ + --asgardeo-border-radius-xs: 4px; + --asgardeo-border-radius-small: 6px; + --asgardeo-border-radius-medium: 10px; + --asgardeo-border-radius-large: 14px; + --asgardeo-border-radius-full: 9999px; + + /* --- Shadows --- */ + --asgardeo-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); + --asgardeo-shadow-small: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); + --asgardeo-shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.05); + --asgardeo-shadow-large: 0 10px 25px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.05); + + /* --- Transitions --- */ + --asgardeo-transition-fast: 120ms ease; + --asgardeo-transition-normal: 180ms ease; + --asgardeo-transition-slow: 280ms ease; + + /* --- Focus Ring --- */ + --asgardeo-focus-ring-width: 2px; + --asgardeo-focus-ring-offset: 2px; + --asgardeo-focus-ring-color: rgba(75, 110, 245, 0.35); + + /* --- Typography: Font Family --- */ + --asgardeo-typography-fontFamily: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + + /* --- Typography: Font Sizes --- */ + --asgardeo-typography-fontSize-xs: 0.6875rem; /* 11px */ + --asgardeo-typography-fontSize-sm: 0.8125rem; /* 13px */ + --asgardeo-typography-fontSize-md: 0.875rem; /* 14px */ + --asgardeo-typography-fontSize-lg: 1rem; /* 16px */ + --asgardeo-typography-fontSize-xl: 1.125rem; /* 18px */ + --asgardeo-typography-fontSize-2xl: 1.375rem; /* 22px */ + --asgardeo-typography-fontSize-3xl: 1.75rem; /* 28px */ + + /* --- Typography: Font Weights --- */ + --asgardeo-typography-fontWeight-normal: 400; + --asgardeo-typography-fontWeight-medium: 500; + --asgardeo-typography-fontWeight-semibold: 600; + --asgardeo-typography-fontWeight-bold: 700; + + /* --- Typography: Line Heights --- */ + --asgardeo-typography-lineHeight-tight: 1.25; + --asgardeo-typography-lineHeight-normal: 1.5; + --asgardeo-typography-lineHeight-relaxed: 1.625; + + /* --- Typography: Letter Spacing --- */ + --asgardeo-typography-letterSpacing-tight: -0.01em; + --asgardeo-typography-letterSpacing-normal: 0; + --asgardeo-typography-letterSpacing-wide: 0.025em; + + /* --- Component: Button --- */ + --asgardeo-button-borderRadius: var(--asgardeo-border-radius-small); + --asgardeo-button-fontWeight: var(--asgardeo-typography-fontWeight-medium); + --asgardeo-button-sm-height: 30px; + --asgardeo-button-sm-paddingX: calc(var(--asgardeo-spacing-unit) * 1.25); + --asgardeo-button-sm-fontSize: var(--asgardeo-typography-fontSize-sm); + --asgardeo-button-md-height: 36px; + --asgardeo-button-md-paddingX: calc(var(--asgardeo-spacing-unit) * 2); + --asgardeo-button-md-fontSize: var(--asgardeo-typography-fontSize-md); + --asgardeo-button-lg-height: 42px; + --asgardeo-button-lg-paddingX: calc(var(--asgardeo-spacing-unit) * 2.5); + --asgardeo-button-lg-fontSize: var(--asgardeo-typography-fontSize-lg); + + /* --- Component: Input fields --- */ + --asgardeo-input-borderRadius: var(--asgardeo-border-radius-small); + --asgardeo-input-height: 36px; + --asgardeo-input-paddingX: calc(var(--asgardeo-spacing-unit) * 1.25); + --asgardeo-input-fontSize: var(--asgardeo-typography-fontSize-md); + --asgardeo-input-borderColor: var(--asgardeo-color-border); + --asgardeo-input-focusBorderColor: var(--asgardeo-color-primary-main); + --asgardeo-input-focusRing: 0 0 0 3px var(--asgardeo-focus-ring-color); + + /* --- Component: Card --- */ + --asgardeo-card-borderRadius: var(--asgardeo-border-radius-medium); + --asgardeo-card-padding: calc(var(--asgardeo-spacing-unit) * 2.5); + --asgardeo-card-shadow: var(--asgardeo-shadow-small); + --asgardeo-card-borderColor: var(--asgardeo-color-border); + + /* --- Component: Alert --- */ + --asgardeo-alert-borderRadius: var(--asgardeo-border-radius-small); + --asgardeo-alert-paddingX: calc(var(--asgardeo-spacing-unit) * 1.5); + --asgardeo-alert-paddingY: calc(var(--asgardeo-spacing-unit) * 1.25); + + /* --- Component: Checkbox --- */ + --asgardeo-checkbox-size: 16px; + + /* --- Component: Avatar --- */ + --asgardeo-avatar-size: 64px; + --asgardeo-avatar-fontSize: 1.375rem; + + /* --- Component: Dropdown --- */ + --asgardeo-dropdown-borderRadius: var(--asgardeo-border-radius-medium); + --asgardeo-dropdown-shadow: var(--asgardeo-shadow-medium); + --asgardeo-dropdown-itemPaddingX: calc(var(--asgardeo-spacing-unit) * 1.5); + --asgardeo-dropdown-itemPaddingY: calc(var(--asgardeo-spacing-unit) * 1); + + /* --- Component overrides (set by ThemeProvider when configured) --- */ + --asgardeo-component-button-root-borderRadius: var(--asgardeo-button-borderRadius); + --asgardeo-component-field-root-borderRadius: var(--asgardeo-input-borderRadius); +} +`; + +export default DEFAULTS_CSS; diff --git a/packages/vue/src/styles/injectStyles.ts b/packages/vue/src/styles/injectStyles.ts new file mode 100644 index 000000000..b443c40e5 --- /dev/null +++ b/packages/vue/src/styles/injectStyles.ts @@ -0,0 +1,115 @@ +ο»Ώ/** + * 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. + */ + +/** + * Style injection orchestrator for the Asgardeo Vue SDK. + * + * Each component owns its CSS in a co-located `*.css.ts` file. + * This module assembles those CSS strings in a deterministic order and + * injects them as a single `