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..9820b8459 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app.css @@ -0,0 +1,79 @@ +.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[active]::part(base) { + background: #e0f2ff; + color: #0075d2; +} + +igc-nav-drawer-item[active] igc-icon { + color: #0075d2; +} + +.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..f45ae508a --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/app.tsx @@ -0,0 +1,121 @@ +import { useEffect, useMemo, useState } from "react"; +import { Outlet, useLocation, useNavigate } from "react-router-dom"; +import { + IgrIcon, + IgrNavDrawer, + IgrNavDrawerItem, + registerIcon, +} from "igniteui-react"; +import { AuthProvider, useAuth } from "./authentication/AuthContext"; +import { LoginBar } from "./authentication/components/LoginBar"; +import { routes } from "./app-routes"; +import "igniteui-webcomponents/themes/light/bootstrap.css"; +import "./app.css"; + +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..18e7557bc --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Login.module.css @@ -0,0 +1,118 @@ +.form { + display: flex; + flex-flow: column; + gap: 16px; + padding: 8px 0 0; +} + +.field { + position: relative; +} + +.input { + box-sizing: border-box; + width: 100%; + height: 40px; + padding: 0 36px 0 12px; + border: 1px solid #c4c4c4; + border-radius: 4px; + font-size: 1rem; + color: #2d2d2d; + outline: none; + transition: border-color .15s; +} + +.input:focus { + border-color: #239ef0; +} + +.inputIcon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: #0075d2; + width: 20px; + height: 20px; +} + +.error { + margin: 0; + font-size: .875rem; + color: #d32f2f; +} + +.actions { + display: flex; + flex-flow: column; + gap: 8px; + padding-top: 4px; +} + +.submitBtn { + width: 100%; + min-height: 36px; + border: none; + border-radius: 4px; + background: #239ef0; + color: #fff; + font-size: .875rem; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + transition: background .15s; +} + +.submitBtn:hover { + background: #1a8fd8; +} + +.submitBtn:disabled { + background: #e0e0e0; + color: #767676; + cursor: default; +} + +.linkBtn { + align-self: center; + border: none; + background: transparent; + color: #0075d2; + font-size: .875rem; + cursor: pointer; + text-decoration: underline; + padding: 0; +} + +.linkBtn:hover { + color: #005da8; +} + +.socialLogin { + display: grid; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #d7d7d7; +} + +.socialBtn { + width: 100%; + min-height: 36px; + border: none; + border-radius: 4px; + color: #fff; + font-size: .875rem; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + transition: filter .15s; +} + +.socialBtn:hover { + filter: brightness(0.9); +} + +.google { background: rgb(255, 19, 74); } +.facebook { background: rgb(19, 119, 213); } +.microsoft { 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..3df2034da --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Login.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { IgrIcon } 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: React.FormEvent) => { + e.preventDefault(); + setError(''); + const data: LoginData = { email, password }; + const err = await login(data); + if (err) { + setError(err); + } else { + setPassword(''); + onSuccess(); + } + }; + + return ( +
+
+ setEmail(e.target.value)} + required + /> + +
+
+ setPassword(e.target.value)} + required + /> + +
+ {error &&

{error}

} +
+ + +
+ {ExternalAuth.hasProvider() && ( +
+ {ExternalAuth.hasProvider('google') && ( + + )} + {ExternalAuth.hasProvider('facebook') && ( + + )} + {ExternalAuth.hasProvider('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..3da3b59e2 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginBar.module.css @@ -0,0 +1,30 @@ +.loginBtn { + border: 1px solid rgba(0, 117, 210, 0.35); + background: #fff; + color: #0075d2; + font-size: .875rem; + font-weight: 600; + cursor: pointer; + padding: 10px 20px; + border-radius: 4px; + white-space: nowrap; + text-transform: none; + transition: background .15s; +} + +.loginBtn:hover { + background: #e8f3fc; +} + +.profileAvatar { + cursor: pointer; + color: #0075d2; + --ig-avatar-background: #fff; + --ig-avatar-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..472d28eba --- /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, 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(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..b2e7fd959 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginDialog.module.css @@ -0,0 +1,28 @@ +.dialog { + box-sizing: border-box; + width: min(24rem, calc(100vw - 48px)); + padding: 0; + border: none; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, .2); +} + +.dialog::backdrop { + background: rgba(0, 0, 0, .4); +} + +.header { + padding: 20px 24px 12px; + border-bottom: 1px solid #e0e0e0; +} + +.title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #2d2d2d; +} + +.body { + padding: 16px 24px 24px; +} 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..65cc9d5f8 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/LoginDialog.tsx @@ -0,0 +1,54 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +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(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (open) { + setShowLogin(true); + if (!dialog.open) dialog.showModal(); + } else { + if (dialog.open) dialog.close(); + } + }, [open]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const handleClose = () => onClose(); + dialog.addEventListener('close', handleClose); + return () => dialog.removeEventListener('close', handleClose); + }, [onClose]); + + const handleSuccess = () => { + dialogRef.current?.close(); + navigate('/auth/profile'); + }; + + return ( + { if (e.target === dialogRef.current) dialogRef.current?.close(); }}> +
+

{showLogin ? 'Login' : 'Register'}

+
+
+ {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..b8d34c903 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Register.module.css @@ -0,0 +1,90 @@ +.form { + display: flex; + flex-flow: column; + gap: 16px; + padding: 8px 0 0; +} + +.field { + position: relative; +} + +.input { + box-sizing: border-box; + width: 100%; + height: 40px; + padding: 0 36px 0 12px; + border: 1px solid #c4c4c4; + border-radius: 4px; + font-size: 1rem; + color: #2d2d2d; + outline: none; + transition: border-color .15s; +} + +.input:focus { + border-color: #239ef0; +} + +.inputIcon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: #0075d2; + width: 20px; + height: 20px; +} + +.error { + margin: 0; + font-size: .875rem; + color: #d32f2f; +} + +.actions { + display: flex; + flex-flow: column; + gap: 8px; + padding-top: 4px; +} + +.submitBtn { + width: 100%; + min-height: 36px; + border: none; + border-radius: 4px; + background: #239ef0; + color: #fff; + font-size: .875rem; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + transition: background .15s; +} + +.submitBtn:hover { + background: #1a8fd8; +} + +.submitBtn:disabled { + background: #e0e0e0; + color: #767676; + cursor: default; +} + +.linkBtn { + align-self: center; + border: none; + background: transparent; + color: #0075d2; + font-size: .875rem; + cursor: pointer; + text-decoration: underline; + padding: 0; +} + +.linkBtn: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..43c5e8240 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/components/Register.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react'; +import { IgrIcon } 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: React.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.target.value)} required /> + +
+
+ setFamilyName(e.target.value)} /> + +
+
+ setEmail(e.target.value)} required /> + +
+
+ setPassword(e.target.value)} required /> + +
+ {error &&

{error}

} +
+ + +
+
+ ); +} 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..1f01dd7e3 --- /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; + 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..0cc898c87 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/Profile.module.css @@ -0,0 +1,101 @@ +.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 64px; + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 50%; + background: #e0f2ff; + color: #0075d2; + font-size: 1.5rem; + font-weight: 700; + overflow: hidden; +} + +.avatarImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.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..06e377980 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/pages/Profile.tsx @@ -0,0 +1,41 @@ +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 ( +
+
+
+
+ {currentUser?.picture + ? {currentUser.name} + : initials + } +
+
+

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..51cf8eba3 --- /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,33 @@ +// Social login configuration. +// To enable a provider, uncomment its entry and replace the placeholder value(s) 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 { + // TODO: Uncomment and replace with your Google OAuth Client ID (type: Web application) + // google?: { clientId: string }; + + // TODO: Uncomment and replace with your Microsoft Application (client) ID + // tenantId defaults to 'common' (multi-tenant). Set it for single-tenant apps. + // microsoft?: { clientId: string; tenantId?: string }; + + // TODO: Uncomment and replace with your Facebook App ID. + // Also add the Facebook JS SDK to your index.html : + // + // facebook?: { clientId: string }; +} + +// Active OAuth configuration — uncomment provider(s) above to enable social login. +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..3021618de --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/externalAuth.ts @@ -0,0 +1,225 @@ +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 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; + +/** + * 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 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: crypto.randomUUID(), + }); + } 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 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: crypto.randomUUID(), + } + ); + } else if (provider === 'facebook') { + const cfg = oauthConfig.facebook!; + FB.init({ appId: cfg.clientId, xfbml: false, version: 'v3.1' }); + 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, + picture: res.picture, + 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.'); + + 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 = JSON.parse(atob(data.id_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + 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 = JSON.parse(atob(data.id_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + 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') { + // FB.logout is only available when the SDK is loaded and the user is connected. + try { + if (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..55bb1256c --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/fakeBackend.ts @@ -0,0 +1,79 @@ +// ⚠ 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; +} + +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); + const given_name = data.given_name ?? data.name?.split(' ')[0] ?? ''; + const family_name = data.family_name ?? data.name?.split(' ').slice(1).join(' ') ?? ''; + if (existing) { + // Update profile fields from provider (name/picture may change) + existing.given_name = given_name; + existing.family_name = family_name; + saveUsers(users); + } else { + saveUsers([...users, { given_name, family_name, email: data.email, password: '' }]); + } + return makeJwt({ name: data.name, given_name, family_name, email: data.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..a59b3140b --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/jwtUtil.ts @@ -0,0 +1,8 @@ +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 payload = token.split('.')[1]; + const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))); + 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..2f815526b --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-auth/files/src/app/authentication/services/userStore.ts @@ -0,0 +1,32 @@ +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); + return raw ? JSON.parse(raw) : null; + } 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..bf49a66d1 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/files/src/app/app.css @@ -0,0 +1,103 @@ +.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; + 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..9f2db5f26 --- /dev/null +++ b/packages/cli/templates/react/igr-ts/projects/side-nav-mini-auth/files/src/app/app.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Outlet, useNavigate, useLocation } from 'react-router-dom'; +import { + IgrNavDrawer, + IgrNavDrawerItem, + IgrIcon, + registerIcon, +} from 'igniteui-react'; +import { AuthProvider, useAuth } from './authentication/AuthContext'; +import { LoginBar } from './authentication/components/LoginBar'; +import { routes } from './app-routes'; +import 'igniteui-webcomponents/themes/light/bootstrap.css'; +import './app.css'; + +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/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 97% 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..dc6111b05 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,7 @@ .app__drawer { flex: 0 0 auto; height: 100%; - --ig-nav-drawer-size: 280px; + --menu-full-width: 280px; } igc-nav-drawer-item[active]::part(base) { 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 100% 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 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/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..d13b3fbd0 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/app.ts @@ -0,0 +1,239 @@ +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[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; + } + `; + + 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); + } + + disconnectedCallback() { + this.mediaQuery?.removeEventListener('change', this.updateDrawerState); + window.removeEventListener('popstate', this.updateCurrentPath); + + 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..30a92c389 --- /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,110 @@ +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 { + border: 1px solid rgba(0, 117, 210, 0.35); + background: #fff; + color: #0075d2; + font-size: .875rem; + font-weight: 600; + cursor: pointer; + padding: 10px 20px; + border-radius: 4px; + white-space: nowrap; + text-transform: none; + } + + .login-btn:hover { + background: #e8f3fc; + } + + .profile-avatar { + cursor: pointer; + color: #0075d2; + --ig-avatar-background: #fff; + --ig-avatar-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..19a787bf4 --- /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,278 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { Router } from '@vaadin/router'; +import { defineComponents, IgcDialogComponent, IgcIconComponent } 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(IgcDialogComponent, IgcIconComponent); + +@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; + } + + .form { + display: flex; + flex-flow: column; + gap: 16px; + padding: 4px 0; + } + + .field { + position: relative; + } + + input { + box-sizing: border-box; + width: 100%; + height: 40px; + padding: 0 36px 0 12px; + border: 1px solid #c4c4c4; + border-radius: 4px; + font-size: 1rem; + color: #2d2d2d; + outline: none; + } + + input:focus { + border-color: #239ef0; + } + + .input-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: #0075d2; + --igc-icon-size: 20px; + width: 20px; + height: 20px; + } + + .error { + margin: 0; + font-size: .875rem; + color: #d32f2f; + } + + .submit-btn { + width: 100%; + min-height: 36px; + border: none; + border-radius: 4px; + background: #239ef0; + color: #fff; + font-size: .875rem; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + } + + .submit-btn:hover { + background: #1a8fd8; + } + + .submit-btn:disabled { + background: #e0e0e0; + color: #767676; + cursor: default; + } + + .link-btn { + align-self: center; + border: none; + background: transparent; + color: #0075d2; + font-size: .875rem; + cursor: pointer; + text-decoration: underline; + padding: 0; + } + + .social-login { + display: grid; + gap: 8px; + padding-top: 16px; + border-top: 1px solid #d7d7d7; + } + + .social-btn { + width: 100%; + min-height: 36px; + border: none; + border-radius: 4px; + color: #fff; + font-size: .875rem; + font-weight: 600; + text-transform: uppercase; + cursor: pointer; + transition: filter .15s; + } + + .social-btn:hover { + filter: brightness(0.9); + } + + .google { background: rgb(255, 19, 74); } + .facebook { background: rgb(19, 119, 213); } + .microsoft { 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}

` : ''} + + + ${ExternalAuth.hasProvider() ? html` + + ` : ''} +
+ `; + + const registerForm = html` +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ ${this.error ? html`

${this.error}

` : ''} + + +
+ `; + 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..1f01dd7e3 --- /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; + 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..51cf8eba3 --- /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,33 @@ +// Social login configuration. +// To enable a provider, uncomment its entry and replace the placeholder value(s) 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 { + // TODO: Uncomment and replace with your Google OAuth Client ID (type: Web application) + // google?: { clientId: string }; + + // TODO: Uncomment and replace with your Microsoft Application (client) ID + // tenantId defaults to 'common' (multi-tenant). Set it for single-tenant apps. + // microsoft?: { clientId: string; tenantId?: string }; + + // TODO: Uncomment and replace with your Facebook App ID. + // Also add the Facebook JS SDK to your index.html : + // + // facebook?: { clientId: string }; +} + +// Active OAuth configuration — uncomment provider(s) above to enable social login. +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..289de0c2a --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/externalAuth.ts @@ -0,0 +1,225 @@ +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 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; + +/** + * 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 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: crypto.randomUUID(), + }); + } 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 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: crypto.randomUUID(), + } + ); + } else if (provider === 'facebook') { + const cfg = oauthConfig.facebook!; + FB.init({ appId: cfg.clientId, xfbml: false, version: 'v3.1' }); + 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, + picture: res.picture, + 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.'); + + 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 = JSON.parse(atob(data.id_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + 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 = JSON.parse(atob(data.id_token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + 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') { + // FB.logout is only available when the SDK is loaded and the user is connected. + try { + if (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..03119a437 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/fakeBackend.ts @@ -0,0 +1,79 @@ +// ⚠ 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; +} + +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); + const given_name = data.given_name ?? data.name?.split(' ')[0] ?? ''; + const family_name = data.family_name ?? data.name?.split(' ').slice(1).join(' ') ?? ''; + if (existing) { + // Update profile fields from provider (name/picture may change) + existing.given_name = given_name; + existing.family_name = family_name; + saveUsers(users); + } else { + saveUsers([...users, { given_name, family_name, email: data.email, password: '' }]); + } + return makeJwt({ name: data.name, given_name, family_name, email: data.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..5fc2885d4 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/jwtUtil.ts @@ -0,0 +1,8 @@ +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 payload = token.split('.')[1]; + const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))); + 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..fdffd4547 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/authentication/services/userStore.ts @@ -0,0 +1,32 @@ +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); + return raw ? JSON.parse(raw) : null; + } 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..fd12255cd --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-auth/files/src/app/profile/profile.ts @@ -0,0 +1,153 @@ +import { LitElement, html, css } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { UserStore } from '../authentication/services/userStore.js'; + +@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 64px; + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 50%; + background: #e0f2ff; + color: #0075d2; + font-size: 1.5rem; + font-weight: 700; + overflow: hidden; + } + + .avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .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` +
+
+
+ ${user?.picture + ? html`${user.name}` + : html`${initials}` + } +
+
+

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..169126406 --- /dev/null +++ b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav-mini-auth/files/src/app/app.ts @@ -0,0 +1,255 @@ +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); + } + + disconnectedCallback() { + this.mediaQuery?.removeEventListener('change', this.updateDrawerState); + window.removeEventListener('popstate', this.updateCurrentPath); + 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/files/src/app/app.ts b/packages/cli/templates/webcomponents/igc-ts/projects/side-nav/files/src/app/app.ts index 52aeb7529..f306ab36c 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,7 @@ 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[active]::part(base) { 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..8b4106ab9 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,17 @@ 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 + + // 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-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.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/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..f92ece17d 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,44 @@ -.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 +); + +.navbar-login { + min-height: 36px; + font-weight: 600; + text-transform: none; + 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..f6b925d72 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,35 +1,37 @@
- - - account_circle - - + + + + account_circle + - - - lock - + - + + + lock +
- - 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..0db8e290a 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,69 @@ 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; + 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; } .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 +74,10 @@ button { button.microsoft { background-color: rgb(27, 158, 245); } + + button.google, + button.facebook, + button.microsoft { + color: #fff; + } } 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..eff1500be 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() { @@ -54,7 +53,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..06434eb67 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,27 @@ -

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

+
+
+
+
{{userStore.initials || 'U'}}
+
+

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..3bfdb4403 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,107 @@ +.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 { + display: flex; + flex: 0 0 64px; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 50%; + color: #0075d2; + background: #e0f2ff; + font-size: 1.5rem; + font-weight: 700; +} + +.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/register/register.html b/packages/igx-templates/igx-ts/projects/side-nav-auth/files/src/app/authentication/register/register.html index d6f18be64..3dbf0bee5 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,36 @@ - - - assignment_ind - - + - - - + + assignment_ind - - + + + + + + assignment_ind + - - - alternate_email - - + + + + alternate_email + - - - lock - + - + + + lock + -
- - 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/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/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..7fe6822fd --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.config.ts @@ -0,0 +1,42 @@ +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 + // 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..ab9ae9cbb --- /dev/null +++ b/packages/igx-templates/igx-ts/projects/side-nav-mini-auth/files/src/app/app.spec.ts @@ -0,0 +1,30 @@ +import { TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterModule } from '@angular/router'; +import { IgxNavigationDrawerModule, IgxNavbarModule, IgxRippleModule } from 'igniteui-angular'; +import { App } from './app'; +import { provideAuthentication } from './authentication/provide-authentication'; + +describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + RouterModule.forRoot([]), + IgxNavigationDrawerModule, + IgxNavbarModule, + IgxRippleModule, + 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"]);