diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/index.html b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/index.html new file mode 100644 index 000000000..f98a94042 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/index.html @@ -0,0 +1,19 @@ + + + + + + + Ignite UI for React + + + + + + +
+ + + diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app-routes.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app-routes.tsx new file mode 100644 index 000000000..1361b746f --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app-routes.tsx @@ -0,0 +1,24 @@ +import Home from './home/home'; +import Profile from './authentication/pages/Profile'; +import RedirectGoogle from './authentication/pages/RedirectGoogle'; +import RedirectMicrosoft from './authentication/pages/RedirectMicrosoft'; +import RedirectFacebook from './authentication/pages/RedirectFacebook'; +import { AuthGuard } from './authentication/AuthGuard'; + +export const routes = [ + { path: '/', element: , text: 'Home', icon: 'home' }, + { + path: '/auth/profile', + element: ( + + + + ), + text: 'Profile', + icon: 'account_circle', + requiresAuth: true + }, + { path: '/auth/redirect-google', element: }, + { path: '/auth/redirect-microsoft', element: }, + { path: '/auth/redirect-facebook', element: }, +]; diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app.css b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app.css new file mode 100644 index 000000000..c43949107 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app.css @@ -0,0 +1,84 @@ +.app { + display: flex; + flex-flow: column nowrap; + height: 100%; + overflow: hidden; +} + +.app__navbar { + display: flex; + align-items: center; + flex: 0 0 auto; + height: 56px; + padding: 0 16px; + background: #239ef0; + box-shadow: 0 2px 4px rgba(0, 0, 0, .24); + box-sizing: border-box; + position: relative; + z-index: 10; +} + +.app__navbar-spacer { + flex: 1 1 auto; +} + +.app__title { + margin: 0 0 0 16px; + font-size: 1.25rem; + font-weight: 600; + line-height: 1; + color: #000; +} + +.app__menu-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + color: #000; + border: 0; + background: transparent; + cursor: pointer; +} + +.app__menu-button igc-icon { + font-size: 24px; +} + +.app__body { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +.app__drawer { + flex: 0 0 auto; + height: 100%; + --menu-full-width: 280px; +} + +igc-nav-drawer-item::part(base) { + min-height: 48px; + color: #2d2d2d; +} + +igc-nav-drawer-item[active]::part(base) { + background: #e0f2ff; + color: #0075d2; +} + +igc-nav-drawer-item[active] igc-icon { + color: #0075d2; +} + +.app__content { + flex: 1 1 auto; + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: stretch; + min-width: 0; + overflow: auto; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app.tsx new file mode 100644 index 000000000..4bca942fa --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app.tsx @@ -0,0 +1,124 @@ +import { useEffect, useMemo, useState } from "react"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { + IgrIcon, + IgrNavDrawer, + IgrNavDrawerItem, + registerIcon, +} from "igniteui-react"; +import { configureTheme } from "igniteui-webcomponents"; +import { AuthProvider, useAuth } from "./authentication/AuthContext"; +import { LoginBar } from "./authentication/components/LoginBar"; +import { routes } from "./app-routes"; +import "igniteui-webcomponents/themes/light/material.css"; +import "./app.css"; + +configureTheme('material', 'light'); + +const materialIcons = [ + ['home', 'action/svg/production/ic_home_24px.svg'], + ['menu', 'navigation/svg/production/ic_menu_24px.svg'], + ['apps', 'navigation/svg/production/ic_apps_24px.svg'], + ['code', 'action/svg/production/ic_code_24px.svg'], + ['build', 'action/svg/production/ic_build_24px.svg'], + ['palette', 'image/svg/production/ic_palette_24px.svg'], + ['account_circle', 'action/svg/production/ic_account_circle_24px.svg'], + ['lock', 'action/svg/production/ic_lock_24px.svg'], + ['assignment_ind', 'action/svg/production/ic_assignment_ind_24px.svg'], +] as const; + +materialIcons.forEach(([name, path]) => + registerIcon(name, `https://unpkg.com/material-design-icons@3.0.1/${path}`, "material") +); + +function AppContent() { + const name = "$(name)"; + const location = useLocation(); + const navigate = useNavigate(); + const { currentUser } = useAuth(); + const [drawerOpen, setDrawerOpen] = useState(true); + const [drawerPosition, setDrawerPosition] = useState<"relative" | "start">("relative"); + + const visibleRoutes = useMemo(() => { + return routes.filter((route) => { + if (!route.path || !route.text) return false; + if ((route as any).requiresAuth && !currentUser) return false; + return true; + }); + }, [currentUser]); + + useEffect(() => { + const mediaQuery = window.matchMedia("(min-width: 1025px)"); + const updateDrawerState = () => { + setDrawerOpen(mediaQuery.matches); + setDrawerPosition(mediaQuery.matches ? "relative" : "start"); + }; + + updateDrawerState(); + mediaQuery.addEventListener("change", updateDrawerState); + + return () => mediaQuery.removeEventListener("change", updateDrawerState); + }, []); + + const handleRouteClick = (path: string) => { + navigate(path); + + if (window.matchMedia("(max-width: 1024px)").matches) { + setDrawerOpen(false); + } + }; + + return ( +
+
+ +

{name}

+
+ +
+
+ + {visibleRoutes.map((route) => ( + handleRouteClick(route.path)} + > + + {route.text} + + ))} + +
+ +
+
+
+ ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/AuthContext.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/AuthContext.tsx new file mode 100644 index 000000000..8959ce8f1 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/AuthContext.tsx @@ -0,0 +1,73 @@ +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import type { User } from './models/user'; +import type { Login } from './models/login'; +import type { RegisterInfo } from './models/register-info'; +import type { ExternalLogin } from './models/external-login'; +import { Authentication } from './services/authentication'; +import { UserStore } from './services/userStore'; +import { ExternalAuth } from './services/externalAuth'; + +interface AuthContextType { + currentUser: User | null; + initials: string | null; + login: (data: Login) => Promise; + register: (data: RegisterInfo) => Promise; + loginWith: (data: ExternalLogin) => Promise; + logout: () => void; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [currentUser, setCurrentUser] = useState(() => UserStore.getUser()); + + const initials = currentUser ? UserStore.getInitials(currentUser) : null; + + const login = useCallback(async (data: Login): Promise => { + const result = await Authentication.login(data); + if (result.user) { + UserStore.setUser(result.user); + setCurrentUser(result.user); + return null; + } + return result.error ?? 'Login failed'; + }, []); + + const register = useCallback(async (data: RegisterInfo): Promise => { + const result = await Authentication.register(data); + if (result.user) { + UserStore.setUser(result.user); + setCurrentUser(result.user); + return null; + } + return result.error ?? 'Registration failed'; + }, []); + + const loginWith = useCallback(async (data: ExternalLogin): Promise => { + const result = await Authentication.loginWith(data); + if (result.user) { + UserStore.setUser(result.user); + setCurrentUser(result.user); + return null; + } + return result.error ?? 'Social login failed'; + }, []); + + const logout = useCallback(() => { + ExternalAuth.logout(); + UserStore.clearUser(); + setCurrentUser(null); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/AuthGuard.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/AuthGuard.tsx new file mode 100644 index 000000000..0fa49bfda --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/AuthGuard.tsx @@ -0,0 +1,14 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from './AuthContext'; +import type { ReactNode } from 'react'; + +export function AuthGuard({ children }: { children: ReactNode }) { + const { currentUser } = useAuth(); + const location = useLocation(); + + if (!currentUser) { + return ; + } + + return <>{children}; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Login.module.css b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Login.module.css new file mode 100644 index 000000000..ba87e6bae --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Login.module.css @@ -0,0 +1,93 @@ +.form { + display: flex; + flex-flow: column; + gap: 16px; + padding: 8px 0 0; +} + +.form igc-input { + width: 100%; + --ig-input-group-focused-secondary-color: #239ef0; + --ig-input-group-focused-border-color: #239ef0; + --ig-input-group-filled-text-color: #2d2d2d; +} + +.form igc-input igc-icon { + color: #0075d2; + --ig-icon-size: 1.50rem; +} + +.error { + margin: 0; + font-size: .875rem; + color: #d32f2f; +} + +.actions { + display: flex; + flex-flow: column; + gap: 8px; + padding-top: 4px; +} + +.submitBtn { + display: block; +} + +.submitBtn::part(base) { + width: 100%; + min-height: 40px; + font-weight: 600; + text-transform: uppercase; +} + +.submitBtn:not([disabled])::part(base) { + background: #239ef0; + color: #fff; +} + +.submitBtn:not([disabled])::part(base):hover { + background: #1a8fd8; +} + +.submitBtn[disabled]::part(base) { + background: #e0e0e0; + color: #767676; +} + +.linkBtn { + align-self: center; + color: #0075d2; + font-size: .875rem; + cursor: pointer; + text-decoration: underline; + text-transform: none; +} + +.linkBtn:hover, +.linkBtn:focus-visible { + color: #005da8; +} + +.socialLogin { + display: grid; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #d7d7d7; +} + +.socialBtn { + display: block; +} + +.socialBtn::part(base) { + width: 100%; + min-height: 40px; + color: #fff; + font-weight: 600; + text-transform: uppercase; +} + +.google::part(base) { background: rgb(255, 19, 74); } +.facebook::part(base) { background: rgb(19, 119, 213); } +.microsoft::part(base){ background: rgb(27, 158, 245); } diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Login.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Login.tsx new file mode 100644 index 000000000..45baa3c51 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Login.tsx @@ -0,0 +1,69 @@ +import { useState, type FormEvent } from 'react'; +import { IgrButton, IgrIcon, IgrInput } from 'igniteui-react'; +import { useAuth } from '../AuthContext'; +import { ExternalAuth } from '../services/externalAuth'; +import type { Login as LoginData } from '../models/login'; +import styles from './Login.module.css'; + +interface LoginProps { + onRegister: () => void; + onSuccess: () => void; +} + +export function Login({ onRegister, onSuccess }: LoginProps) { + const { login } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const canSubmit = email.trim() !== '' && password !== ''; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + const data: LoginData = { email, password }; + const err = await login(data); + if (err) { + setError(err); + } else { + setPassword(''); + onSuccess(); + } + }; + + return ( +
+ setEmail(e.detail ?? '')}> + + + setPassword(e.detail ?? '')}> + + + {error &&

{error}

} +
+ + Log In + + Create new account +
+ {ExternalAuth.hasProvider() && ( +
+ {ExternalAuth.hasProvider('google') && ( + ExternalAuth.login('google')}>Sign in with Google + )} + {ExternalAuth.hasProvider('facebook') && ( + ExternalAuth.login('facebook')}>Sign in with Facebook + )} + {ExternalAuth.hasProvider('microsoft') && ( + ExternalAuth.login('microsoft')}>Sign in with Microsoft + )} +
+ )} +
+ ); +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginBar.module.css b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginBar.module.css new file mode 100644 index 000000000..234815126 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginBar.module.css @@ -0,0 +1,42 @@ +.loginBtn::part(base) { + color: #0075d2; + background: #fff; + border-color: rgba(0, 117, 210, 0.35); + font-weight: 600; + white-space: nowrap; + transition: background .15s; +} + +.loginBtn::part(base):hover { + background: #e8f3fc; +} + +.profileAvatar { + cursor: pointer; + color: #0075d2; + --ig-avatar-background: #fff; + --ig-avatar-color: #0075d2; +} + +:global(igc-dropdown-item:hover), +:global(igc-dropdown-item[active]:hover) { + background: #e8f3fc; + color: #0075d2; +} + +:global(igc-dropdown-item[active]) { + background: #e8f3fc; + color: #0075d2; +} + +:global(igc-dropdown-item[selected]), +:global(igc-dropdown-item[selected]:hover), +:global(igc-dropdown-item[selected][active]) { + background: #e8f3fc; + color: #0075d2; +} + +.profileAvatar:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginBar.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginBar.tsx new file mode 100644 index 000000000..1479a4a3e --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginBar.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { IgrAvatar, IgrButton, IgrDropdown, IgrDropdownItem } from 'igniteui-react'; +import { useAuth } from '../AuthContext'; +import { LoginDialog } from './LoginDialog'; +import styles from './LoginBar.module.css'; + +export function LoginBar() { + const { currentUser, initials, logout } = useAuth(); + const navigate = useNavigate(); + const [dialogOpen, setDialogOpen] = useState(false); + + if (!currentUser) { + return ( + <> + setDialogOpen(true)}> + Log In + + setDialogOpen(false)} /> + + ); + } + + return ( + + + navigate('/auth/profile')}> + Profile + + { logout(); navigate('/'); }}> + Log Out + + + ); +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginDialog.module.css b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginDialog.module.css new file mode 100644 index 000000000..7188a6c3f --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginDialog.module.css @@ -0,0 +1,14 @@ +.dialog::part(base) { + max-width: 24rem; + width: calc(100vw - 48px); +} + +.dialog::part(title) { + font-size: 1.125rem; + font-weight: 600; + color: #2d2d2d; +} + +.body { + padding: 4px 0; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginDialog.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginDialog.tsx new file mode 100644 index 000000000..cfe5cb174 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginDialog.tsx @@ -0,0 +1,49 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { IgrDialog } from 'igniteui-react'; +import { Login } from './Login'; +import { Register } from './Register'; +import styles from './LoginDialog.module.css'; + +interface LoginDialogProps { + open: boolean; + onClose: () => void; +} + +export function LoginDialog({ open, onClose }: LoginDialogProps) { + const [showLogin, setShowLogin] = useState(true); + const dialogRef = useRef(null); + const navigate = useNavigate(); + + useEffect(() => { + if (open) { + setShowLogin(true); + dialogRef.current?.show(); + } else { + dialogRef.current?.hide(); + } + }, [open]); + + const handleSuccess = () => { + dialogRef.current?.hide(); + navigate('/auth/profile'); + }; + + return ( + +
+ {showLogin + ? setShowLogin(false)} onSuccess={handleSuccess} /> + : setShowLogin(true)} onSuccess={handleSuccess} /> + } +
+ +
+ ); +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Register.module.css b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Register.module.css new file mode 100644 index 000000000..faeb05420 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Register.module.css @@ -0,0 +1,74 @@ +.form { + display: flex; + flex-flow: column; + gap: 16px; + padding: 8px 0 0; +} + +.form igc-input { + width: 100%; + --ig-input-group-focused-secondary-color: #239ef0; + --ig-input-group-focused-border-color: #239ef0; + --ig-input-group-filled-text-color: #2d2d2d; +} + +.form igc-input igc-icon { + color: #0075d2; + --ig-icon-size: 1.50rem; +} + +.error { + margin: 0; + font-size: .875rem; + color: #d32f2f; +} + +.actions { + display: flex; + flex-flow: column; + gap: 8px; + padding-top: 4px; +} + +.submitBtn { + display: block; +} + +.submitBtn::part(base) { + width: 100%; + min-height: 40px; + font-weight: 600; + text-transform: uppercase; +} + +.submitBtn:not([disabled])::part(base) { + background: #239ef0; + color: #fff; +} + +.submitBtn:not([disabled])::part(base):hover { + background: #1a8fd8; +} + +.submitBtn[disabled]::part(base) { + background: #e0e0e0; + color: #767676; +} + +.linkBtn { + align-self: center; + color: #0075d2; + font-size: .875rem; + cursor: pointer; + text-decoration: underline; + text-transform: none; +} + +.linkBtn:hover, +.linkBtn:focus-visible { + color: #005da8; +} + +.linkBtn::part(base):hover { + color: #005da8; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Register.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Register.tsx new file mode 100644 index 000000000..9116fece1 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Register.tsx @@ -0,0 +1,67 @@ +import { useState, type FormEvent } from 'react'; +import { IgrButton, IgrIcon, IgrInput } from 'igniteui-react'; +import { useAuth } from '../AuthContext'; +import type { RegisterInfo } from '../models/register-info'; +import styles from './Register.module.css'; + +interface RegisterProps { + onLogin: () => void; + onSuccess: () => void; +} + +export function Register({ onLogin, onSuccess }: RegisterProps) { + const { register } = useAuth(); + const [givenName, setGivenName] = useState(''); + const [familyName, setFamilyName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const canSubmit = givenName.trim() !== '' && email.trim() !== '' && password !== ''; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + const data: RegisterInfo = { + given_name: givenName, + family_name: familyName, + email, + password + }; + const err = await register(data); + if (err) { + setError(err); + } else { + setPassword(''); + onSuccess(); + } + }; + + return ( +
+ setGivenName(e.detail ?? '')}> + + + setFamilyName(e.detail ?? '')}> + + + setEmail(e.detail ?? '')}> + + + setPassword(e.detail ?? '')}> + + + {error &&

{error}

} +
+ + Sign Up + + Have an account? +
+
+ ); +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/external-login.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/external-login.ts new file mode 100644 index 000000000..17a7d9bf5 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/external-login.ts @@ -0,0 +1,10 @@ +/** User profile returned by a social (external) auth provider. */ +export interface ExternalLogin { + id: string; + name: string; + email?: string; // not always present use id as fallback key + given_name?: string; + family_name?: string; + picture?: string; + externalToken: string; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/login.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/login.ts new file mode 100644 index 000000000..1269e5b3c --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/login.ts @@ -0,0 +1,4 @@ +export interface Login { + email: string; + password: string; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/register-info.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/register-info.ts new file mode 100644 index 000000000..1142fd1aa --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/register-info.ts @@ -0,0 +1,6 @@ +export interface RegisterInfo { + given_name: string; + family_name: string; + email: string; + password: string; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/user.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/user.ts new file mode 100644 index 000000000..a28a04abf --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/models/user.ts @@ -0,0 +1,19 @@ +/** Data transfer model expected from backend API JWT-s */ +export interface UserJWT { + exp: number; + name: string; + given_name: string; + family_name: string; + email: string; + picture?: string; +} + +/** Client user model */ +export interface User extends UserJWT { + token: string; +} + +export interface LoginResult { + user?: User; + error?: string; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/Profile.module.css b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/Profile.module.css new file mode 100644 index 000000000..ee713023a --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/Profile.module.css @@ -0,0 +1,87 @@ +.container { + display: flex; + justify-content: center; + padding: 48px 16px; + width: 100%; + box-sizing: border-box; +} + +.card { + align-self: flex-start; + width: 100%; + max-width: 640px; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, .08); + padding: 32px; + box-sizing: border-box; +} + +.header { + display: flex; + align-items: center; + gap: 20px; + padding-bottom: 24px; + border-bottom: 1px solid #d7eaf8; +} + +.avatar { + flex: 0 0 auto; + --ig-avatar-background: #e0f2ff; + --ig-avatar-color: #0075d2; + --ig-avatar-size: 4rem; +} + +.intro { + min-width: 0; +} + +.status { + margin: 0 0 4px; + color: #000; + font-size: .875rem; + font-weight: 700; + text-transform: uppercase; +} + +.name { + margin: 0; + overflow-wrap: anywhere; + color: #09f; + font-size: 2rem; + font-weight: 600; + line-height: 1.2; +} + +.description { + margin: 8px 0 0; + color: #000; + font-size: 1rem; + line-height: 1.5; +} + +.details { + margin: 28px 0 0; + padding: 0; +} + +.row { + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + gap: 24px; + padding: 14px 0; + border-bottom: 1px solid #eef3f7; +} + +.dt { + color: rgba(0, 0, 0, .62); + font-size: .875rem; + font-weight: 600; + margin: 0; +} + +.dd { + margin: 0; + font-size: 1rem; + color: #2d2d2d; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/Profile.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/Profile.tsx new file mode 100644 index 000000000..c7f7d253e --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/Profile.tsx @@ -0,0 +1,42 @@ +import { IgrAvatar } from 'igniteui-react'; +import { useAuth } from '../AuthContext'; +import styles from './Profile.module.css'; + +export default function Profile() { + const { currentUser } = useAuth(); + const initials = ((currentUser?.given_name?.[0] ?? '') + (currentUser?.family_name?.[0] ?? '')).toUpperCase() || 'U'; + + return ( +
+
+
+ +
+

Signed in

+

{currentUser?.name || 'Your profile'}

+

Your account details are available on this protected route.

+
+
+
+
+
First name
+
{currentUser?.given_name || 'Not provided'}
+
+
+
Last name
+
{currentUser?.family_name || 'Not provided'}
+
+
+
Email
+
{currentUser?.email || 'No email available'}
+
+
+
+
+ ); +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/RedirectFacebook.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/RedirectFacebook.tsx new file mode 100644 index 000000000..6c47cb61d --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/RedirectFacebook.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ExternalAuth } from '../services/externalAuth'; +import { useAuth } from '../AuthContext'; + +/** + * Handles the Facebook login redirect. + * Facebook uses a popup (JS SDK) instead of PKCE, so this page reads the profile + * that was stored in sessionStorage during the FB.login() callback. + */ +export default function RedirectFacebook() { + const { loginWith } = useAuth(); + const navigate = useNavigate(); + const [error, setError] = useState(''); + + useEffect(() => { + (async () => { + try { + const externalUser = await ExternalAuth.handleRedirect('facebook'); + const err = await loginWith(externalUser); + if (err) { + setError(err); + } else { + navigate('/auth/profile'); + } + } catch (e: any) { + console.error('Facebook sign-in failed:', e); + setError('Facebook sign-in failed. Please try again.'); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return

Signing in with Facebook…

; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/RedirectGoogle.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/RedirectGoogle.tsx new file mode 100644 index 000000000..c746ec51b --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/RedirectGoogle.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ExternalAuth } from '../services/externalAuth'; +import { useAuth } from '../AuthContext'; + +/** Handles the OAuth redirect from Google. Exchanges the authorization code for a user profile. */ +export default function RedirectGoogle() { + const { loginWith } = useAuth(); + const navigate = useNavigate(); + const [error, setError] = useState(''); + + useEffect(() => { + (async () => { + try { + const externalUser = await ExternalAuth.handleRedirect('google'); + const err = await loginWith(externalUser); + if (err) { + setError(err); + } else { + navigate('/auth/profile'); + } + } catch (e: any) { + console.error('Google sign-in failed:', e); + setError('Google sign-in failed. Please try again.'); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return

Signing in with Google…

; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/RedirectMicrosoft.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/RedirectMicrosoft.tsx new file mode 100644 index 000000000..96a8fd500 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/RedirectMicrosoft.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ExternalAuth } from '../services/externalAuth'; +import { useAuth } from '../AuthContext'; + +/** Handles the OAuth redirect from Microsoft. Exchanges the authorization code for a user profile. */ +export default function RedirectMicrosoft() { + const { loginWith } = useAuth(); + const navigate = useNavigate(); + const [error, setError] = useState(''); + + useEffect(() => { + (async () => { + try { + const externalUser = await ExternalAuth.handleRedirect('microsoft'); + const err = await loginWith(externalUser); + if (err) { + setError(err); + } else { + navigate('/auth/profile'); + } + } catch (e: any) { + console.error('Microsoft sign-in failed:', e); + setError('Microsoft sign-in failed. Please try again.'); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return

Signing in with Microsoft…

; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts new file mode 100644 index 000000000..1ddefb5fd --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts @@ -0,0 +1,37 @@ +import type { Login } from '../models/login'; +import type { RegisterInfo } from '../models/register-info'; +import type { ExternalLogin } from '../models/external-login'; +import type { LoginResult } from '../models/user'; +import { parseUser } from './jwtUtil'; +import { fakeLogin, fakeRegister, fakeExtLogin } from './fakeBackend'; + +/** Authentication service — swap fakeLogin/fakeRegister for real API calls when ready. */ +export const Authentication = { + async login(data: Login): Promise { + try { + const token = await fakeLogin(data); + return { user: parseUser(token) }; + } catch (e: any) { + return { error: e.message }; + } + }, + + async register(data: RegisterInfo): Promise { + try { + const token = await fakeRegister(data); + return { user: parseUser(token) }; + } catch (e: any) { + return { error: e.message }; + } + }, + + /** Send user info from a social provider to the external login endpoint. */ + async loginWith(data: ExternalLogin): Promise { + try { + const token = fakeExtLogin(data); + return { user: parseUser(token) }; + } catch (e: any) { + return { error: e.message }; + } + } +}; diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-config.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-config.ts new file mode 100644 index 000000000..b8f19f14d --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-config.ts @@ -0,0 +1,44 @@ +// Social login configuration. +// To enable a provider, set its entry in oauthConfig below with your real credentials +// from the provider's developer console. +// +// Redirect URIs to register in each provider's app settings: +// {your-origin}/auth/redirect-google +// {your-origin}/auth/redirect-facebook +// {your-origin}/auth/redirect-microsoft +// +// Developer consoles: +// Google: https://console.cloud.google.com/apis/credentials +// Microsoft: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps +// Facebook: https://developers.facebook.com/apps + +export type OAuthProvider = 'google' | 'facebook' | 'microsoft'; + +export interface OAuthConfig { + google?: { clientId: string }; + + // tenantId defaults to 'common' (multi-tenant). Set it for single-tenant apps. + // IMPORTANT: The redirect URI must be registered as a SPA redirect URI in Azure + // (not "Web"), otherwise the token exchange will fail with a CORS error. + // See: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow + microsoft?: { clientId: string; tenantId?: string }; + + // Facebook login uses the JS SDK (popup flow). The SDK script must be loaded in + // index.html (see below). In the Facebook app dashboard you must also: + // - Enable "Login with the JavaScript SDK" + // - Add your domain to "Allowed Domains for the JavaScript SDK" + // - Add the redirect URI to "Valid OAuth Redirect URIs" + // - Serve the app over HTTPS + // See: https://developers.facebook.com/docs/facebook-login/web + facebook?: { clientId: string }; +} + +// Active OAuth configuration — fill in the providers you want to enable, for example: +// +// export const oauthConfig: OAuthConfig = { +// google: { clientId: 'YOUR_GOOGLE_CLIENT_ID' }, +// microsoft: { clientId: 'YOUR_AZURE_APP_CLIENT_ID', tenantId: 'common' }, +// // Note: Facebook requires HTTPS even for local dev - use ngrok or a local SSL proxy. +// facebook: { clientId: 'YOUR_FACEBOOK_APP_ID' }, +// }; +export const oauthConfig: OAuthConfig = {}; diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/externalAuth.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/externalAuth.ts new file mode 100644 index 000000000..83fb921e1 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/externalAuth.ts @@ -0,0 +1,272 @@ +import type { ExternalLogin } from '../models/external-login'; +import type { OAuthProvider } from './external-auth-config'; +import { oauthConfig } from './external-auth-config'; +import { generateCodeVerifier, generateCodeChallenge, buildAuthUrl } from './pkce'; + +// sessionStorage keys +const VERIFIER_KEY = '_pkce_verifier'; +const STATE_KEY = '_oauth_state'; +const FB_USER_KEY = '_fb_user'; +const ACTIVE_PROVIDER_KEY = '_ext_active_provider'; + +// Declared by the Facebook JS SDK (loaded via script tag in index.html) +declare const FB: any; + +// Set to true once FB.init() has been called in this session. +// Prevents FB.logout() from being called before initialization. +let fbInitialized = false; + +/** + * Decode a JWT payload segment. Handles Base64URL encoding (no padding, - and _ chars) + * which `atob()` does not accept natively - missing padding causes `InvalidCharacterError`. + */ +function decodeJwtPayload(token: string): any { + const base64url = token.split('.')[1]; + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '='); + return JSON.parse(atob(padded)); +} + +/** + * Waits until the Facebook JS SDK has loaded and is available on window. + * The SDK is loaded with `async defer` so it may not be ready when login() is called. + */ +function waitForFB(): Promise { + return new Promise(resolve => { + if (typeof (window as any).FB !== 'undefined') { resolve(); return; } + const id = setInterval(() => { + if (typeof (window as any).FB !== 'undefined') { clearInterval(id); resolve(); } + }, 50); + }); +} + +/** + * External (social) authentication service. + * Supports Google and Microsoft via OIDC/PKCE, and Facebook via the JS SDK. + * + * Usage: call login(provider) to start the flow; call handleRedirect(provider) + * on the matching redirect page to complete it and retrieve the user profile. + */ +export const ExternalAuth = { + /** Returns true if any provider (or the specific provider) is configured. */ + hasProvider(provider?: OAuthProvider): boolean { + if (provider) { + return provider in oauthConfig && (oauthConfig as any)[provider] != null; + } + return Object.values(oauthConfig).some(v => v != null); + }, + + /** Initiate login for the given provider. Redirects the page to the provider's auth endpoint. */ + async login(provider: OAuthProvider): Promise { + localStorage.setItem(ACTIVE_PROVIDER_KEY, provider); + if (provider === 'google') { + const cfg = oauthConfig.google!; + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + sessionStorage.setItem(VERIFIER_KEY, verifier); + const state = crypto.randomUUID(); + sessionStorage.setItem(STATE_KEY, state); + const redirectUri = `${window.location.origin}/auth/redirect-google`; + window.location.href = buildAuthUrl('https://accounts.google.com/o/oauth2/v2/auth', { + response_type: 'code', + client_id: cfg.clientId, + redirect_uri: redirectUri, + scope: 'openid profile email', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + }); + } else if (provider === 'microsoft') { + const cfg = oauthConfig.microsoft!; + const tenantId = cfg.tenantId ?? 'common'; + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + sessionStorage.setItem(VERIFIER_KEY, verifier); + const state = crypto.randomUUID(); + sessionStorage.setItem(STATE_KEY, state); + const redirectUri = `${window.location.origin}/auth/redirect-microsoft`; + window.location.href = buildAuthUrl( + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`, + { + response_type: 'code', + client_id: cfg.clientId, + redirect_uri: redirectUri, + scope: 'openid profile email', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + } + ); + } else if (provider === 'facebook') { + const cfg = oauthConfig.facebook!; + // Wait for the SDK to load (it is included with `async defer` in index.html + // and may not be available yet when the user clicks the login button). + await waitForFB(); + FB.init({ appId: cfg.clientId, xfbml: false, version: 'v3.1' }); + fbInitialized = true; + FB.login( + (response: any) => { + if (response.authResponse) { + FB.api( + '/me?fields=id,email,name,first_name,last_name,picture', + (res: any) => { + const user: ExternalLogin = { + id: res.id, + name: res.name, + given_name: res.first_name, + family_name: res.last_name, + email: res.email, + // Facebook returns picture as an object: { data: { url, width, height } } + picture: res.picture?.data?.url, + externalToken: FB.getAuthResponse()?.accessToken ?? '', + }; + sessionStorage.setItem(FB_USER_KEY, JSON.stringify(user)); + window.location.href = '/auth/redirect-facebook'; + } + ); + } + }, + { scope: 'public_profile,email' } + ); + } + }, + + /** + * Complete the OAuth redirect flow and return the external user profile. + * Call this from the /auth/redirect-{provider} page. + * + * For Google/Microsoft: exchanges the authorization code (PKCE) for tokens. + * For Facebook: reads the profile stored during the FB.login() popup flow. + */ + async handleRedirect(provider: OAuthProvider): Promise { + if (provider === 'facebook') { + const stored = sessionStorage.getItem(FB_USER_KEY); + if (!stored) throw new Error('No Facebook user data found. Please try again.'); + sessionStorage.removeItem(FB_USER_KEY); + return JSON.parse(stored) as ExternalLogin; + } + + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + if (!code) throw new Error('Missing authorization code in redirect URL.'); + + // Validate the state parameter to prevent CSRF attacks. + const returnedState = params.get('state'); + const savedState = sessionStorage.getItem(STATE_KEY); + sessionStorage.removeItem(STATE_KEY); + if (!returnedState || returnedState !== savedState) { + throw new Error('OAuth state mismatch. The request may have been tampered with.'); + } + + const verifier = sessionStorage.getItem(VERIFIER_KEY); + if (!verifier) throw new Error('Missing PKCE code verifier. Please try again.'); + sessionStorage.removeItem(VERIFIER_KEY); + + if (provider === 'google') { + const cfg = oauthConfig.google!; + const redirectUri = `${window.location.origin}/auth/redirect-google`; + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: cfg.clientId, + redirect_uri: redirectUri, + code, + code_verifier: verifier, + }), + }); + if (!res.ok) throw new Error('Google token exchange failed.'); + const data = await res.json(); + // Decode the id_token to extract user claims - no extra userinfo request needed + const payload = decodeJwtPayload(data.id_token); + return { + id: payload.sub, + name: payload.name, + given_name: payload.given_name, + family_name: payload.family_name, + email: payload.email, + picture: payload.picture, + externalToken: data.access_token, + }; + } + + if (provider === 'microsoft') { + const cfg = oauthConfig.microsoft!; + const tenantId = cfg.tenantId ?? 'common'; + const redirectUri = `${window.location.origin}/auth/redirect-microsoft`; + const res = await fetch( + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: cfg.clientId, + redirect_uri: redirectUri, + code, + code_verifier: verifier, + }), + } + ); + if (!res.ok) throw new Error('Microsoft token exchange failed.'); + const data = await res.json(); + const payload = decodeJwtPayload(data.id_token); + return { + id: payload.oid ?? payload.sub, + name: payload.name, + email: payload.email ?? payload.preferred_username, + externalToken: data.access_token, + }; + } + + throw new Error(`Unknown provider: ${provider}`); + }, + + /** + * Sign out from the active external provider (if any) and clear its stored state. + * Call this alongside clearing local user state on logout. + */ + logout(): void { + const provider = localStorage.getItem(ACTIVE_PROVIDER_KEY) as OAuthProvider | null; + localStorage.removeItem(ACTIVE_PROVIDER_KEY); + sessionStorage.removeItem(VERIFIER_KEY); + sessionStorage.removeItem(FB_USER_KEY); + + if (!provider) return; + + if (provider === 'google') { + // Redirect to Google's end-session endpoint to clear the Google session. + // The user is returned to the app root after sign-out. + const cfg = oauthConfig.google; + if (cfg) { + window.location.href = `https://accounts.google.com/logout`; + return; + } + } + + if (provider === 'microsoft') { + const cfg = oauthConfig.microsoft; + if (cfg) { + const tenantId = cfg.tenantId ?? 'common'; + const postLogoutRedirectUri = encodeURIComponent(window.location.origin); + window.location.href = + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/logout` + + `?post_logout_redirect_uri=${postLogoutRedirectUri}`; + return; + } + } + + if (provider === 'facebook') { + // Only call FB.logout() when the SDK was initialised in this session. + // Calling it on a fresh page load (before FB.init) throws an error. + try { + if (fbInitialized && typeof FB !== 'undefined') { + FB.logout(); + } + } catch { + // SDK not loaded - nothing to do + } + } + }, +}; diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/fakeBackend.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/fakeBackend.ts new file mode 100644 index 000000000..1e6899068 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/fakeBackend.ts @@ -0,0 +1,88 @@ +// ⚠ DEVELOPMENT ONLY — simulates POST /login, /register, /extlogin using localStorage. +// Before going to production: remove this interceptor and replace with calls to your real API. +import type { Login } from '../models/login'; +import type { RegisterInfo } from '../models/register-info'; +import type { ExternalLogin } from '../models/external-login'; + +const USERS_KEY = '_fake_users'; + +interface StoredUser { + given_name: string; + family_name: string; + email: string; + passwordHash: string; + externalId?: string; +} + +function getUsers(): StoredUser[] { + try { + return JSON.parse(localStorage.getItem(USERS_KEY) ?? '[]'); + } catch { + return []; + } +} + +function saveUsers(users: StoredUser[]): void { + localStorage.setItem(USERS_KEY, JSON.stringify(users)); +} + +async function hashPassword(password: string): Promise { + const data = new TextEncoder().encode(password); + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function makeJwt(payload: object): string { + const header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' })); + const body = btoa(JSON.stringify({ exp: Date.now() / 1000 + 3600, ...payload })); + return `${header}.${body}.`; +} + +export async function fakeLogin(data: Login): Promise { + const users = getUsers(); + const passwordHash = await hashPassword(data.password); + const user = users.find(u => u.email === data.email && u.passwordHash === passwordHash); + if (!user) { + throw new Error('Invalid email or password.'); + } + return makeJwt({ name: `${user.given_name} ${user.family_name}`, given_name: user.given_name, family_name: user.family_name, email: user.email }); +} + +export async function fakeRegister(data: RegisterInfo): Promise { + const users = getUsers(); + if (users.find(u => u.email === data.email)) { + throw new Error('An account with this email already exists.'); + } + const newUser: StoredUser = { + given_name: data.given_name, + family_name: data.family_name, + email: data.email, + passwordHash: await hashPassword(data.password) + }; + saveUsers([...users, newUser]); + return makeJwt({ name: `${data.given_name} ${data.family_name}`, given_name: data.given_name, family_name: data.family_name, email: data.email }); +} +/** Upsert a user from a social (external) auth provider and return a JWT. */ +export function fakeExtLogin(data: ExternalLogin): string { + const users = getUsers(); + const existing = users.find(u => u.email === data.email && data.email != null) + ?? users.find(u => u.externalId === data.id); + const given_name = data.given_name ?? data.name?.split(' ')[0] ?? ''; + const family_name = data.family_name ?? data.name?.split(' ').slice(1).join(' ') ?? ''; + // Resolve email: prefer what the provider returned, fall back to what we stored previously. + const email = data.email ?? existing?.email; + if (existing) { + // Update profile fields from provider (name/picture may change). + // Also store externalId if this user was originally created by email (first social login). + existing.given_name = given_name; + existing.family_name = family_name; + if (!existing.externalId) existing.externalId = data.id; + saveUsers(users); + } else { + if (!email) { + throw new Error('Cannot create an account without an email address.'); + } + saveUsers([...users, { given_name, family_name, email, passwordHash: '', externalId: data.id }]); + } + return makeJwt({ name: data.name, given_name, family_name, email, picture: data.picture }); +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/jwtUtil.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/jwtUtil.ts new file mode 100644 index 000000000..47a059e2f --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/jwtUtil.ts @@ -0,0 +1,10 @@ +import type { UserJWT } from '../models/user'; + +/** Parse the payload of a JWT string into a UserJWT object. */ +export function parseUser(token: string): UserJWT & { token: string } { + const base64url = token.split('.')[1]; + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '='); + const decoded = JSON.parse(atob(padded)); + return { ...decoded, token }; +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/pkce.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/pkce.ts new file mode 100644 index 000000000..1a7ba6d5c --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/pkce.ts @@ -0,0 +1,29 @@ +// PKCE (Proof Key for Code Exchange) utilities for OAuth 2.0 Authorization Code Flow. +// https://tools.ietf.org/html/rfc7636 + +function base64UrlEncode(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +/** Generate a cryptographically random PKCE code verifier (43–128 chars, URL-safe). */ +export function generateCodeVerifier(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return base64UrlEncode(bytes); +} + +/** Compute the S256 code challenge from a code verifier. */ +export async function generateCodeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** Build a URL with query parameters from a plain object. */ +export function buildAuthUrl(endpoint: string, params: Record): string { + const url = new URL(endpoint); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + return url.toString(); +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/userStore.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/userStore.ts new file mode 100644 index 000000000..a59fddeea --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/userStore.ts @@ -0,0 +1,39 @@ +import type { User } from '../models/user'; + +const USER_KEY = 'currentUser'; + +/** + * Simple localStorage-backed user store. + * + * NOTE: Storing the full user object in localStorage can be susceptible to XSS attacks. + * Consider additional security measures before going to production. + */ +export const UserStore = { + getUser(): User | null { + try { + const raw = localStorage.getItem(USER_KEY); + if (!raw) return null; + const parsed: User = JSON.parse(raw); + // Discard expired tokens so a stale session is never silently restored. + if (parsed.exp && Date.now() / 1000 > parsed.exp) { + localStorage.removeItem(USER_KEY); + return null; + } + return parsed; + } catch { + return null; + } + }, + + setUser(user: User): void { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + }, + + clearUser(): void { + localStorage.removeItem(USER_KEY); + }, + + getInitials(user: User): string { + return (user.given_name.charAt(0) + (user.family_name?.charAt(0) ?? '')).toUpperCase(); + } +}; diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-auth/index.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/index.ts new file mode 100644 index 000000000..342bce636 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/index.ts @@ -0,0 +1,19 @@ +import { ProjectTemplate } from "@igniteui/cli-core"; +import * as path from "path"; +import { SideNavIgrTsProject } from "../side-nav"; + +export class SideNavAuthIgrTsProject extends SideNavIgrTsProject implements ProjectTemplate { + public id: string = "side-nav-auth"; + public name = "Side navigation + login"; + public description = "Side navigation extended with user authentication module"; + public dependencies: string[] = []; + public framework: string = "react"; + public projectType: string = "igr-ts"; + public hasExtraConfiguration: boolean = false; + public isHidden: boolean = true; + + public get templatePaths(): string[] { + return [...super.templatePaths, path.join(__dirname, "files")]; + } +} +export default new SideNavAuthIgrTsProject(); diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/files/src/app/app.css b/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/files/src/app/app.css new file mode 100644 index 000000000..2d5a8b72c --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/files/src/app/app.css @@ -0,0 +1,106 @@ +.app { + display: flex; + flex-flow: column nowrap; + height: 100%; + overflow: hidden; +} + +.app__navbar { + display: flex; + align-items: center; + flex: 0 0 auto; + height: 56px; + padding: 0 16px; + background: #239ef0; + box-shadow: 0 2px 4px rgba(0, 0, 0, .24); + box-sizing: border-box; + position: relative; + z-index: 10; +} + +.app__navbar-spacer { + flex: 1 1 auto; +} + +.app__menu-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + color: #000; + border: 0; + background: transparent; + cursor: pointer; +} + +.app__menu-button igc-icon { + font-size: 24px; +} + +.app__title { + margin: 0 0 0 16px; + color: #000; + font-size: 1.25rem; + font-weight: 600; + line-height: 1; +} + +.app__body { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +.app__drawer { + flex: 0 0 auto; + height: 100%; + --menu-full-width: 280px; +} + +igc-nav-drawer-item[active]::part(base) { + background: #e0f2ff; + color: #0075d2; +} + +igc-nav-drawer-item[active] igc-icon { + color: #0075d2; +} + +.app--mini .app__drawer { + --menu-full-width: 68px; +} + +igc-nav-drawer.app__drawer::part(base) { + transition: width 0.3s ease-out; + overflow: hidden; +} + +.app--mini igc-nav-drawer-item::part(base) { + justify-content: center; + width: 40px; + min-height: 40px; + padding: 0; + margin: 4px auto; + border-radius: 8px; +} + +.app--mini igc-nav-drawer-item::part(content) { + display: none; +} + +.app__content { + flex: 1 1 auto; + min-width: 0; + overflow: auto; + display: flex; + justify-content: center; + align-items: flex-start; +} + +@media (max-width: 1024px) { + .app__menu-button { + display: none; + } +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/files/src/app/app.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/files/src/app/app.tsx new file mode 100644 index 000000000..94cc0a76c --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/files/src/app/app.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { + IgrNavDrawer, + IgrNavDrawerItem, + IgrIcon, + registerIcon, +} from 'igniteui-react'; +import { configureTheme } from 'igniteui-webcomponents'; +import { AuthProvider, useAuth } from './authentication/AuthContext'; +import { LoginBar } from './authentication/components/LoginBar'; +import { routes } from './app-routes'; +import 'igniteui-webcomponents/themes/light/material.css'; +import './app.css'; + +configureTheme('material', 'light'); + +const materialIcons = [ + ['home', 'action/svg/production/ic_home_24px.svg'], + ['menu', 'navigation/svg/production/ic_menu_24px.svg'], + ['apps', 'navigation/svg/production/ic_apps_24px.svg'], + ['code', 'action/svg/production/ic_code_24px.svg'], + ['build', 'action/svg/production/ic_build_24px.svg'], + ['palette', 'image/svg/production/ic_palette_24px.svg'], + ['account_circle', 'action/svg/production/ic_account_circle_24px.svg'], + ['lock', 'action/svg/production/ic_lock_24px.svg'], + ['assignment_ind', 'action/svg/production/ic_assignment_ind_24px.svg'], +] as const; + +materialIcons.forEach(([name, path]) => + registerIcon(name, `https://unpkg.com/material-design-icons@3.0.1/${path}`, 'material') +); + +function AppContent() { + const [drawerOpen, setDrawerOpen] = useState(true); + const navigate = useNavigate(); + const location = useLocation(); + const { currentUser } = useAuth(); + + const navRoutes = useMemo(() => routes.filter((r) => { + if (!r.text) return false; + if ((r as any).requiresAuth && !currentUser) return false; + return true; + }), [currentUser]); + + useEffect(() => { + const mq = window.matchMedia('(min-width: 1025px)'); + const update = () => setDrawerOpen(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + + return ( +
+
+ +

$(name)

+
+ +
+
+ + {navRoutes.map((route) => ( + navigate(route.path)} + > + + {route.text} + + ))} + +
+ +
+
+
+ ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/index.ts b/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/index.ts new file mode 100644 index 000000000..55ec60276 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/index.ts @@ -0,0 +1,23 @@ +import { ProjectTemplate } from "@igniteui/cli-core"; +import * as path from "path"; +import { SideNavMiniIgrTsProject } from "../side-nav-mini"; + +export class SideNavMiniAuthIgrTsProject extends SideNavMiniIgrTsProject implements ProjectTemplate { + public id: string = "side-nav-mini-auth"; + public name = "Side navigation (mini) + login"; + public description = "Collapsible mini side navigation extended with user authentication module"; + public dependencies: string[] = []; + public framework: string = "react"; + public projectType: string = "igr-ts"; + public hasExtraConfiguration: boolean = false; + public isHidden: boolean = true; + + public get templatePaths(): string[] { + return [ + ...super.templatePaths, + path.join(__dirname, "../side-nav-auth/files"), + path.join(__dirname, "files") + ]; + } +} +export default new SideNavMiniAuthIgrTsProject(); diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-mini/files/src/app/app.css b/packages/cli/templates/react/igr-ts/projects/side-nav-mini/files/src/app/app.css index 039fafee2..5b01df4e1 100644 --- a/packages/cli/templates/react/igr-ts/projects/side-nav-mini/files/src/app/app.css +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-mini/files/src/app/app.css @@ -27,6 +27,9 @@ border: 0; background: transparent; cursor: pointer; +} + +.app__menu-button igc-icon { font-size: 24px; } @@ -50,6 +53,11 @@ --menu-full-width: 280px; } +igc-nav-drawer-item::part(base) { + min-height: 48px; + color: #2d2d2d; +} + igc-nav-drawer-item[active]::part(base) { background: #e0f2ff; color: #0075d2; @@ -59,6 +67,10 @@ igc-nav-drawer-item[active] igc-icon { color: #0075d2; } +igc-nav-drawer-item:not([active]) igc-icon { + color: #2d2d2d; +} + .app--mini .app__drawer { --menu-full-width: 68px; } diff --git a/packages/cli/templates/react/igr-ts/projects/side-nav-mini/files/src/app/app.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav-mini/files/src/app/app.tsx index f92274eb2..6a5c3eed2 100644 --- a/packages/cli/templates/react/igr-ts/projects/side-nav-mini/files/src/app/app.tsx +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-mini/files/src/app/app.tsx @@ -6,10 +6,13 @@ import { IgrIcon, registerIcon, } from 'igniteui-react'; +import { configureTheme } from 'igniteui-webcomponents'; import { routes } from './app-routes'; -import 'igniteui-webcomponents/themes/light/bootstrap.css'; +import 'igniteui-webcomponents/themes/light/material.css'; import './app.css'; +configureTheme('material', 'light'); + const materialIcons = [ ['home', 'action/svg/production/ic_home_24px.svg'], ['menu', 'navigation/svg/production/ic_menu_24px.svg'], diff --git a/packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app-routes.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app-routes.tsx similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app-routes.tsx rename to packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app-routes.tsx diff --git a/packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app.css b/packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app.css similarity index 85% rename from packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app.css rename to packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app.css index 458fca9a9..93d0473a6 100644 --- a/packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app.css +++ b/packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app.css @@ -50,7 +50,12 @@ .app__drawer { flex: 0 0 auto; height: 100%; - --ig-nav-drawer-size: 280px; + --menu-full-width: 280px; +} + +igc-nav-drawer-item::part(base) { + min-height: 48px; + color: #2d2d2d; } igc-nav-drawer-item[active]::part(base) { @@ -62,7 +67,11 @@ igc-nav-drawer-item[active] igc-icon { color: #0075d2; } -.content { +igc-nav-drawer-item:not([active]) igc-icon { + color: #2d2d2d; +} + +.app__content { flex: 1 1 auto; display: flex; flex-flow: row nowrap; diff --git a/packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app.test.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app.test.tsx similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app.test.tsx rename to packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app.test.tsx diff --git a/packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app.tsx similarity index 93% rename from packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app.tsx rename to packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app.tsx index 58ece34b4..406cc84bb 100644 --- a/packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/app.tsx +++ b/packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/app.tsx @@ -6,10 +6,13 @@ import { IgrNavDrawerItem, registerIcon, } from "igniteui-react"; +import { configureTheme } from "igniteui-webcomponents"; import { routes } from "./app-routes"; -import "igniteui-webcomponents/themes/light/bootstrap.css"; +import "igniteui-webcomponents/themes/light/material.css"; import "./app.css"; +configureTheme('material', 'light'); + const materialIcons = [ ['home', 'action/svg/production/ic_home_24px.svg'], ['menu', 'navigation/svg/production/ic_menu_24px.svg'], @@ -92,7 +95,7 @@ export default function App() { ))} -
+
diff --git a/packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/home/home.tsx b/packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/home/home.tsx similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/home/home.tsx rename to packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/home/home.tsx diff --git a/packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/home/style.module.css b/packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/home/style.module.css similarity index 100% rename from packages/cli/templates/react/igr-ts/projects/top-nav/files/src/app/home/style.module.css rename to packages/cli/templates/react/igr-ts/projects/side-nav/files/src/app/home/style.module.css diff --git a/packages/cli/templates/react/igr-ts/projects/top-nav/index.ts b/packages/cli/templates/react/igr-ts/projects/side-nav/index.ts similarity index 94% rename from packages/cli/templates/react/igr-ts/projects/top-nav/index.ts rename to packages/cli/templates/react/igr-ts/projects/side-nav/index.ts index 28914f0cf..d89e54344 100644 --- a/packages/cli/templates/react/igr-ts/projects/top-nav/index.ts +++ b/packages/cli/templates/react/igr-ts/projects/side-nav/index.ts @@ -4,7 +4,7 @@ import { BaseWithHomeIgrTsProject } from "../_base_with_home"; export class SideNavIgrTsProject extends BaseWithHomeIgrTsProject implements ProjectTemplate { public id: string = "side-nav"; - public name = "Default side navigation"; + public name = "Side navigation default"; public description = "Project structure with side navigation drawer"; public dependencies: string[] = []; public framework: string = "react"; diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/styles.css b/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/styles.css index 323cedad6..51a04016b 100644 --- a/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/styles.css +++ b/packages/cli/templates/webcomponents/igc-ts/projects/_base/files/styles.css @@ -2,6 +2,7 @@ body { background: var(--ig-surface-500, 0 0% 100%); color: var(--ig-surface-500-contrast, black); font-family: var(--ig-font-family); + font-weight: 400; } html, diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/index.html b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/index.html new file mode 100644 index 000000000..890fe862f --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/index.html @@ -0,0 +1,25 @@ + + + + + + + + Ignite UI for Web Components + + + + + + + + + + + + + + + diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/app-routing.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/app-routing.ts new file mode 100644 index 000000000..415d03d85 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/app-routing.ts @@ -0,0 +1,37 @@ +import { type Route } from '@vaadin/router'; +import { UserStore } from './authentication/services/userStore.js'; +import './home/home.js'; +import './not-found/not-found.js'; +import './profile/profile.js'; +import './redirect/redirect-google.js'; +import './redirect/redirect-microsoft.js'; +import './redirect/redirect-facebook.js'; + +export interface AppRoute extends Route { + icon?: string; + requiresAuth?: boolean; +} + +function authGuard(_context: any, commands: any) { + if (!UserStore.getUser()) { + return commands.redirect('/'); + } + return undefined; +} + +export const routes: AppRoute[] = [ + { path: '/', component: 'app-home', name: 'Home', icon: 'home' }, + { + path: '/auth/profile', + component: 'app-profile', + name: 'Profile', + icon: 'account_circle', + requiresAuth: true, + action: authGuard, + }, + { path: '/auth/redirect-google', component: 'app-redirect-google' }, + { path: '/auth/redirect-microsoft', component: 'app-redirect-microsoft' }, + { path: '/auth/redirect-facebook', component: 'app-redirect-facebook' }, + // The fallback route should always be after other alternatives. + { path: '(.*)', component: 'app-not-found' }, +]; diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/app.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/app.ts new file mode 100644 index 000000000..ce02e1fef --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/app.ts @@ -0,0 +1,251 @@ +import { Router } from '@vaadin/router'; +import { css, html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { + defineComponents, + IgcIconComponent, + IgcNavDrawerComponent, + IgcNavDrawerItemComponent, + registerIcon, +} from 'igniteui-webcomponents'; +import { routes, type AppRoute } from './app-routing.js'; +import { UserStore } from './authentication/services/userStore.js'; +import './authentication/login-bar/login-bar.js'; + +defineComponents( + IgcIconComponent, + IgcNavDrawerComponent, + IgcNavDrawerItemComponent, +); + +const materialIcons = [ + ['home', 'action/svg/production/ic_home_24px.svg'], + ['menu', 'navigation/svg/production/ic_menu_24px.svg'], + ['apps', 'navigation/svg/production/ic_apps_24px.svg'], + ['code', 'action/svg/production/ic_code_24px.svg'], + ['build', 'action/svg/production/ic_build_24px.svg'], + ['palette', 'image/svg/production/ic_palette_24px.svg'], + ['account_circle', 'action/svg/production/ic_account_circle_24px.svg'], + ['lock', 'action/svg/production/ic_lock_24px.svg'], + ['assignment_ind', 'action/svg/production/ic_assignment_ind_24px.svg'], +] as const; + +materialIcons.forEach(([name, path]) => + registerIcon(name, `https://unpkg.com/material-design-icons@3.0.1/${path}`, 'material') +); + +@customElement('app-root') +export default class App extends LitElement { + @state() + private drawerOpen = true; + + @state() + private drawerPosition: 'relative' | 'start' = 'relative'; + + @state() + private currentPath = window.location.pathname; + + @state() + private isLoggedIn = Boolean(UserStore.getUser()); + + private mediaQuery?: MediaQueryList; + + static styles = css` + :host { + display: flex; + height: 100%; + } + + .app { + display: flex; + flex-flow: column nowrap; + width: 100%; + height: 100%; + overflow: hidden; + } + + .app__navbar { + display: flex; + align-items: center; + flex: 0 0 auto; + height: 56px; + padding: 0 16px; + background: #239ef0; + box-shadow: 0 2px 4px rgba(0, 0, 0, .24); + box-sizing: border-box; + position: relative; + z-index: 10; + } + + .app__menu-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + color: #000; + border: 0; + background: transparent; + cursor: pointer; + } + + .app__menu-button igc-icon { + font-size: 24px; + } + + .app__title { + margin: 0 0 0 16px; + color: #000; + font-size: 1.25rem; + font-weight: 600; + line-height: 1; + } + + .app__navbar-spacer { + flex: 1 1 auto; + } + + .app__body { + display: flex; + flex: 1 1 auto; + min-height: 0; + } + + .app__drawer { + flex: 0 0 auto; + height: 100%; + --menu-full-width: 280px; + } + + igc-nav-drawer-item::part(base) { + min-height: 48px; + color: #2d2d2d; + } + + igc-nav-drawer-item[active]::part(base) { + background: #e0f2ff; + color: #0075d2; + } + + igc-nav-drawer-item[active] igc-icon { + color: #0075d2; + } + + igc-nav-drawer-item:not([active]) igc-icon { + color: #2d2d2d; + } + + router-outlet { + flex: 1 1 auto; + display: flex; + align-items: stretch; + justify-content: center; + min-width: 0; + overflow: auto; + } + `; + + render() { + const visibleRoutes = (routes as AppRoute[]).filter((route) => { + if (!route.name) return false; + if (route.requiresAuth && !this.isLoggedIn) return false; + return true; + }); + + return html` +
+
+ +

$(name)

+
+ +
+
+ + ${visibleRoutes.map((route) => html` + this.navigate(route.path)} + > + + ${route.name} + + `)} + + +
+
+ `; + } + + connectedCallback() { + super.connectedCallback(); + + this.mediaQuery = window.matchMedia('(min-width: 1025px)'); + this.updateDrawerState(); + this.mediaQuery.addEventListener('change', this.updateDrawerState); + window.addEventListener('popstate', this.updateCurrentPath); + // Listen globally so redirect components (Google/Facebook/Microsoft) in the router + // outlet can also trigger a shell state update after a successful OAuth redirect. + window.addEventListener('auth-change', this.handleAuthChange); + } + + disconnectedCallback() { + this.mediaQuery?.removeEventListener('change', this.updateDrawerState); + window.removeEventListener('popstate', this.updateCurrentPath); + window.removeEventListener('auth-change', this.handleAuthChange); + + super.disconnectedCallback(); + } + + firstUpdated() { + const outlet = this.shadowRoot?.querySelector('router-outlet'); + const router = new Router(outlet); + router.setRoutes(routes); + } + + private toggleDrawer = () => { + this.drawerOpen = !this.drawerOpen; + }; + + private navigate(path: string) { + this.currentPath = path; + Router.go(path); + + if (!this.mediaQuery?.matches) { + this.drawerOpen = false; + } + } + + private updateDrawerState = () => { + const pinned = Boolean(this.mediaQuery?.matches); + + this.drawerOpen = pinned; + this.drawerPosition = pinned ? 'relative' : 'start'; + }; + + private updateCurrentPath = () => { + this.currentPath = window.location.pathname; + }; + + private handleAuthChange = () => { + this.isLoggedIn = Boolean(UserStore.getUser()); + this.currentPath = window.location.pathname; + }; +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.ts new file mode 100644 index 000000000..a682f263a --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.ts @@ -0,0 +1,124 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { Router } from '@vaadin/router'; +import { defineComponents, IgcAvatarComponent, IgcButtonComponent, IgcDropdownComponent, IgcDropdownItemComponent } from 'igniteui-webcomponents'; +import { UserStore } from '../services/userStore.js'; +import { ExternalAuth } from '../services/externalAuth.js'; +import type { User } from '../models/user.js'; +import '../login-dialog/login-dialog.js'; + +defineComponents(IgcAvatarComponent, IgcButtonComponent, IgcDropdownComponent, IgcDropdownItemComponent); + +@customElement('auth-login-bar') +export class LoginBarElement extends LitElement { + @state() private currentUser: User | null = UserStore.getUser(); + + static styles = css` + :host { + display: contents; + } + + .login-btn::part(base) { + color: #0075d2; + background: #fff; + border-color: rgba(0, 117, 210, 0.35); + font-weight: 600; + white-space: nowrap; + } + + .login-btn::part(base):hover { + background: #e8f3fc; + } + + .profile-avatar { + cursor: pointer; + color: #0075d2; + --ig-avatar-background: #fff; + --ig-avatar-color: #0075d2; + --ig-avatar-initials-font-size: 0.875rem; + } + + igc-dropdown-item:hover, + igc-dropdown-item[active]:hover { + background: #e8f3fc; + color: #0075d2; + } + + igc-dropdown-item[active] { + background: #e8f3fc; + color: #0075d2; + } + + igc-dropdown-item[selected], + igc-dropdown-item[selected]:hover, + igc-dropdown-item[selected][active] { + background: #e8f3fc; + color: #0075d2; + } + + .profile-avatar:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; + } + `; + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('auth-change', this.handleAuthChange as EventListener); + } + + disconnectedCallback() { + this.removeEventListener('auth-change', this.handleAuthChange as EventListener); + super.disconnectedCallback(); + } + + private handleAuthChange = () => { + this.currentUser = UserStore.getUser(); + }; + + private handleMenuSelect(e: CustomEvent) { + // igcChange detail is the selected IgcDropdownItemComponent element + const value = (e.detail as any)?.value; + if (value === 'profile') { + Router.go('/auth/profile'); + } else if (value === 'logout') { + ExternalAuth.logout(); + UserStore.clearUser(); + this.currentUser = null; + Router.go('/'); + } + } + + render() { + if (!this.currentUser) { + return html` + + + `; + } + + const initials = UserStore.getInitials(this.currentUser); + + return html` + + ${initials} + Profile + Log Out + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'auth-login-bar': LoginBarElement; + } +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/login-dialog/login-dialog.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/login-dialog/login-dialog.ts new file mode 100644 index 000000000..8c51d3657 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/login-dialog/login-dialog.ts @@ -0,0 +1,253 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { Router } from '@vaadin/router'; +import { defineComponents, IgcButtonComponent, IgcDialogComponent, IgcIconComponent, IgcInputComponent } from 'igniteui-webcomponents'; +import { Authentication } from '../services/authentication.js'; +import { ExternalAuth } from '../services/externalAuth.js'; +import { UserStore } from '../services/userStore.js'; +import type { User } from '../models/user.js'; + +defineComponents(IgcButtonComponent, IgcDialogComponent, IgcIconComponent, IgcInputComponent); + +@customElement('auth-login-dialog') +export class LoginDialogElement extends LitElement { + @state() private showLogin = true; + @state() private error = ''; + @state() private _loginValid = false; + @state() private _registerValid = false; + + static styles = css` + igc-dialog::part(base) { + max-width: 24rem; + width: calc(100vw - 48px); + } + + igc-dialog::part(title) { + font-size: 1.125rem; + font-weight: 600; + color: #2d2d2d; + border-bottom: none; + } + + .form { + display: flex; + flex-flow: column; + gap: 16px; + padding: 8px 0 0; + } + + .form > * { + width: 100%; + } + + igc-input { + --ig-input-group-focused-secondary-color: #239ef0; + --ig-input-group-focused-border-color: #239ef0; + --ig-input-group-filled-text-color: #2d2d2d; + } + + igc-input igc-icon { + color: #0075d2; + --ig-icon-size: 1.50rem; + } + + .error { + margin: 0; + font-size: .875rem; + color: #d32f2f; + } + + .submit-btn { + display: block; + } + + .submit-btn::part(base) { + width: 100%; + min-height: 40px; + font-weight: 600; + text-transform: uppercase; + } + + .submit-btn:not([disabled])::part(base) { + background: #239ef0; + color: #fff; + } + + .submit-btn:not([disabled])::part(base):hover { + background: #1a8fd8; + } + + .submit-btn[disabled]::part(base) { + background: #e0e0e0; + color: #767676; + } + + .link-btn { + align-self: center; + text-align: center; + color: #0075d2; + font-size: .875rem; + cursor: pointer; + text-decoration: underline; + text-transform: none; + } + + .link-btn:hover, + .link-btn:focus-visible { + color: #005da8; + } + + .social-login { + display: grid; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #d7d7d7; + } + + .social-btn { + display: block; + } + + .social-btn::part(base) { + width: 100%; + min-height: 40px; + color: #fff; + font-weight: 600; + text-transform: uppercase; + } + + .google::part(base) { background: rgb(255, 19, 74); } + .facebook::part(base) { background: rgb(19, 119, 213); } + .microsoft::part(base) { background: rgb(27, 158, 245); } + `; + + private dialogRef: IgcDialogComponent | null = null; + + public open() { + this.showLogin = true; + this.error = ''; + this.dialogRef?.show(); + } + + firstUpdated() { + this.dialogRef = this.shadowRoot?.querySelector('igc-dialog') ?? null; + } + + private checkLoginValidity = (e: Event) => { + this._loginValid = (e.currentTarget as HTMLFormElement).checkValidity(); + }; + + private checkRegisterValidity = (e: Event) => { + this._registerValid = (e.currentTarget as HTMLFormElement).checkValidity(); + }; + + private handleLoginSubmit = async (e: Event) => { + e.preventDefault(); + this.error = ''; + const form = e.target as HTMLFormElement; + const data = new FormData(form); + const result = await Authentication.login({ + email: data.get('email') as string, + password: data.get('password') as string, + }); + if (result.user) { + form.reset(); + this._loginValid = false; + UserStore.setUser(result.user as User); + this.dialogRef?.hide(); + this.dispatchEvent(new CustomEvent('auth-change', { bubbles: true, composed: true })); + Router.go('/auth/profile'); + } else { + this.error = result.error ?? 'Login failed'; + } + }; + + private handleRegisterSubmit = async (e: Event) => { + e.preventDefault(); + this.error = ''; + const form = e.target as HTMLFormElement; + const data = new FormData(form); + const result = await Authentication.register({ + given_name: data.get('given_name') as string, + family_name: data.get('family_name') as string, + email: data.get('email') as string, + password: data.get('password') as string, + }); + if (result.user) { + form.reset(); + this._registerValid = false; + UserStore.setUser(result.user as User); + this.dialogRef?.hide(); + this.dispatchEvent(new CustomEvent('auth-change', { bubbles: true, composed: true })); + Router.go('/auth/profile'); + } else { + this.error = result.error ?? 'Registration failed'; + } + }; + + render() { + const title = this.showLogin ? 'Login' : 'Register'; + + const loginForm = html` +
+ + + + + + + ${this.error ? html`

${this.error}

` : ''} + Log In + { this.showLogin = false; this.error = ''; }} role="button" tabindex="0">Create new account + ${ExternalAuth.hasProvider() ? html` + + ` : ''} +
+ `; + + const registerForm = html` +
+ + + + + + + + + + + + + ${this.error ? html`

${this.error}

` : ''} + Sign Up + { this.showLogin = true; this.error = ''; }} role="button" tabindex="0">Have an account? +
+ `; + return html` + + + ${this.showLogin ? loginForm : registerForm} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'auth-login-dialog': LoginDialogElement; + } +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/external-login.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/external-login.ts new file mode 100644 index 000000000..17a7d9bf5 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/external-login.ts @@ -0,0 +1,10 @@ +/** User profile returned by a social (external) auth provider. */ +export interface ExternalLogin { + id: string; + name: string; + email?: string; // not always present use id as fallback key + given_name?: string; + family_name?: string; + picture?: string; + externalToken: string; +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/login.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/login.ts new file mode 100644 index 000000000..1269e5b3c --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/login.ts @@ -0,0 +1,4 @@ +export interface Login { + email: string; + password: string; +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/register-info.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/register-info.ts new file mode 100644 index 000000000..1142fd1aa --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/register-info.ts @@ -0,0 +1,6 @@ +export interface RegisterInfo { + given_name: string; + family_name: string; + email: string; + password: string; +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/user.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/user.ts new file mode 100644 index 000000000..a28a04abf --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/models/user.ts @@ -0,0 +1,19 @@ +/** Data transfer model expected from backend API JWT-s */ +export interface UserJWT { + exp: number; + name: string; + given_name: string; + family_name: string; + email: string; + picture?: string; +} + +/** Client user model */ +export interface User extends UserJWT { + token: string; +} + +export interface LoginResult { + user?: User; + error?: string; +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts new file mode 100644 index 000000000..724bcdcfe --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts @@ -0,0 +1,37 @@ +import type { Login } from '../models/login.js'; +import type { RegisterInfo } from '../models/register-info.js'; +import type { ExternalLogin } from '../models/external-login.js'; +import type { LoginResult } from '../models/user.js'; +import { parseUser } from './jwtUtil.js'; +import { fakeLogin, fakeRegister, fakeExtLogin } from './fakeBackend.js'; + +/** Authentication service — swap fakeLogin/fakeRegister for real API calls when ready. */ +export const Authentication = { + async login(data: Login): Promise { + try { + const token = await fakeLogin(data); + return { user: parseUser(token) }; + } catch (e: any) { + return { error: e.message }; + } + }, + + async register(data: RegisterInfo): Promise { + try { + const token = await fakeRegister(data); + return { user: parseUser(token) }; + } catch (e: any) { + return { error: e.message }; + } + }, + + /** Send user info from a social provider to the external login endpoint. */ + async loginWith(data: ExternalLogin): Promise { + try { + const token = fakeExtLogin(data); + return { user: parseUser(token) }; + } catch (e: any) { + return { error: e.message }; + } + } +}; diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-config.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-config.ts new file mode 100644 index 000000000..b8f19f14d --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-config.ts @@ -0,0 +1,44 @@ +// Social login configuration. +// To enable a provider, set its entry in oauthConfig below with your real credentials +// from the provider's developer console. +// +// Redirect URIs to register in each provider's app settings: +// {your-origin}/auth/redirect-google +// {your-origin}/auth/redirect-facebook +// {your-origin}/auth/redirect-microsoft +// +// Developer consoles: +// Google: https://console.cloud.google.com/apis/credentials +// Microsoft: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps +// Facebook: https://developers.facebook.com/apps + +export type OAuthProvider = 'google' | 'facebook' | 'microsoft'; + +export interface OAuthConfig { + google?: { clientId: string }; + + // tenantId defaults to 'common' (multi-tenant). Set it for single-tenant apps. + // IMPORTANT: The redirect URI must be registered as a SPA redirect URI in Azure + // (not "Web"), otherwise the token exchange will fail with a CORS error. + // See: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow + microsoft?: { clientId: string; tenantId?: string }; + + // Facebook login uses the JS SDK (popup flow). The SDK script must be loaded in + // index.html (see below). In the Facebook app dashboard you must also: + // - Enable "Login with the JavaScript SDK" + // - Add your domain to "Allowed Domains for the JavaScript SDK" + // - Add the redirect URI to "Valid OAuth Redirect URIs" + // - Serve the app over HTTPS + // See: https://developers.facebook.com/docs/facebook-login/web + facebook?: { clientId: string }; +} + +// Active OAuth configuration — fill in the providers you want to enable, for example: +// +// export const oauthConfig: OAuthConfig = { +// google: { clientId: 'YOUR_GOOGLE_CLIENT_ID' }, +// microsoft: { clientId: 'YOUR_AZURE_APP_CLIENT_ID', tenantId: 'common' }, +// // Note: Facebook requires HTTPS even for local dev - use ngrok or a local SSL proxy. +// facebook: { clientId: 'YOUR_FACEBOOK_APP_ID' }, +// }; +export const oauthConfig: OAuthConfig = {}; diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/externalAuth.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/externalAuth.ts new file mode 100644 index 000000000..428b599bf --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/externalAuth.ts @@ -0,0 +1,272 @@ +import type { ExternalLogin } from '../models/external-login.js'; +import type { OAuthProvider } from './external-auth-config.js'; +import { oauthConfig } from './external-auth-config.js'; +import { generateCodeVerifier, generateCodeChallenge, buildAuthUrl } from './pkce.js'; + +// sessionStorage keys +const VERIFIER_KEY = '_pkce_verifier'; +const STATE_KEY = '_oauth_state'; +const FB_USER_KEY = '_fb_user'; +const ACTIVE_PROVIDER_KEY = '_ext_active_provider'; + +// Declared by the Facebook JS SDK (loaded via script tag in index.html) +declare const FB: any; + +// Set to true once FB.init() has been called in this session. +// Prevents FB.logout() from being called before initialization. +let fbInitialized = false; + +/** + * Decode a JWT payload segment. Handles Base64URL encoding (no padding, - and _ chars) + * which `atob()` does not accept natively - missing padding causes `InvalidCharacterError`. + */ +function decodeJwtPayload(token: string): any { + const base64url = token.split('.')[1]; + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '='); + return JSON.parse(atob(padded)); +} + +/** + * Waits until the Facebook JS SDK has loaded and is available on window. + * The SDK is loaded with `async defer` so it may not be ready when login() is called. + */ +function waitForFB(): Promise { + return new Promise(resolve => { + if (typeof (window as any).FB !== 'undefined') { resolve(); return; } + const id = setInterval(() => { + if (typeof (window as any).FB !== 'undefined') { clearInterval(id); resolve(); } + }, 50); + }); +} + +/** + * External (social) authentication service. + * Supports Google and Microsoft via OIDC/PKCE, and Facebook via the JS SDK. + * + * Usage: call login(provider) to start the flow; call handleRedirect(provider) + * on the matching redirect page to complete it and retrieve the user profile. + */ +export const ExternalAuth = { + /** Returns true if any provider (or the specific provider) is configured. */ + hasProvider(provider?: OAuthProvider): boolean { + if (provider) { + return provider in oauthConfig && (oauthConfig as any)[provider] != null; + } + return Object.values(oauthConfig).some(v => v != null); + }, + + /** Initiate login for the given provider. Redirects the page to the provider's auth endpoint. */ + async login(provider: OAuthProvider): Promise { + localStorage.setItem(ACTIVE_PROVIDER_KEY, provider); + if (provider === 'google') { + const cfg = oauthConfig.google!; + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + sessionStorage.setItem(VERIFIER_KEY, verifier); + const state = crypto.randomUUID(); + sessionStorage.setItem(STATE_KEY, state); + const redirectUri = `${window.location.origin}/auth/redirect-google`; + window.location.href = buildAuthUrl('https://accounts.google.com/o/oauth2/v2/auth', { + response_type: 'code', + client_id: cfg.clientId, + redirect_uri: redirectUri, + scope: 'openid profile email', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + }); + } else if (provider === 'microsoft') { + const cfg = oauthConfig.microsoft!; + const tenantId = cfg.tenantId ?? 'common'; + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + sessionStorage.setItem(VERIFIER_KEY, verifier); + const state = crypto.randomUUID(); + sessionStorage.setItem(STATE_KEY, state); + const redirectUri = `${window.location.origin}/auth/redirect-microsoft`; + window.location.href = buildAuthUrl( + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`, + { + response_type: 'code', + client_id: cfg.clientId, + redirect_uri: redirectUri, + scope: 'openid profile email', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + } + ); + } else if (provider === 'facebook') { + const cfg = oauthConfig.facebook!; + // Wait for the SDK to load (it is included with `async defer` in index.html + // and may not be available yet when the user clicks the login button). + await waitForFB(); + FB.init({ appId: cfg.clientId, xfbml: false, version: 'v3.1' }); + fbInitialized = true; + FB.login( + (response: any) => { + if (response.authResponse) { + FB.api( + '/me?fields=id,email,name,first_name,last_name,picture', + (res: any) => { + const user: ExternalLogin = { + id: res.id, + name: res.name, + given_name: res.first_name, + family_name: res.last_name, + email: res.email, + // Facebook returns picture as an object: { data: { url, width, height } } + picture: res.picture?.data?.url, + externalToken: FB.getAuthResponse()?.accessToken ?? '', + }; + sessionStorage.setItem(FB_USER_KEY, JSON.stringify(user)); + window.location.href = '/auth/redirect-facebook'; + } + ); + } + }, + { scope: 'public_profile,email' } + ); + } + }, + + /** + * Complete the OAuth redirect flow and return the external user profile. + * Call this from the /auth/redirect-{provider} page. + * + * For Google/Microsoft: exchanges the authorization code (PKCE) for tokens. + * For Facebook: reads the profile stored during the FB.login() popup flow. + */ + async handleRedirect(provider: OAuthProvider): Promise { + if (provider === 'facebook') { + const stored = sessionStorage.getItem(FB_USER_KEY); + if (!stored) throw new Error('No Facebook user data found. Please try again.'); + sessionStorage.removeItem(FB_USER_KEY); + return JSON.parse(stored) as ExternalLogin; + } + + const params = new URLSearchParams(window.location.search); + const code = params.get('code'); + if (!code) throw new Error('Missing authorization code in redirect URL.'); + + // Validate the state parameter to prevent CSRF attacks. + const returnedState = params.get('state'); + const savedState = sessionStorage.getItem(STATE_KEY); + sessionStorage.removeItem(STATE_KEY); + if (!returnedState || returnedState !== savedState) { + throw new Error('OAuth state mismatch. The request may have been tampered with.'); + } + + const verifier = sessionStorage.getItem(VERIFIER_KEY); + if (!verifier) throw new Error('Missing PKCE code verifier. Please try again.'); + sessionStorage.removeItem(VERIFIER_KEY); + + if (provider === 'google') { + const cfg = oauthConfig.google!; + const redirectUri = `${window.location.origin}/auth/redirect-google`; + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: cfg.clientId, + redirect_uri: redirectUri, + code, + code_verifier: verifier, + }), + }); + if (!res.ok) throw new Error('Google token exchange failed.'); + const data = await res.json(); + // Decode the id_token to extract user claims - no extra userinfo request needed + const payload = decodeJwtPayload(data.id_token); + return { + id: payload.sub, + name: payload.name, + given_name: payload.given_name, + family_name: payload.family_name, + email: payload.email, + picture: payload.picture, + externalToken: data.access_token, + }; + } + + if (provider === 'microsoft') { + const cfg = oauthConfig.microsoft!; + const tenantId = cfg.tenantId ?? 'common'; + const redirectUri = `${window.location.origin}/auth/redirect-microsoft`; + const res = await fetch( + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: cfg.clientId, + redirect_uri: redirectUri, + code, + code_verifier: verifier, + }), + } + ); + if (!res.ok) throw new Error('Microsoft token exchange failed.'); + const data = await res.json(); + const payload = decodeJwtPayload(data.id_token); + return { + id: payload.oid ?? payload.sub, + name: payload.name, + email: payload.email ?? payload.preferred_username, + externalToken: data.access_token, + }; + } + + throw new Error(`Unknown provider: ${provider}`); + }, + + /** + * Sign out from the active external provider (if any) and clear its stored state. + * Call this alongside clearing local user state on logout. + */ + logout(): void { + const provider = localStorage.getItem(ACTIVE_PROVIDER_KEY) as OAuthProvider | null; + localStorage.removeItem(ACTIVE_PROVIDER_KEY); + sessionStorage.removeItem(VERIFIER_KEY); + sessionStorage.removeItem(FB_USER_KEY); + + if (!provider) return; + + if (provider === 'google') { + // Redirect to Google's end-session endpoint to clear the Google session. + // The user is returned to the app root after sign-out. + const cfg = oauthConfig.google; + if (cfg) { + window.location.href = `https://accounts.google.com/logout`; + return; + } + } + + if (provider === 'microsoft') { + const cfg = oauthConfig.microsoft; + if (cfg) { + const tenantId = cfg.tenantId ?? 'common'; + const postLogoutRedirectUri = encodeURIComponent(window.location.origin); + window.location.href = + `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/logout` + + `?post_logout_redirect_uri=${postLogoutRedirectUri}`; + return; + } + } + + if (provider === 'facebook') { + // Only call FB.logout() when the SDK was initialised in this session. + // Calling it on a fresh page load (before FB.init) throws an error. + try { + if (fbInitialized && typeof FB !== 'undefined') { + FB.logout(); + } + } catch { + // SDK not loaded - nothing to do + } + } + }, +}; diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/fakeBackend.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/fakeBackend.ts new file mode 100644 index 000000000..da817feae --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/fakeBackend.ts @@ -0,0 +1,88 @@ +// ⚠ DEVELOPMENT ONLY — simulates POST /login, /register, /extlogin using localStorage. +// Before going to production: remove this interceptor and replace with calls to your real API. +import type { Login } from '../models/login.js'; +import type { RegisterInfo } from '../models/register-info.js'; +import type { ExternalLogin } from '../models/external-login.js'; + +const USERS_KEY = '_fake_users'; + +interface StoredUser { + given_name: string; + family_name: string; + email: string; + passwordHash: string; + externalId?: string; +} + +function getUsers(): StoredUser[] { + try { + return JSON.parse(localStorage.getItem(USERS_KEY) ?? '[]'); + } catch { + return []; + } +} + +function saveUsers(users: StoredUser[]): void { + localStorage.setItem(USERS_KEY, JSON.stringify(users)); +} + +async function hashPassword(password: string): Promise { + const data = new TextEncoder().encode(password); + const digest = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); +} + +function makeJwt(payload: object): string { + const header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' })); + const body = btoa(JSON.stringify({ exp: Date.now() / 1000 + 3600, ...payload })); + return `${header}.${body}.`; +} + +export async function fakeLogin(data: Login): Promise { + const users = getUsers(); + const passwordHash = await hashPassword(data.password); + const user = users.find(u => u.email === data.email && u.passwordHash === passwordHash); + if (!user) { + throw new Error('Invalid email or password.'); + } + return makeJwt({ name: `${user.given_name} ${user.family_name}`, given_name: user.given_name, family_name: user.family_name, email: user.email }); +} + +export async function fakeRegister(data: RegisterInfo): Promise { + const users = getUsers(); + if (users.find(u => u.email === data.email)) { + throw new Error('An account with this email already exists.'); + } + const newUser: StoredUser = { + given_name: data.given_name, + family_name: data.family_name, + email: data.email, + passwordHash: await hashPassword(data.password) + }; + saveUsers([...users, newUser]); + return makeJwt({ name: `${data.given_name} ${data.family_name}`, given_name: data.given_name, family_name: data.family_name, email: data.email }); +} +/** Upsert a user from a social (external) auth provider and return a JWT. */ +export function fakeExtLogin(data: ExternalLogin): string { + const users = getUsers(); + const existing = users.find(u => u.email === data.email && data.email != null) + ?? users.find(u => u.externalId === data.id); + const given_name = data.given_name ?? data.name?.split(' ')[0] ?? ''; + const family_name = data.family_name ?? data.name?.split(' ').slice(1).join(' ') ?? ''; + // Resolve email: prefer what the provider returned, fall back to what we stored previously. + const email = data.email ?? existing?.email; + if (existing) { + // Update profile fields from provider (name/picture may change). + // Also store externalId if this user was originally created by email (first social login). + existing.given_name = given_name; + existing.family_name = family_name; + if (!existing.externalId) existing.externalId = data.id; + saveUsers(users); + } else { + if (!email) { + throw new Error('Cannot create an account without an email address.'); + } + saveUsers([...users, { given_name, family_name, email, passwordHash: '', externalId: data.id }]); + } + return makeJwt({ name: data.name, given_name, family_name, email, picture: data.picture }); +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/jwtUtil.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/jwtUtil.ts new file mode 100644 index 000000000..e8cc2c31b --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/jwtUtil.ts @@ -0,0 +1,10 @@ +import type { UserJWT } from '../models/user.js'; + +/** Parse the payload of a JWT string into a UserJWT object. */ +export function parseUser(token: string): UserJWT & { token: string } { + const base64url = token.split('.')[1]; + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '='); + const decoded = JSON.parse(atob(padded)); + return { ...decoded, token }; +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/pkce.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/pkce.ts new file mode 100644 index 000000000..1a7ba6d5c --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/pkce.ts @@ -0,0 +1,29 @@ +// PKCE (Proof Key for Code Exchange) utilities for OAuth 2.0 Authorization Code Flow. +// https://tools.ietf.org/html/rfc7636 + +function base64UrlEncode(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +/** Generate a cryptographically random PKCE code verifier (43–128 chars, URL-safe). */ +export function generateCodeVerifier(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return base64UrlEncode(bytes); +} + +/** Compute the S256 code challenge from a code verifier. */ +export async function generateCodeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** Build a URL with query parameters from a plain object. */ +export function buildAuthUrl(endpoint: string, params: Record): string { + const url = new URL(endpoint); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + return url.toString(); +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/userStore.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/userStore.ts new file mode 100644 index 000000000..e2f4639e9 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/userStore.ts @@ -0,0 +1,39 @@ +import type { User } from '../models/user.js'; + +const USER_KEY = 'currentUser'; + +/** + * Simple localStorage-backed user store. + * + * NOTE: Storing the full user object in localStorage can be susceptible to XSS attacks. + * Consider additional security measures before going to production. + */ +export const UserStore = { + getUser(): User | null { + try { + const raw = localStorage.getItem(USER_KEY); + if (!raw) return null; + const parsed: User = JSON.parse(raw); + // Discard expired tokens so a stale session is never silently restored. + if (parsed.exp && Date.now() / 1000 > parsed.exp) { + localStorage.removeItem(USER_KEY); + return null; + } + return parsed; + } catch { + return null; + } + }, + + setUser(user: User): void { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + }, + + clearUser(): void { + localStorage.removeItem(USER_KEY); + }, + + getInitials(user: User): string { + return (user.given_name.charAt(0) + (user.family_name?.charAt(0) ?? '')).toUpperCase(); + } +}; diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/profile/profile.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/profile/profile.ts new file mode 100644 index 000000000..47bf2c709 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/profile/profile.ts @@ -0,0 +1,142 @@ +import { LitElement, html, css } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { defineComponents, IgcAvatarComponent } from 'igniteui-webcomponents'; +import { UserStore } from '../authentication/services/userStore.js'; + +defineComponents(IgcAvatarComponent); + +@customElement('app-profile') +export default class ProfilePage extends LitElement { + static styles = css` + :host { + display: flex; + justify-content: center; + padding: 48px 16px; + width: 100%; + box-sizing: border-box; + } + + .card { + align-self: flex-start; + width: 100%; + max-width: 640px; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, .08); + padding: 32px; + box-sizing: border-box; + } + + .header { + display: flex; + align-items: center; + gap: 20px; + padding-bottom: 24px; + border-bottom: 1px solid #d7eaf8; + } + + .avatar { + flex: 0 0 auto; + --ig-avatar-background: #e0f2ff; + --ig-avatar-color: #0075d2; + --ig-avatar-size: 4rem; + } + + .intro { + min-width: 0; + } + + .status { + margin: 0 0 4px; + color: #000; + font-size: .875rem; + font-weight: 700; + text-transform: uppercase; + } + + .name { + margin: 0; + overflow-wrap: anywhere; + color: #09f; + font-size: 2rem; + font-weight: 600; + line-height: 1.2; + } + + .description { + margin: 8px 0 0; + color: #000; + font-size: 1rem; + line-height: 1.5; + } + + .details { + margin: 28px 0 0; + padding: 0; + } + + .row { + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + gap: 24px; + padding: 14px 0; + border-bottom: 1px solid #eef3f7; + } + + dt { + color: rgba(0, 0, 0, .62); + font-size: .875rem; + font-weight: 600; + margin: 0; + } + + dd { + margin: 0; + font-size: 1rem; + color: #2d2d2d; + } + `; + + render() { + const user = UserStore.getUser(); + const initials = user ? UserStore.getInitials(user) : 'U'; + + return html` +
+
+ +
+

Signed in

+

${user?.name || 'Your profile'}

+

Your account details are available on this protected route.

+
+
+
+
+
First name
+
${user?.given_name || 'Not provided'}
+
+
+
Last name
+
${user?.family_name || 'Not provided'}
+
+
+
Email
+
${user?.email || 'No email available'}
+
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'app-profile': ProfilePage; + } +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/redirect/redirect-facebook.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/redirect/redirect-facebook.ts new file mode 100644 index 000000000..470c27385 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/redirect/redirect-facebook.ts @@ -0,0 +1,57 @@ +import { LitElement, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { Router } from '@vaadin/router'; +import { ExternalAuth } from '../authentication/services/externalAuth.js'; +import { Authentication } from '../authentication/services/authentication.js'; +import { UserStore } from '../authentication/services/userStore.js'; +import type { User } from '../authentication/models/user.js'; + +/** + * Handles the Facebook login redirect. + * Facebook uses a popup (JS SDK) instead of PKCE, so this page reads the profile + * that was stored in sessionStorage during the FB.login() callback. + */ +@customElement('app-redirect-facebook') +export class RedirectFacebookElement extends LitElement { + @state() private error = ''; + + connectedCallback() { + super.connectedCallback(); + this._handleRedirect(); + } + + private async _handleRedirect() { + try { + const externalUser = await ExternalAuth.handleRedirect('facebook'); + const result = await Authentication.loginWith(externalUser); + if (result.user) { + UserStore.setUser(result.user as User); + this.dispatchEvent(new CustomEvent('auth-change', { bubbles: true, composed: true })); + Router.go('/auth/profile'); + } else { + this.error = result.error ?? 'Facebook sign-in failed.'; + } + } catch (e: any) { + console.error('Facebook sign-in failed:', e); + this.error = 'Facebook sign-in failed. Please try again.'; + } + } + + render() { + if (this.error) { + return html` +
+

${this.error}

+ +
+ `; + } + return html`

Signing in with Facebook…

`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'app-redirect-facebook': RedirectFacebookElement; + } +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/redirect/redirect-google.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/redirect/redirect-google.ts new file mode 100644 index 000000000..f88b4c9a7 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/redirect/redirect-google.ts @@ -0,0 +1,53 @@ +import { LitElement, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { Router } from '@vaadin/router'; +import { ExternalAuth } from '../authentication/services/externalAuth.js'; +import { Authentication } from '../authentication/services/authentication.js'; +import { UserStore } from '../authentication/services/userStore.js'; +import type { User } from '../authentication/models/user.js'; + +/** Handles the OAuth redirect from Google. Exchanges the authorization code for a user profile. */ +@customElement('app-redirect-google') +export class RedirectGoogleElement extends LitElement { + @state() private error = ''; + + connectedCallback() { + super.connectedCallback(); + this._handleRedirect(); + } + + private async _handleRedirect() { + try { + const externalUser = await ExternalAuth.handleRedirect('google'); + const result = await Authentication.loginWith(externalUser); + if (result.user) { + UserStore.setUser(result.user as User); + this.dispatchEvent(new CustomEvent('auth-change', { bubbles: true, composed: true })); + Router.go('/auth/profile'); + } else { + this.error = result.error ?? 'Google sign-in failed.'; + } + } catch (e: any) { + console.error('Google sign-in failed:', e); + this.error = 'Google sign-in failed. Please try again.'; + } + } + + render() { + if (this.error) { + return html` +
+

${this.error}

+ +
+ `; + } + return html`

Signing in with Google…

`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'app-redirect-google': RedirectGoogleElement; + } +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/redirect/redirect-microsoft.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/redirect/redirect-microsoft.ts new file mode 100644 index 000000000..896b52b47 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/redirect/redirect-microsoft.ts @@ -0,0 +1,53 @@ +import { LitElement, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { Router } from '@vaadin/router'; +import { ExternalAuth } from '../authentication/services/externalAuth.js'; +import { Authentication } from '../authentication/services/authentication.js'; +import { UserStore } from '../authentication/services/userStore.js'; +import type { User } from '../authentication/models/user.js'; + +/** Handles the OAuth redirect from Microsoft. Exchanges the authorization code for a user profile. */ +@customElement('app-redirect-microsoft') +export class RedirectMicrosoftElement extends LitElement { + @state() private error = ''; + + connectedCallback() { + super.connectedCallback(); + this._handleRedirect(); + } + + private async _handleRedirect() { + try { + const externalUser = await ExternalAuth.handleRedirect('microsoft'); + const result = await Authentication.loginWith(externalUser); + if (result.user) { + UserStore.setUser(result.user as User); + this.dispatchEvent(new CustomEvent('auth-change', { bubbles: true, composed: true })); + Router.go('/auth/profile'); + } else { + this.error = result.error ?? 'Microsoft sign-in failed.'; + } + } catch (e: any) { + console.error('Microsoft sign-in failed:', e); + this.error = 'Microsoft sign-in failed. Please try again.'; + } + } + + render() { + if (this.error) { + return html` +
+

${this.error}

+ +
+ `; + } + return html`

Signing in with Microsoft…

`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'app-redirect-microsoft': RedirectMicrosoftElement; + } +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/index.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/index.ts new file mode 100644 index 000000000..2a779c4d8 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/index.ts @@ -0,0 +1,19 @@ +import { ProjectTemplate } from "@igniteui/cli-core"; +import * as path from "path"; +import { SideNavProject } from "../side-nav"; + +export class SideNavAuthIgcProject extends SideNavProject implements ProjectTemplate { + public id: string = "side-nav-auth"; + public name = "Side navigation + login"; + public description = "Side navigation extended with user authentication module"; + public dependencies: string[] = []; + public framework: string = "webcomponents"; + public projectType: string = "igc-ts"; + public hasExtraConfiguration: boolean = false; + public isHidden: boolean = true; + + public get templatePaths(): string[] { + return [...super.templatePaths, path.join(__dirname, "files")]; + } +} +export default new SideNavAuthIgcProject(); diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini-auth/files/src/app/app.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini-auth/files/src/app/app.ts new file mode 100644 index 000000000..eacbec635 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini-auth/files/src/app/app.ts @@ -0,0 +1,258 @@ +import { html, css, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { + defineComponents, + IgcIconComponent, + IgcNavDrawerComponent, + IgcNavDrawerItemComponent, + registerIcon, +} from 'igniteui-webcomponents'; +import { Router } from '@vaadin/router'; +import { routes, type AppRoute } from './app-routing.js'; +import { UserStore } from './authentication/services/userStore.js'; +import './authentication/login-bar/login-bar.js'; + +defineComponents( + IgcIconComponent, + IgcNavDrawerComponent, + IgcNavDrawerItemComponent, +); + +const materialIcons = [ + ['home', 'action/svg/production/ic_home_24px.svg'], + ['menu', 'navigation/svg/production/ic_menu_24px.svg'], + ['apps', 'navigation/svg/production/ic_apps_24px.svg'], + ['code', 'action/svg/production/ic_code_24px.svg'], + ['build', 'action/svg/production/ic_build_24px.svg'], + ['palette', 'image/svg/production/ic_palette_24px.svg'], + ['account_circle', 'action/svg/production/ic_account_circle_24px.svg'], + ['lock', 'action/svg/production/ic_lock_24px.svg'], + ['assignment_ind', 'action/svg/production/ic_assignment_ind_24px.svg'], +] as const; + +materialIcons.forEach(([name, path]) => + registerIcon(name, `https://unpkg.com/material-design-icons@3.0.1/${path}`, 'material') +); + +@customElement('app-root') +export default class App extends LitElement { + @state() + private drawerOpen = true; + + @state() + private currentPath = window.location.pathname; + + @state() + private isLoggedIn = Boolean(UserStore.getUser()); + + private mediaQuery?: MediaQueryList; + + static styles = css` + :host { + display: flex; + height: 100%; + } + + .app { + display: flex; + flex-flow: column nowrap; + width: 100%; + height: 100%; + overflow: hidden; + } + + .app__navbar { + display: flex; + align-items: center; + flex: 0 0 auto; + height: 56px; + padding: 0 16px; + background: #239ef0; + box-shadow: 0 2px 4px rgba(0, 0, 0, .24); + box-sizing: border-box; + position: relative; + z-index: 10; + } + + .app__menu-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + color: #000; + border: 0; + background: transparent; + cursor: pointer; + } + + .app__menu-button igc-icon { + font-size: 24px; + } + + .app__title { + margin: 0 0 0 16px; + color: #000; + font-size: 1.25rem; + font-weight: 600; + line-height: 1; + } + + .app__navbar-spacer { + flex: 1 1 auto; + } + + .app__body { + display: flex; + flex: 1 1 auto; + min-height: 0; + } + + .app__drawer { + flex: 0 0 auto; + height: 100%; + --menu-full-width: 280px; + } + + .app--mini .app__drawer { + --menu-full-width: 68px; + } + + igc-nav-drawer.app__drawer::part(base) { + transition: width 0.3s ease-out; + overflow: hidden; + } + + .app--mini igc-nav-drawer-item::part(base) { + justify-content: center; + width: 40px; + min-height: 40px; + padding: 0; + margin: 4px auto; + border-radius: 8px; + } + + .app--mini igc-nav-drawer-item::part(content) { + display: none; + } + + igc-nav-drawer-item[active]::part(base) { + background: #e0f2ff; + color: #0075d2; + } + + igc-nav-drawer-item[active] igc-icon { + color: #0075d2; + } + + router-outlet { + flex: 1 1 auto; + display: flex; + align-items: stretch; + justify-content: center; + min-width: 0; + overflow: auto; + } + + @media (max-width: 1024px) { + .app__menu-button { + display: none; + } + } + `; + + render() { + const visibleRoutes = (routes as AppRoute[]).filter((route) => { + if (!route.name) return false; + if ((route as any).requiresAuth && !this.isLoggedIn) return false; + return true; + }); + + return html` +
+
+ +

$(name)

+
+ +
+
+ + ${visibleRoutes.map((route) => html` + this.navigate(route.path)} + > + + ${route.name} + + `)} + + +
+
+ `; + } + + connectedCallback() { + super.connectedCallback(); + this.mediaQuery = window.matchMedia('(min-width: 1025px)'); + this.updateDrawerState(); + this.mediaQuery.addEventListener('change', this.updateDrawerState); + window.addEventListener('popstate', this.updateCurrentPath); + // Listen globally so redirect components (Google/Facebook/Microsoft) in the router + // outlet can also trigger a shell state update after a successful OAuth redirect. + window.addEventListener('auth-change', this.handleAuthChange); + } + + disconnectedCallback() { + this.mediaQuery?.removeEventListener('change', this.updateDrawerState); + window.removeEventListener('popstate', this.updateCurrentPath); + window.removeEventListener('auth-change', this.handleAuthChange); + super.disconnectedCallback(); + } + + firstUpdated() { + const outlet = this.shadowRoot?.querySelector('router-outlet'); + const router = new Router(outlet); + router.setRoutes(routes); + } + + private toggleDrawer = () => { + this.drawerOpen = !this.drawerOpen; + }; + + private navigate(path: string) { + this.currentPath = path; + Router.go(path); + } + + private updateDrawerState = () => { + this.drawerOpen = Boolean(this.mediaQuery?.matches); + }; + + private updateCurrentPath = () => { + this.currentPath = window.location.pathname; + }; + + private handleAuthChange = () => { + this.isLoggedIn = Boolean(UserStore.getUser()); + this.currentPath = window.location.pathname; + }; +} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini-auth/index.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini-auth/index.ts new file mode 100644 index 000000000..32e4d2d29 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini-auth/index.ts @@ -0,0 +1,23 @@ +import { ProjectTemplate } from "@igniteui/cli-core"; +import * as path from "path"; +import { SideNavMiniProject } from "../side-nav-mini"; + +export class SideNavMiniAuthIgcProject extends SideNavMiniProject implements ProjectTemplate { + public id: string = "side-nav-mini-auth"; + public name = "Side navigation (mini) + login"; + public description = "Collapsible mini side navigation extended with user authentication module"; + public dependencies: string[] = []; + public framework: string = "webcomponents"; + public projectType: string = "igc-ts"; + public hasExtraConfiguration: boolean = false; + public isHidden: boolean = true; + + public get templatePaths(): string[] { + return [ + ...super.templatePaths, + path.join(__dirname, "../side-nav-auth/files"), + path.join(__dirname, "files") + ]; + } +} +export default new SideNavMiniAuthIgcProject(); diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini/files/src/app/app.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini/files/src/app/app.ts index a97318025..172eaa16f 100644 --- a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini/files/src/app/app.ts +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini/files/src/app/app.ts @@ -123,6 +123,11 @@ export default class App extends LitElement { display: none; } + igc-nav-drawer-item::part(base) { + min-height: 48px; + color: #2d2d2d; + } + igc-nav-drawer-item[active]::part(base) { background: #e0f2ff; color: #0075d2; @@ -132,6 +137,10 @@ export default class App extends LitElement { color: #0075d2; } + igc-nav-drawer-item:not([active]) igc-icon { + color: #2d2d2d; + } + router-outlet { flex: 1 1 auto; display: flex; @@ -179,7 +188,6 @@ export default class App extends LitElement { slot="icon" name=${route.icon || 'apps'} collection="material" - style=${this.currentPath === route.path ? 'color: #0075D2;' : ''} > ${route.name} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/files/index.html b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/files/index.html new file mode 100644 index 000000000..964ade6f8 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/files/index.html @@ -0,0 +1,21 @@ + + + + + + + + Ignite UI for Web Components + + + + + + + + + + + + + diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/files/src/app/app.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/files/src/app/app.ts index 52aeb7529..45e08088c 100644 --- a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/files/src/app/app.ts +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/files/src/app/app.ts @@ -107,7 +107,12 @@ export default class App extends LitElement { .app__drawer { flex: 0 0 auto; height: 100%; - --ig-nav-drawer-size: 280px; + --menu-full-width: 280px; + } + + igc-nav-drawer-item::part(base) { + min-height: 48px; + color: #2d2d2d; } igc-nav-drawer-item[active]::part(base) { @@ -119,6 +124,10 @@ export default class App extends LitElement { color: #0075d2; } + igc-nav-drawer-item:not([active]) igc-icon { + color: #2d2d2d; + } + router-outlet { flex: 1 1 auto; display: flex; @@ -160,7 +169,6 @@ export default class App extends LitElement { slot="icon" name=${route.icon || 'home'} collection="material" - style=${this.currentPath === route.path ? 'color: #0075D2;' : ''} > ${route.name} diff --git a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/index.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/index.ts index 107b4139a..fd14ff07e 100644 --- a/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/index.ts +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/index.ts @@ -4,7 +4,7 @@ import { BaseWithHomeIgcProject } from "../_base_with_home"; export class SideNavProject extends BaseWithHomeIgcProject implements ProjectTemplate { public id: string = "side-nav"; - public name = "Default side navigation"; + public name = "Side navigation default"; public description = "Project structure with side navigation drawer"; public framework: string = "webcomponents"; public projectType: string = "igc-ts"; diff --git a/packages/core/prompt/BasePromptSession.ts b/packages/core/prompt/BasePromptSession.ts index e1b949cb5..016b5971e 100644 --- a/packages/core/prompt/BasePromptSession.ts +++ b/packages/core/prompt/BasePromptSession.ts @@ -235,7 +235,30 @@ export abstract class BasePromptSession { message: "Choose project template:", choices: Util.formatChoices(visibleProjects) }); - return visibleProjects.find(x => x.name === componentNameRes); + const selected = visibleProjects.find(x => x.name === componentNameRes); + if (!selected) { + throw new Error(`Project template '${componentNameRes}' not found.`); + } + + // If the selected template has an auth variant (id: "-auth"), offer it + const authVariant = projectLibrary.getProject(`${selected.id}-auth`); + if (authVariant) { + const wantsAuth = await InquirerWrapper.confirm({ + message: "Would you like to add authentication (login, register, social login)?", + default: false + }); + GoogleAnalytics.post({ + t: "event", + ec: "$ig wizard", + el: "Include authentication?", + ea: `projTemplate: ${selected.id}; auth: ${wantsAuth}` + }); + if (wantsAuth) { + return authVariant; + } + } + + return selected; } /** diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.config.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.config.ts index 8547ae596..d7372323a 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.config.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.config.ts @@ -26,9 +26,27 @@ export const appConfig: ApplicationConfig = { IgxRippleModule, ), provideAnimations(), + // Social login: uncomment the provider(s) you want and replace the placeholder client IDs. + // Each provider requires its redirect URI to be registered in the provider's developer console. + // Redirect URIs: {origin}/auth/redirect-google | /auth/redirect-facebook | /auth/redirect-microsoft + // + // Additional requirements per provider: + // Google: register both Authorised JavaScript origins AND redirect URIs; HTTPS required in production. + // See: https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites + // Microsoft: register the redirect URI as a SPA (not "Web") platform in Azure to allow browser token exchange. + // See: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow + // Facebook: add the Facebook JS SDK to index.html, enable "Login with JavaScript SDK" in the dashboard, + // and add your domain to "Allowed Domains". HTTPS required. + // See: https://developers.facebook.com/docs/facebook-login/web + // + // Guide: https://github.com/IgniteUI/igniteui-cli/wiki/Angular-Authentication-Project-Template provideAuthentication({ + // TODO: Uncomment and replace with your Google OAuth Client ID // google: { clientId: 'YOUR_GOOGLE_CLIENT_ID' }, + // TODO: Uncomment and replace with your Microsoft Client ID + Tenant ID // microsoft: { clientId: 'YOUR_MICROSOFT_CLIENT_ID', tenantId: 'YOUR_TENANT_ID' }, + // TODO: Uncomment and replace with your Facebook App ID + // Note: Facebook requires HTTPS even for local dev - use ngrok or a local SSL proxy. // facebook: { clientId: 'YOUR_FACEBOOK_CLIENT_ID' }, }) ] diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.html b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.html index 388c143dc..35735a01a 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.html +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.html @@ -1,18 +1,20 @@ -
- - - Views - @for (route of topNavLinks; track route) { - {{route.name}} - } - - -
- - +
+ + -
+
+ + + @for (route of topNavLinks; track route) { + + {{route.icon}} + {{route.name}} + + } + + +
-
+
diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.routes.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.routes.ts index e2b96a9e6..00960a1fb 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.routes.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.routes.ts @@ -5,6 +5,6 @@ import { AUTH_BASE_PATH } from './authentication/services/external-auth-configs' export const routes: Routes = [ { path: '', redirectTo: '/home', pathMatch: 'full' }, - { path: 'home', component: Home, data: { text: 'Home' } }, + { path: 'home', component: Home, data: { text: 'Home', icon: 'home' } }, { path: AUTH_BASE_PATH, children: AUTH_ROUTES } ]; diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.spec.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.spec.ts index 7f31eed41..a0801f3a6 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.spec.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.spec.ts @@ -1,7 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterModule } from '@angular/router'; -import { IgxLayoutModule, IgxNavbarModule, IgxNavigationDrawerModule, IgxRippleModule } from 'igniteui-angular'; import { App } from './app'; import { provideAuthentication } from './authentication/provide-authentication'; @@ -11,10 +10,6 @@ describe('App', () => { imports: [ NoopAnimationsModule, RouterModule.forRoot([]), - IgxNavigationDrawerModule, - IgxNavbarModule, - IgxLayoutModule, - IgxRippleModule, App ], providers: [ diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.ts index 410c4549f..b038a6168 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/app.ts @@ -7,12 +7,14 @@ import { IgxNavDrawerItemDirective, IgxRippleDirective, IgxFlexDirective, - IgxNavbarComponent + IgxNavbarComponent, + IgxIconComponent, } from 'igniteui-angular'; import { filter } from 'rxjs/operators'; import { routes } from './app.routes'; import { LoginBar } from './authentication/login-bar/login-bar'; +import { UserStore } from './authentication/services/user-store'; @Component({ selector: 'app-root', @@ -30,25 +32,55 @@ import { LoginBar } from './authentication/login-bar/login-bar'; RouterLink, IgxFlexDirective, IgxNavbarComponent, + IgxIconComponent, RouterOutlet] }) export class App implements OnInit { - public topNavLinks: { + public appTitle = '<%=name%>'; + + private readonly homeNavLinks: { + path: string, + name: string, + icon: string + }[] = [ + { + name: 'Home', + path: '/home', + icon: 'home' + } + ]; + + private readonly profileNavLinks: { path: string, - name: string - }[] = []; + name: string, + icon: string + }[] = [ + { + name: 'Profile', + path: '/auth/profile', + icon: 'account_circle' + } + ]; + + public get topNavLinks() { + return this.userStore.currentUser + ? [...this.homeNavLinks, ...this.profileNavLinks] + : this.homeNavLinks; + } public navdrawer = viewChild.required(IgxNavigationDrawerComponent); + public userStore = inject(UserStore); private router = inject(Router); constructor() { for (const route of routes) { if (route.path && route.data && route.path.indexOf('*') === -1) { - this.topNavLinks.push({ + this.homeNavLinks[0] = { name: route.data['text'], - path: '/' + route.path - }); + path: '/' + route.path, + icon: route.data['icon'] || 'home' + }; } } } diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/auth.guard.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/auth.guard.ts index 83bcdd910..64748e42c 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/auth.guard.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/auth.guard.ts @@ -1,5 +1,5 @@ import { inject, Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router'; import { UserStore } from './services/user-store'; @Injectable({ @@ -9,11 +9,11 @@ export class AuthGuard implements CanActivate { private router = inject(Router); private userStore = inject(UserStore); - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + canActivate(route: ActivatedRouteSnapshot) { if (this.userStore.currentUser) { return true; } - this.router.navigate([''], { queryParams: { returnUrl: state.url } }); + this.router.navigate(['']); return false; } } diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.html b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.html index 08a996cdb..1826393cb 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.html +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.html @@ -1,21 +1,18 @@ @if (!userStore.currentUser) { - } @if (userStore.currentUser) { - + + } - - Profile - Log Out + + Profile + Log Out diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.scss b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.scss index c4b8e44a6..b6c6005ca 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.scss +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.scss @@ -1,8 +1,47 @@ -.login-button { - height: 3rem; - vertical-align: middle; +@use "igniteui-angular/theming" as *; + +$profile-menu-theme: drop-down-theme( + $schema: $light-material-schema, + $background-color: #fff, + $hover-item-background: #e8f3fc, + $hover-item-text-color: #0075d2, + $focused-item-background: #e8f3fc, + $focused-item-text-color: #0075d2, + $selected-item-background: #e8f3fc, + $selected-item-text-color: #0075d2, + $selected-hover-item-background: #e8f3fc, + $selected-hover-item-text-color: #0075d2 +); + +.navbar-login { + min-height: 36px; + font-weight: 600; + color: #0075d2; + background: #fff; + border: 1px solid rgba(0, 117, 210, 0.35); +} + +.profile-avatar { + cursor: pointer; + color: #0075d2; + background: #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, .24); + transition: box-shadow .15s ease, transform .15s ease; +} + +.profile-avatar:hover { + box-shadow: 0 3px 8px rgba(0, 0, 0, .28); +} + +.profile-avatar:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; } igx-drop-down-item { position: relative; } + +.profile-menu { + @include tokens($profile-menu-theme); +} diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.spec.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.spec.ts index 6bddc9178..7504284c2 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.spec.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.spec.ts @@ -68,23 +68,19 @@ describe('LoginBar', () => { }); it('should switch between buttons based on logged user ', () => { - let buttons = fixture.debugElement.queryAll(By.css('button')); - expect(buttons.length).toBe(2); - expect(buttons[0].nativeElement.innerText).toBe('Log In'); + expect(fixture.debugElement.query(By.css('.navbar-login')).nativeElement.innerText).toBe('Log In'); const userStore = TestBed.inject(UserStore); vi.spyOn(userStore, 'currentUser', 'get').mockReturnValue({ picture: 'picture' } as any); fixture.detectChanges(); - buttons = fixture.debugElement.queryAll(By.css('button')); - expect(buttons.length).toBe(2); - expect(buttons[0].nativeElement.children.length).toEqual(2); + expect(fixture.debugElement.query(By.css('.navbar-login'))).toBeNull(); const avatar: IgxAvatarComponent = fixture.debugElement.query(By.css('igx-avatar')).componentInstance; expect(avatar.src).toBe('picture'); }); it('should open dialog on button click (not logged)', () => { - const button = fixture.debugElement.query(By.css('button')); + const button = fixture.debugElement.query(By.css('.navbar-login')); vi.spyOn(component.loginDialog(), 'open'); button.triggerEventHandler('click', {}); expect(component.loginDialog().open).toHaveBeenCalled(); @@ -97,8 +93,8 @@ describe('LoginBar', () => { } as any); fixture.detectChanges(); - const button = fixture.debugElement.query(By.css('button')); - button.triggerEventHandler('click', {}); + const avatar = fixture.debugElement.query(By.css('.profile-avatar')); + avatar.triggerEventHandler('click', {}); await fixture.whenStable(); expect(component.igxDropDown().collapsed).toBeFalsy(); }); diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.ts index c47cd5044..4842c3500 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-bar/login-bar.ts @@ -1,7 +1,19 @@ import { Component, inject, viewChild } from '@angular/core'; import { Router } from '@angular/router'; -import { IgxDropDownComponent, ISelectionEventArgs, IgxRippleDirective, IgxButtonDirective, IgxToggleActionDirective, - IgxAvatarComponent, IgxIconComponent, IgxDropDownItemComponent } from 'igniteui-angular'; +import { + CloseScrollStrategy, + ConnectedPositioningStrategy, + HorizontalAlignment, + IgxDropDownComponent, + ISelectionEventArgs, + IgxRippleDirective, + IgxButtonDirective, + IgxToggleActionDirective, + IgxAvatarComponent, + IgxDropDownItemComponent, + OverlaySettings, + VerticalAlignment +} from 'igniteui-angular'; import { LoginDialog } from '../login-dialog/login-dialog'; import { ExternalAuth } from '../services/external-auth'; import { UserStore } from '../services/user-store'; @@ -10,7 +22,7 @@ import { UserStore } from '../services/user-store'; selector: 'app-login-bar', templateUrl: './login-bar.html', styleUrl: './login-bar.scss', - imports: [IgxRippleDirective, IgxButtonDirective, IgxToggleActionDirective, IgxAvatarComponent, IgxIconComponent, + imports: [IgxRippleDirective, IgxButtonDirective, IgxToggleActionDirective, IgxAvatarComponent, IgxDropDownComponent, IgxDropDownItemComponent, LoginDialog] }) export class LoginBar { @@ -19,10 +31,25 @@ export class LoginBar { igxDropDown = viewChild.required(IgxDropDownComponent); + public menuOverlaySettings: OverlaySettings = { + closeOnOutsideClick: true, + modal: false, + positionStrategy: new ConnectedPositioningStrategy({ + horizontalDirection: HorizontalAlignment.Left, + horizontalStartPoint: HorizontalAlignment.Right, + verticalStartPoint: VerticalAlignment.Bottom + }), + scrollStrategy: new CloseScrollStrategy() + }; + public userStore = inject(UserStore); private externalAuth = inject(ExternalAuth); private router = inject(Router); + public get isProfileRoute() { + return false; + } + openDialog() { this.loginDialog().open(); } @@ -34,7 +61,6 @@ export class LoginBar { } menuSelect(args: ISelectionEventArgs) { - // TODO: Use item value, swap to menu component in the future switch (args.newSelection.index) { case 0: this.router.navigate(['/auth/profile']); diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-dialog/login-dialog.scss b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-dialog/login-dialog.scss index 09607029f..8123b44ae 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-dialog/login-dialog.scss +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login-dialog/login-dialog.scss @@ -1,3 +1,5 @@ .sign-dialog { - width: 20rem; + box-sizing: border-box; + width: min(24rem, calc(100vw - 48px)); + padding: 4px 8px 8px; } diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.html b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.html index a34b1cd2d..571918bb7 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.html +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.html @@ -1,36 +1,37 @@
- - - account_circle - - + + + + account_circle + - - - lock - + - + + + lock +
- - Create new account? + + Create new account
@if (externalAuth.hasProvider()) { } diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.scss b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.scss index 32492fbae..9c7868c26 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.scss +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.scss @@ -1,24 +1,66 @@ form { display: flex; flex-flow: column; - align-items: center; - padding: 10px; + gap: 16px; + padding: 8px 0 0; + > * { width: 100%; } } -a { - padding-top: 10px; - cursor: pointer; +igx-input-group { + --ig-input-group-focused-secondary-color: #239ef0; + --ig-input-group-filled-text-color: #2d2d2d; + + igx-icon { + color: #0075d2; + } } -button { - margin-top: 15px; + +.button-wrapper { + display: flex; + flex-flow: column; + gap: 8px; + padding-top: 4px; +} + +button[type='submit'] { + min-height: 40px; width: 100%; + font-weight: 600; + text-transform: uppercase; +} + +button[type='submit']:not(:disabled) { + color: #fff; + background: #239ef0; +} + +button[type='submit']:disabled { + color: #767676; + background: #e0e0e0; +} + +.button-wrapper .auth-link { + align-self: center; + color: #0075d2; + font-size: .875rem; + cursor: pointer; + text-decoration: underline; + text-transform: none; +} + +.button-wrapper .auth-link:hover, +.button-wrapper .auth-link:focus-visible { + color: #005da8; } .social-login { - border-top: 1px solid gray; + display: grid; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #d7d7d7; button.google { background-color: rgb(255, 19, 74); @@ -29,4 +71,11 @@ button { button.microsoft { background-color: rgb(27, 158, 245); } + + button.google, + button.facebook, + button.microsoft { + color: #fff; + text-transform: uppercase; + } } diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.ts index eaa8d7294..d0d5e4ac0 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/login/login.ts @@ -1,7 +1,7 @@ import { Component, inject, output } from '@angular/core'; import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { Router } from '@angular/router'; -import { IgxInputGroupComponent, IgxPrefixDirective, IgxIconComponent, IgxLabelDirective, +import { IgxInputGroupComponent, IgxSuffixDirective, IgxIconComponent, IgxLabelDirective, IgxInputDirective, IgxButtonDirective, IgxRippleDirective } from 'igniteui-angular'; import { Authentication } from '../services/authentication'; @@ -13,7 +13,7 @@ import { UserStore } from '../services/user-store'; selector: 'app-login', templateUrl: './login.html', styleUrl: './login.scss', - imports: [ReactiveFormsModule, IgxInputGroupComponent, IgxPrefixDirective, IgxIconComponent, IgxLabelDirective, + imports: [ReactiveFormsModule, IgxInputGroupComponent, IgxSuffixDirective, IgxIconComponent, IgxLabelDirective, IgxInputDirective, IgxButtonDirective, IgxRippleDirective] }) export class Login { @@ -29,7 +29,6 @@ export class Login { }); public viewChange = output(); public loggedIn = output(); - /** expose to template */ public providers = ExternalAuthProvider; signUpG() { @@ -41,8 +40,10 @@ export class Login { } signUpFb() { + // Do NOT emit loggedIn here — Facebook uses a popup flow (FB.login). + // The redirect to /auth/redirect-facebook (and then /auth/profile) will + // navigate the user away naturally once authentication completes. this.externalAuth.login(ExternalAuthProvider.Facebook); - this.loggedIn.emit(); } async tryLogin() { @@ -54,7 +55,6 @@ export class Login { this.userStore.setCurrentUser(response.user!); this.router.navigate(['/auth/profile']); this.loginForm.reset(); - // https://github.com/angular/angular/issues/15741 Object.keys(this.loginForm.controls).forEach(key => { this.loginForm.get(key)?.setErrors(null); }); diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.html b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.html index 77c7a1514..ab7dfe819 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.html +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.html @@ -1,3 +1,32 @@ -

- Hello {{this.userStore.currentUser!.name.toUpperCase()}}, you are now logged in! -

+
+
+
+ + +
+

Signed in

+

{{userStore.currentUser?.name || 'Your profile'}}

+

Your account details are available on this protected route.

+
+
+ +
+
+
First name
+
{{userStore.currentUser?.given_name || 'Not provided'}}
+
+
+
Last name
+
{{userStore.currentUser?.family_name || 'Not provided'}}
+
+
+
Email
+
{{userStore.currentUser?.email || 'No email available'}}
+
+
+
+
diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.scss b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.scss index e69de29bb..e2ff9e5d6 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.scss +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.scss @@ -0,0 +1,99 @@ +.profile { + display: flex; + justify-content: center; + width: 100%; + padding: 72px 24px; + box-sizing: border-box; +} + +.profile__content { + width: 100%; + max-width: 640px; + background: #fff; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, .08); + padding: 32px; + box-sizing: border-box; +} + +.profile__header { + display: flex; + align-items: center; + gap: 20px; + padding-bottom: 24px; + border-bottom: 1px solid #d7eaf8; +} + +.profile__avatar { + flex: 0 0 auto; + background: #e0f2ff; + color: #0075d2; +} + +.profile__intro { + min-width: 0; +} + +.profile__status { + margin: 0 0 4px; + color: #000; + font-size: .875rem; + font-weight: 700; + text-transform: uppercase; +} + +h1 { + margin: 0; + overflow-wrap: anywhere; + color: #09f; + font-size: 2rem; + font-weight: 600; + line-height: 1.2; +} + +p { + margin: 8px 0 0; + color: #000; + font-size: 1rem; + line-height: 1.5; +} + +.profile__details { + margin: 28px 0 0; +} + +.profile__details div { + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + gap: 24px; + padding: 14px 0; + border-bottom: 1px solid #eef3f7; +} + +dt { + color: rgba(0, 0, 0, .62); + font-size: .875rem; + font-weight: 600; +} + +dd { + margin: 0; + overflow-wrap: anywhere; + color: #000; + font-size: 1rem; +} + +@media only screen and (max-width: 768px) { + .profile { + padding: 32px 16px; + } + + .profile__header { + align-items: flex-start; + } + + .profile__details div { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.ts index 82424422a..93baad11c 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/profile/profile.ts @@ -1,10 +1,12 @@ import { Component, inject } from '@angular/core'; +import { IgxAvatarComponent } from 'igniteui-angular'; import { UserStore } from '../services/user-store'; @Component({ selector: 'app-profile', templateUrl: './profile.html', - styleUrl: './profile.scss' + styleUrl: './profile.scss', + imports: [IgxAvatarComponent] }) export class Profile { public userStore = inject(UserStore); diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/provide-authentication.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/provide-authentication.ts index 8a13e2046..479d0750c 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/provide-authentication.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/provide-authentication.ts @@ -1,6 +1,6 @@ import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { ENVIRONMENT_INITIALIZER, EnvironmentProviders, inject, Provider } from '@angular/core'; -import { OpenIdConfiguration, provideAuth } from 'angular-auth-oidc-client'; +import { OidcSecurityService, OpenIdConfiguration, provideAuth } from 'angular-auth-oidc-client'; import { BackendProvider } from './services/fake-backend'; import { JwtInterceptor } from './services/jwt.interceptor'; @@ -57,7 +57,9 @@ export function provideAuthentication(authProviders: AuthProviders = {}): (Provi redirectUrl: `${window.location.origin}/${AUTH_BASE_PATH}/${ExternalAuthRedirectUrl.Microsoft}`, postLogoutRedirectUri: window.location.origin, clientId: authProviders.microsoft.clientId, - scope: 'openid profile email', + // offline_access is required by Microsoft to receive refresh tokens in auth-code + PKCE flows. + // See: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc#offline_access + scope: 'openid profile email offline_access', responseType: 'code', silentRenew: true, useRefreshToken: true, @@ -80,6 +82,10 @@ export function provideAuthentication(authProviders: AuthProviders = {}): (Provi if (authProviders.google) { externalAuth.addGoogle(); } if (authProviders.microsoft) { externalAuth.addMicrosoft(); } if (authProviders.facebook) { externalAuth.addFacebook(authProviders.facebook.clientId); } + // Restore any stored OIDC auth state on app load (handles refresh and deep-link scenarios). + // See: https://nice-hill-002425310.azurestaticapps.net/docs/documentation/public-api + const oidc = inject(OidcSecurityService); + oidc.checkAuth().subscribe(); } } ]; diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/providers/facebook-provider.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/providers/facebook-provider.ts index c6f781261..390e1135d 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/providers/facebook-provider.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/providers/facebook-provider.ts @@ -32,7 +32,8 @@ export class FacebookProvider implements AuthProvider { given_name: newResponse.first_name, family_name: newResponse.last_name, email: newResponse.email, - picture: newResponse.picture, + // Facebook returns picture as an object: { data: { url, width, height } } + picture: newResponse.picture?.data?.url, externalToken: FB.getAuthResponse()?.[accessToken] ?? '' }; this.router.navigate([this.externalStsConfig.redirect_url]); @@ -40,7 +41,7 @@ export class FacebookProvider implements AuthProvider { } else { console.log('User cancelled login or did not fully authorize.'); } - }, { scope: 'public_profile' }); + }, { scope: 'public_profile,email' }); } public getUserInfo(): Promise { diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/providers/microsoft-provider.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/providers/microsoft-provider.ts index 39809545f..49fed2d94 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/providers/microsoft-provider.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/providers/microsoft-provider.ts @@ -9,10 +9,13 @@ export class MicrosoftProvider extends BaseOidcProvider { */ protected async formatUserData(userData: any): Promise { const token = await firstValueFrom(this.oidcSecurityService.getAccessToken(this.configId)); + // The 'email' claim is not guaranteed in Microsoft ID tokens even when requested. + // Fall back to 'preferred_username' which is typically the UPN/email for work/school accounts. + // See: https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference return { id: userData.oid, name: userData.name, - email: userData.email, + email: userData.email ?? userData.preferred_username, externalToken: token }; } diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/redirect/redirect.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/redirect/redirect.ts index 71fbd13c2..34a764b21 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/redirect/redirect.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/redirect/redirect.ts @@ -26,7 +26,14 @@ export class Redirect implements OnInit { async ngOnInit() { try { - await firstValueFrom(this.oidcSecurityService.checkAuth()); + // Facebook uses the JS SDK (popup), not OIDC — skip checkAuth to avoid + // querying an OIDC config that does not exist for this provider. + if (this.provider !== ExternalAuthProvider.Facebook) { + // Pass the configId so the correct OIDC config is used when multiple providers + // are active. Without it, angular-auth-oidc-client falls back to the first config. + const configId = this.externalAuth.activeProvider; + await firstValueFrom(this.oidcSecurityService.checkAuth(undefined, configId)); + } const userInfo: ExternalLogin = await this.externalAuth.getUserInfo(this.provider); const result = await this.authentication.loginWith(userInfo); if (!result.error) { diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.html b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.html index d6f18be64..68376f092 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.html +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.html @@ -1,34 +1,35 @@ - - - assignment_ind - - + - - - + + assignment_ind - - + + + + + + assignment_ind + - - - alternate_email - - + + + + account_circle + - - - lock - + - + + + lock + -
- - Have an account? +
+ + Have an account?
diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.scss b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.scss index 858338a78..1c3f0f6ae 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.scss +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.scss @@ -1,18 +1,60 @@ form { display: flex; flex-flow: column; - align-items: center; - padding: 10px; + gap: 16px; + padding: 8px 0 0; + > * { width: 100%; } } -button { +igx-input-group { + --ig-input-group-focused-secondary-color: #239ef0; + --ig-input-group-filled-text-color: #2d2d2d; + + igx-icon { + color: #0075d2; + } +} + +.button-wrapper { + display: flex; + flex-flow: column; + gap: 8px; + padding-top: 4px; +} + +button[type='submit'] { + min-height: 40px; width: 100%; - margin-top: 10px; + font-weight: 600; + text-transform: uppercase; +} + +button[type='submit']:not(:disabled) { + color: #fff; + background: #239ef0; } -a { +button[type='submit']:disabled { + color: #767676; + background: #e0e0e0; +} + +.button-wrapper .auth-link { + align-self: center; + border: none; + background: transparent; + color: #0075d2; + font-size: .875rem; cursor: pointer; + text-decoration: underline; + padding: 0; + text-transform: none; +} + +.button-wrapper .auth-link:hover, +.button-wrapper .auth-link:focus-visible { + color: #005da8; } diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.ts index f07af6be7..f970ed085 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.ts @@ -1,8 +1,8 @@ import { Component, inject, output } from '@angular/core'; import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; import { Router } from '@angular/router'; -import { IgxInputGroupComponent, IgxPrefixDirective, IgxIconComponent, IgxLabelDirective, IgxInputDirective, IgxButtonDirective, - IgxRippleDirective } from 'igniteui-angular'; +import { IgxInputGroupComponent, IgxSuffixDirective, IgxIconComponent, IgxLabelDirective, IgxInputDirective, IgxButtonDirective, + IgxRippleDirective } from 'igniteui-angular'; import { RegisterInfo } from '../models/register-info'; import { Authentication } from '../services/authentication'; import { UserStore } from '../services/user-store'; @@ -11,8 +11,8 @@ import { UserStore } from '../services/user-store'; selector: 'app-register', templateUrl: './register.html', styleUrl: './register.scss', - imports: [ReactiveFormsModule, IgxInputGroupComponent, IgxPrefixDirective, IgxIconComponent, IgxLabelDirective, - IgxInputDirective, IgxButtonDirective, IgxRippleDirective] + imports: [ReactiveFormsModule, IgxInputGroupComponent, IgxSuffixDirective, IgxIconComponent, IgxLabelDirective, + IgxInputDirective, IgxButtonDirective, IgxRippleDirective] }) export class Register { diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts index f644e88b0..f0dbf5ba6 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/authentication.ts @@ -34,7 +34,7 @@ export class Authentication { const data = await this.http.post(endpoint, userData).toPromise() as string; user = parseUser(data); } catch (e: any) { - return { error: e.message }; + return { error: e?.error?.message ?? e?.message ?? 'Authentication failed.' }; } return { user }; diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-configs.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-configs.ts index ffefc1d37..ff62da04d 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-configs.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/external-auth-configs.ts @@ -1,3 +1,4 @@ +/** Base path for auth routes. OAuth redirect URIs are built as: {origin}/auth/redirect-google|facebook|microsoft */ export const AUTH_BASE_PATH = 'auth'; export enum ExternalAuthProvider { diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/fake-backend.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/fake-backend.ts index c87a08b89..7b3e0aa39 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/fake-backend.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/fake-backend.ts @@ -1,3 +1,6 @@ +// ⚠ DEVELOPMENT ONLY — simulates POST /login, /register, /extlogin using localStorage. +// Before going to production: remove BackendProvider from provide-authentication.ts +// and replace it with calls to your real API. See authentication.ts for the endpoints. import { HttpEvent, HttpHandler, diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/microsoft-keys.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/microsoft-keys.ts index 8fa3b9b61..3cc784ebf 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/microsoft-keys.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/microsoft-keys.ts @@ -1,3 +1,5 @@ -// Replace with backend discovery from https://login.microsoftonline.com/consumers/discovery/v2.0/keys -// See https://blogs.msdn.microsoft.com/mihansen/2018/07/12/net-core-angular-app-with-openid-connection-implicit-flow-authentication-angular-auth-oidc-client/ +// Dev-only workaround: the fake backend intercepts requests to 'ms-discovery/keys' +// and returns these keys so the OIDC library can validate Microsoft tokens locally. +// This file is unused in production — removing BackendProvider causes the OIDC library +// to call the real Microsoft discovery endpoint automatically. Do not edit this file. export default { "keys": [{ "kty": "RSA", "use": "sig", "kid": "1LTMzakihiRla_8z2BEJVXeWMqo", "x5t": "1LTMzakihiRla_8z2BEJVXeWMqo", "n": "3sKcJSD4cHwTY5jYm5lNEzqk3wON1CaARO5EoWIQt5u-X-ZnW61CiRZpWpfhKwRYU153td5R8p-AJDWT-NcEJ0MHU3KiuIEPmbgJpS7qkyURuHRucDM2lO4L4XfIlvizQrlyJnJcd09uLErZEO9PcvKiDHoois2B4fGj7CsAe5UZgExJvACDlsQSku2JUyDmZUZP2_u_gCuqNJM5o0hW7FKRI3MFoYCsqSEmHnnumuJ2jF0RHDRWQpodhlAR6uKLoiWHqHO3aG7scxYMj5cMzkpe1Kq_Dm5yyHkMCSJ_JaRhwymFfV_SWkqd3n-WVZT0ADLEq0RNi9tqZ43noUnO_w", "e": "AQAB", "x5c": ["MIIDYDCCAkigAwIBAgIJAIB4jVVJ3BeuMA0GCSqGSIb3DQEBCwUAMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleTAeFw0xNjA0MDUxNDQzMzVaFw0yMTA0MDQxNDQzMzVaMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN7CnCUg+HB8E2OY2JuZTRM6pN8DjdQmgETuRKFiELebvl/mZ1utQokWaVqX4SsEWFNed7XeUfKfgCQ1k/jXBCdDB1NyoriBD5m4CaUu6pMlEbh0bnAzNpTuC+F3yJb4s0K5ciZyXHdPbixK2RDvT3Lyogx6KIrNgeHxo+wrAHuVGYBMSbwAg5bEEpLtiVMg5mVGT9v7v4ArqjSTOaNIVuxSkSNzBaGArKkhJh557pridoxdERw0VkKaHYZQEerii6Ilh6hzt2hu7HMWDI+XDM5KXtSqvw5ucsh5DAkifyWkYcMphX1f0lpKnd5/llWU9AAyxKtETYvbameN56FJzv8CAwEAAaOBijCBhzAdBgNVHQ4EFgQU9IdLLpbC2S8Wn1MCXsdtFac9SRYwWQYDVR0jBFIwUIAU9IdLLpbC2S8Wn1MCXsdtFac9SRahLaQrMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleYIJAIB4jVVJ3BeuMAsGA1UdDwQEAwIBxjANBgkqhkiG9w0BAQsFAAOCAQEAXk0sQAib0PGqvwELTlflQEKS++vqpWYPW/2gCVCn5shbyP1J7z1nT8kE/ZDVdl3LvGgTMfdDHaRF5ie5NjkTHmVOKbbHaWpTwUFbYAFBJGnx+s/9XSdmNmW9GlUjdpd6lCZxsI6888r0ptBgKINRRrkwMlq3jD1U0kv4JlsIhafUIOqGi4+hIDXBlY0F/HJPfUU75N885/r4CCxKhmfh3PBM35XOch/NGC67fLjqLN+TIWLoxnvil9m3jRjqOA9u50JUeDGZABIYIMcAdLpI2lcfru4wXcYXuQul22nAR7yOyGKNOKULoOTE4t4AeGRqCogXSxZgaTgKSBhvhE+MGg=="], "issuer": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0" }, { "kty": "RSA", "use": "sig", "kid": "xP_zn6I1YkXcUUmlBoPuXTGsaxk", "x5t": "xP_zn6I1YkXcUUmlBoPuXTGsaxk", "n": "2pWatafeb3mB0A73-Z-URwrubwDldWvivRu19GNC61MBOb3fZ4I4lyhUhNuS7aJRPJIFB6zl-HFx1nHpGg74BHe0z9skODHYZEACd2iKBIet55DdduIe1CXsZ9keyEmNaGv3XS4OW_7IDM0j5wR9OHugUifkH3PQIcFvTYanHmXojTmgjIOWoz7y0okpyN9-FbZRzdfx-ej-njaj5gR8r69muwO5wlTbIG20V40R6zYh-QODMUpayy7jDGFGw5vjFH9Ca0tLZcNQq__JKE_mp-0fODOAQobOrBUoASFkyCd95BVW7KJrndvW7ofRWaCTuZZOy5SnU4asbjMrgxFZFw", "e": "AQAB", "x5c": ["MIIDYDCCAkigAwIBAgIJAJzCyTLC+DjJMA0GCSqGSIb3DQEBCwUAMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleTAeFw0xNjA3MTMyMDMyMTFaFw0yMTA3MTIyMDMyMTFaMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANqVmrWn3m95gdAO9/mflEcK7m8A5XVr4r0btfRjQutTATm932eCOJcoVITbku2iUTySBQes5fhxcdZx6RoO+AR3tM/bJDgx2GRAAndoigSHreeQ3XbiHtQl7GfZHshJjWhr910uDlv+yAzNI+cEfTh7oFIn5B9z0CHBb02Gpx5l6I05oIyDlqM+8tKJKcjffhW2Uc3X8fno/p42o+YEfK+vZrsDucJU2yBttFeNEes2IfkDgzFKWssu4wxhRsOb4xR/QmtLS2XDUKv/yShP5qftHzgzgEKGzqwVKAEhZMgnfeQVVuyia53b1u6H0Vmgk7mWTsuUp1OGrG4zK4MRWRcCAwEAAaOBijCBhzAdBgNVHQ4EFgQU11z579/IePwuc4WBdN4L0ljG4CUwWQYDVR0jBFIwUIAU11z579/IePwuc4WBdN4L0ljG4CWhLaQrMCkxJzAlBgNVBAMTHkxpdmUgSUQgU1RTIFNpZ25pbmcgUHVibGljIEtleYIJAJzCyTLC+DjJMAsGA1UdDwQEAwIBxjANBgkqhkiG9w0BAQsFAAOCAQEAiASLEpQseGNahE+9f9PQgmX3VgjJerNjXr1zXWXDJfFE31DxgsxddjcIgoBL9lwegOHHvwpzK1ecgH45xcJ0Z/40OgY8NITqXbQRfdgLrEGJCoyOQEbjb5PW5k2aOdn7LBxvDsH6Y8ax26v+EFMPh3G+xheh6bfoIRSK1b+44PfoDZoJ9NfJibOZ4Cq+wt/yOvpMYQDB/9CNo18wmA3RCLYjf2nAc7RO0PDYHSIq5QDWV+1awmXDKgIdRpYPpRtn9KFXQkpCeEc/lDTG+o6n7nC40wyjioyR6QmHGvNkMR4VfSoTKCTnFATyDpI1bqU2K7KNjUEsCYfwybFB8d6mjQ=="], "issuer": "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0" }] }; diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/user-store.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/user-store.ts index b56c9d348..3906d3d12 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/user-store.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/services/user-store.ts @@ -50,6 +50,10 @@ export class UserStore { /** Save new login as current user */ public setCurrentUser(user: User) { + // In production the user is kept in memory only (not written to localStorage) + // to avoid exposing the token to XSS. On page refresh the user will need to + // log in again. To persist sessions securely, use an HttpOnly cookie set by + // your backend instead of relying on localStorage. if (isDevMode()) { this.localStorage.setItem(USER_TOKEN, JSON.stringify(user)); } diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/index.html b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/index.html index d305d6eb2..53e79f93c 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/index.html +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/index.html @@ -8,7 +8,10 @@ - + + diff --git a/packages/igx-templates/igx-ts/projects/side-nav-auth/index.ts b/packages/igx-templates/igx-ts/projects/side-nav-auth/index.ts index 324f4e91e..a99790d89 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav-auth/index.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav-auth/index.ts @@ -10,6 +10,7 @@ export class AuthSideProject extends SideNavProject implements ProjectTemplate { public framework: string = "angular"; public projectType: string = "igx-ts"; public hasExtraConfiguration = false; + public isHidden = true; public get templatePaths() { return [...super.templatePaths, path.join(__dirname, "files")]; diff --git a/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.config.ts b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.config.ts new file mode 100644 index 000000000..59a41cf02 --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.config.ts @@ -0,0 +1,52 @@ +import { ApplicationConfig, importProvidersFrom, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; +import { BrowserModule, HammerModule } from '@angular/platform-browser'; +import { provideAnimations } from '@angular/platform-browser/animations'; +import { provideRouter } from '@angular/router'; +import { + IgxNavigationDrawerModule, + IgxNavbarModule, + IgxIconModule, + IgxRippleModule, +} from '<%=igxPackage%>'; + +import { provideAuthentication } from './authentication'; +import { routes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + importProvidersFrom( + BrowserModule, + HammerModule, + IgxNavigationDrawerModule, + IgxNavbarModule, + IgxIconModule, + IgxRippleModule, + ), + provideAnimations(), + // Social login: uncomment the provider(s) you want and replace the placeholder client IDs. + // Each provider requires its redirect URI to be registered in the provider's developer console. + // Redirect URIs: {origin}/auth/redirect-google | /auth/redirect-facebook | /auth/redirect-microsoft + // + // Additional requirements per provider: + // Google: register both Authorised JavaScript origins AND redirect URIs; HTTPS required in production. + // See: https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites + // Microsoft: register the redirect URI as a SPA (not "Web") platform in Azure to allow browser token exchange. + // See: https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow + // Facebook: add the Facebook JS SDK to index.html, enable "Login with JavaScript SDK" in the dashboard, + // and add your domain to "Allowed Domains". HTTPS required. + // See: https://developers.facebook.com/docs/facebook-login/web + // + // Guide: https://github.com/IgniteUI/igniteui-cli/wiki/Angular-Authentication-Project-Template + provideAuthentication({ + // TODO: Uncomment and replace with your Google OAuth Client ID + // google: { clientId: 'YOUR_GOOGLE_CLIENT_ID' }, + // TODO: Uncomment and replace with your Microsoft Client ID + Tenant ID + // microsoft: { clientId: 'YOUR_MICROSOFT_CLIENT_ID', tenantId: 'YOUR_TENANT_ID' }, + // TODO: Uncomment and replace with your Facebook App ID + // facebook: { clientId: 'YOUR_FACEBOOK_CLIENT_ID' }, + }) + ] +}; diff --git a/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.html b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.html new file mode 100644 index 000000000..f1bed9030 --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.html @@ -0,0 +1,30 @@ + + + + + + +
+ + + @for (link of topNavLinks; track link) { + + {{link.icon}} + {{link.name}} + + } + + + @for (link of topNavLinks; track link) { + + {{link.icon}} + + } + + +
+ +
+
diff --git a/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.spec.ts b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.spec.ts new file mode 100644 index 000000000..a0801f3a6 --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.spec.ts @@ -0,0 +1,26 @@ +import { TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterModule } from '@angular/router'; +import { App } from './app'; +import { provideAuthentication } from './authentication/provide-authentication'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + RouterModule.forRoot([]), + App + ], + providers: [ + ...provideAuthentication() + ] + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); +}); diff --git a/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.ts b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.ts new file mode 100644 index 000000000..89d89f1d7 --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.ts @@ -0,0 +1,99 @@ +import { Component, HostListener, OnInit, AfterViewInit, viewChild, ViewEncapsulation, inject } from '@angular/core'; +import { NavigationStart, Router, RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router'; +import { + IgxNavigationDrawerComponent, + IgxNavDrawerTemplateDirective, + IgxNavDrawerMiniTemplateDirective, + IgxNavDrawerItemDirective, + IgxRippleDirective, + IgxNavbarComponent, + IgxNavbarActionDirective, + IgxIconComponent, + IgxIconButtonDirective, +} from 'igniteui-angular'; +import { filter } from 'rxjs/operators'; + +import { routes } from './app.routes'; +import { LoginBar } from './authentication/login-bar/login-bar'; +import { UserStore } from './authentication/services/user-store'; + +const MINI_BREAKPOINT = 1024; + +@Component({ + selector: 'app-root', + templateUrl: './app.html', + styleUrl: './app.scss', + encapsulation: ViewEncapsulation.None, + imports: [ + LoginBar, + IgxNavigationDrawerComponent, + IgxNavDrawerTemplateDirective, + IgxNavDrawerMiniTemplateDirective, + IgxNavDrawerItemDirective, + IgxRippleDirective, + RouterLinkActive, + RouterLink, + IgxNavbarComponent, + IgxNavbarActionDirective, + IgxIconComponent, + IgxIconButtonDirective, + RouterOutlet + ] +}) +export class App implements OnInit, AfterViewInit { + public appTitle = '<%=name%>'; + + private readonly homeNavLinks: { path: string; name: string; icon: string }[] = [ + { name: 'Home', path: '/home', icon: 'home' } + ]; + + private readonly profileNavLinks: { path: string; name: string; icon: string }[] = [ + { name: 'Profile', path: '/auth/profile', icon: 'account_circle' } + ]; + + public get topNavLinks() { + return this.userStore.currentUser + ? [...this.homeNavLinks, ...this.profileNavLinks] + : this.homeNavLinks; + } + + public readonly initiallyOpen = window.innerWidth > MINI_BREAKPOINT; + public navdrawer = viewChild.required(IgxNavigationDrawerComponent); + + public userStore = inject(UserStore); + private router = inject(Router); + + constructor() { + for (const route of routes) { + if (route.path && route.data && route.path.indexOf('*') === -1) { + this.homeNavLinks[0] = { + name: route.data['text'], + path: '/' + route.path, + icon: route.data['icon'] || 'home' + }; + } + } + } + + public ngOnInit(): void { + this.router.events.pipe( + filter((x): x is NavigationStart => x instanceof NavigationStart) + ).subscribe(() => this.updateDrawerState()); + } + + public ngAfterViewInit(): void { + this.updateDrawerState(); + } + + public toggleNav(): void { + this.navdrawer().toggle(); + } + + @HostListener('window:resize') + public updateDrawerState(): void { + const isWide = window.innerWidth > MINI_BREAKPOINT; + if (!isWide && this.navdrawer().isOpen) { + this.navdrawer().close(); + } + } +} diff --git a/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/index.ts b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/index.ts new file mode 100644 index 000000000..2d5388151 --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/index.ts @@ -0,0 +1,26 @@ +import { ProjectTemplate } from "@igniteui/cli-core"; +import * as path from "path"; +import { SideNavMiniProject } from "../side-nav-mini"; + +export class SideNavMiniAuthProject extends SideNavMiniProject implements ProjectTemplate { + public id: string = "side-nav-mini-auth"; + public name = "Side navigation (mini) + login"; + public description = "Collapsible mini navigation extended with user authentication module"; + public dependencies: string[] = []; + public framework: string = "angular"; + public projectType: string = "igx-ts"; + public hasExtraConfiguration = false; + public isHidden = true; + + public get templatePaths() { + return [ + ...super.templatePaths, + // Auth overlay: app.routes.ts, app.config.ts, authentication/ (shared with side-nav-auth) + path.join(__dirname, "../side-nav-auth/files"), + // Mini+auth specific: app.ts and app.html that combine both + path.join(__dirname, "files") + ]; + } +} + +export default new SideNavMiniAuthProject(); diff --git a/packages/igx-templates/igx-ts/projects/side-nav/files/src/app/app.html b/packages/igx-templates/igx-ts/projects/side-nav/files/src/app/app.html index 371bb8999..163a3da62 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav/files/src/app/app.html +++ b/packages/igx-templates/igx-ts/projects/side-nav/files/src/app/app.html @@ -1,5 +1,5 @@
- +
diff --git a/packages/igx-templates/igx-ts/projects/side-nav/files/src/app/app.ts b/packages/igx-templates/igx-ts/projects/side-nav/files/src/app/app.ts index 9056d136a..862dd56d7 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav/files/src/app/app.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav/files/src/app/app.ts @@ -33,6 +33,8 @@ import { routes } from './app.routes'; ] }) export class App implements OnInit { + public appTitle = '<%=name%>'; + public topNavLinks: { path: string, name: string, diff --git a/packages/igx-templates/igx-ts/projects/side-nav/index.ts b/packages/igx-templates/igx-ts/projects/side-nav/index.ts index 22d2e08de..ce4042251 100644 --- a/packages/igx-templates/igx-ts/projects/side-nav/index.ts +++ b/packages/igx-templates/igx-ts/projects/side-nav/index.ts @@ -4,7 +4,7 @@ import { BaseWithHomeProject } from "../_base_with_home"; export class SideNavProject extends BaseWithHomeProject implements ProjectTemplate { public id: string = "side-nav"; - public name = "Default side navigation"; + public name = "Side navigation default"; public description = "Project structure with side navigation drawer"; public dependencies: string[] = []; public framework: string = "angular"; diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index b5f974f57..ee0eb8898 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -138,7 +138,8 @@ describe("Unit - PromptSession", () => { const mockProjectLibrary = { themes: ["infragistics", "infragistics.less"], projectIds: ["empty"], - projects: [mockProject] + projects: [mockProject], + getProject: jasmine.createSpy().and.returnValue(undefined) }; const projectLibraries = ["jQuery", "Angular", "React"]; const mockFramework1 = { @@ -250,7 +251,8 @@ describe("Unit - PromptSession", () => { const mockProjectLibrary = { themes: ["infragistics"], projectIds: ["empty"], - projects: [mockProjectTemplate] + projects: [mockProjectTemplate], + getProject: jasmine.createSpy().and.returnValue(undefined) }; const projectLibraries = [ { projectType: "ig-ts", name: "Ignite UI Angular Wrappers" }, @@ -326,7 +328,8 @@ describe("Unit - PromptSession", () => { const mockProjectLibrary = { projectIds: ["empty"], themes: ["infragistics", "infragistics.less"], - projects: [mockProject] + projects: [mockProject], + getProject: jasmine.createSpy().and.returnValue(undefined) }; const projectLibraries = ["jQuery", "Angular", "React"]; const mockFramework1 = { @@ -831,9 +834,10 @@ describe("Unit - PromptSession", () => { spyOn(InquirerWrapper, "input").and.returnValues(Promise.resolve("Test1")); spyOn(InquirerWrapper, "select").and.returnValues( Promise.resolve("Angular"), - Promise.resolve("Default side navigation"), + Promise.resolve("Side navigation default"), Promise.resolve("Custom") ); + spyOn(InquirerWrapper, "confirm").and.returnValue(Promise.resolve(false)); spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); spyOn(mockSession, "chooseActionLoop").and.returnValue(Promise.resolve()); spyOn(process, "chdir"); @@ -871,8 +875,9 @@ describe("Unit - PromptSession", () => { spyOn(InquirerWrapper, "input").and.returnValues(Promise.resolve("Test1")); spyOn(InquirerWrapper, "select").and.returnValues( Promise.resolve("Angular"), - Promise.resolve("Default side navigation"), + Promise.resolve("Side navigation default"), Promise.resolve("Default")); + spyOn(InquirerWrapper, "confirm").and.returnValue(Promise.resolve(false)); spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve([])); spyOn(mockSession, "chooseActionLoop").and.returnValue(Promise.resolve()); spyOn(process, "chdir"); @@ -888,7 +893,8 @@ describe("Unit - PromptSession", () => { const visibleProject1 = createMockProjectTemplate({ ...mockBaseTemplate, name: "empty", isHidden: false }); const visibleProject2 = createMockProjectTemplate({ ...mockBaseTemplate, name: "top-nav", isHidden: false }); const mockProjectLibrary = { - projects: [hiddenProject, visibleProject1, visibleProject2] + projects: [hiddenProject, visibleProject1, visibleProject2], + getProject: jasmine.createSpy().and.returnValue(undefined) } as unknown as ProjectLibrary; const mockTemplate = jasmine.createSpyObj("mockTemplate", ["getProjectLibrary"]);