diff --git a/.env.example b/.env.example index 7950e5863..db669cda3 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,30 @@ # NEXT_PUBLIC_CONSUME_MARKET_FEE=0.1 # NEXT_PUBLIC_MARKET_FEE_ADDRESS=0x0db00a90deee402256cb1df89f3e14d6b9130fdd # NEXT_PUBLIC_ALLOWED_ERC20_ADDRESSES={"11155111":["0x1B083D8584dd3e6Ff37d04a6e7e82b5F622f3985","0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4", "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"]} + + + +# (Keycloak .env.example) + +# Enable auth +# NEXT_PUBLIC_AUTH_ENABLED=true +# NEXT_PUBLIC_AUTH_PROVIDER=keycloak + +# Keycloak Configuration (provided by DevOps) +# NEXT_PUBLIC_KEYCLOAK_ISSUER=http://localhost:8080/realms/ocean-enterprise +# NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=ocean-market +# NEXT_PUBLIC_KEYCLOAK_CLIENT_SECRET=your-client-secret-here +# NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI=http://localhost:3000/auth/callback + +# NextAuth Secret (for JWT signing) +# NEXTAUTH_SECRET=your-nextauth-secret-here +# NEXTAUTH_URL=http://localhost:3000 + +# for oidc +# Add these to Vercel (Project Settings → Environment Variables) +# NEXT_PUBLIC_AUTH_ENABLED=true +# NEXT_PUBLIC_AUTH_PROVIDER=oidc +# NEXT_PUBLIC_OIDC_ISSUER=http://ocean-node-vm2.oceanenterprise.io:8080/application/o/ocean-market/ +# NEXT_PUBLIC_OIDC_CLIENT_ID=your-client-id-here +# NEXT_PUBLIC_OIDC_CLIENT_SECRET=your-client-secret-here +# NEXT_PUBLIC_OIDC_REDIRECT_URI=https://market-git-feat-aa-auth-ocean-enterprise.vercel.app/auth/callback \ No newline at end of file diff --git a/content/auth/login.json b/content/auth/login.json new file mode 100644 index 000000000..15bc400ed --- /dev/null +++ b/content/auth/login.json @@ -0,0 +1,22 @@ +{ + "title": "Ocean Enterprise Marketplace", + "description": "Discover, publish and manage data, software and AI services with enterprise-grade governance and trusted access control.", + "features": [ + { + "icon": "marketplace", + "text": "Publish and discover service offerings" + }, + { + "icon": "access", + "text": "Control access with verified credentials" + }, + { + "icon": "interop", + "text": "Standardized, interoperable metadata" + }, + { + "icon": "compute", + "text": "Private computation with Compute-to-Data" + } + ] +} diff --git a/src/@hooks/stores/authStore.ts b/src/@hooks/stores/authStore.ts new file mode 100644 index 000000000..4bc8c0913 --- /dev/null +++ b/src/@hooks/stores/authStore.ts @@ -0,0 +1,48 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export interface User { + id: string + email: string + name: string + avatar?: string + organizationId?: string + walletAddress?: string + isOnboarded: boolean + authProvider: 'oidc' +} + +interface AuthState { + user: User | null + isLoading: boolean + isAuthenticated: boolean + isLogoutPending: boolean + setUser: (user: User | null) => void + setLoading: (loading: boolean) => void + setLogoutPending: (isLogoutPending: boolean) => void + logout: () => void + updateUser: (updates: Partial) => void +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + isLoading: false, + isAuthenticated: false, + isLogoutPending: false, + setUser: (user) => set({ user, isAuthenticated: !!user }), + setLoading: (isLoading) => set({ isLoading }), + setLogoutPending: (isLogoutPending) => set({ isLogoutPending }), + logout: () => set({ user: null, isAuthenticated: false }), + updateUser: (updates) => + set((state) => ({ + user: state.user ? { ...state.user, ...updates } : null + })) + }), + { + name: 'ocean-auth-storage', + partialize: (state) => ({ user: state.user }) + } + ) +) diff --git a/src/@hooks/useAuth.ts b/src/@hooks/useAuth.ts new file mode 100644 index 000000000..d8ba5cbb2 --- /dev/null +++ b/src/@hooks/useAuth.ts @@ -0,0 +1,399 @@ +import { useAuthStore, User } from './stores/authStore' +import { useRouter } from 'next/router' +import { toast } from 'react-toastify' +import { authConfig } from '../config/auth.config' +import React from 'react' +import { + clearPendingAuthMode, + clearPendingCallbackUrl, + setPendingAuthMode, + getPendingCallbackUrl, + setPendingCallbackUrl, + type PendingAuthMode +} from '@utils/authFlow' +import { + OIDC_LOGOUT_PENDING_KEY, + OIDC_LOGOUT_RETURN_FALLBACK_MS, + OIDC_LOGOUT_STARTED_AT_KEY, + OIDC_LOGOUT_STATE_KEY +} from '@components/Auth/constants' + +/* ---------------- ENDPOINTS ---------------- */ + +const getEndpoints = (issuer: string) => { + const match = issuer.match(/(.*\/application\/o\/)[^/]+\/?$/) + + const baseUrl = match + ? match[1].replace(/\/$/, '') + : issuer.replace(/\/[^/]+?\/?$/, '') + + return { + authorize: `${baseUrl}/authorize/`, + token: `${baseUrl}/token/`, + endSession: `${issuer.replace(/\/$/, '')}/end-session/` + } +} + +/* ---------------- OIDC ---------------- */ + +class OIDCProvider { + private getConfig() { + return authConfig.oidc + } + + async signup(): Promise { + const config = this.getConfig() + const endpoints = getEndpoints(config.issuer) + + const codeVerifier = this.generateCodeVerifier() + const codeChallenge = await this.generateCodeChallenge(codeVerifier) + + sessionStorage.setItem('oidc_pkce_code_verifier', codeVerifier) + + const authorizeUrl = + `${endpoints.authorize}?` + + `client_id=${config.clientId}&` + + `redirect_uri=${encodeURIComponent(config.redirectUri)}&` + + `response_type=code&` + + `scope=${config.scope}&` + + `code_challenge=${codeChallenge}&` + + `code_challenge_method=S256` + + const flowSlug = 'self-service-registration' + const authentikBase = config.issuer.replace(/\/application\/o\/.*$/, '') + + const signupUrl = + `${authentikBase}/if/flow/${flowSlug}/?` + + `next=${encodeURIComponent(authorizeUrl)}` + + window.location.href = signupUrl + } + + async login(): Promise { + const config = this.getConfig() + const endpoints = getEndpoints(config.issuer) + + const codeVerifier = this.generateCodeVerifier() + const codeChallenge = await this.generateCodeChallenge(codeVerifier) + + sessionStorage.setItem('oidc_pkce_code_verifier', codeVerifier) + + const authUrl = + `${endpoints.authorize}?` + + `client_id=${config.clientId}&` + + `redirect_uri=${encodeURIComponent(config.redirectUri)}&` + + `response_type=code&` + + `scope=${config.scope}&` + + `code_challenge=${codeChallenge}&` + + `code_challenge_method=S256` + + window.location.href = authUrl + } + + async logout(): Promise { + try { + const config = this.getConfig() + const endpoints = getEndpoints(config.issuer) + + const redirectUri = encodeURIComponent( + `${window.location.origin}/auth/login` + ) + + let idTokenHint = '' + const tokens = localStorage.getItem('oidc_tokens') + + if (tokens) { + try { + const parsed = JSON.parse(tokens) + if (parsed.id_token) { + idTokenHint = `&id_token_hint=${encodeURIComponent( + parsed.id_token + )}` + } + } catch (e) { + console.warn('Could not parse tokens', e) + } + } + + const state = Math.random().toString(36).substring(2) + sessionStorage.setItem(OIDC_LOGOUT_STATE_KEY, state) + + const logoutUrl = + `${endpoints.endSession}?` + + `client_id=${config.clientId}&` + + `post_logout_redirect_uri=${redirectUri}&` + + `state=${state}` + + idTokenHint + + sessionStorage.setItem(OIDC_LOGOUT_PENDING_KEY, 'true') + sessionStorage.setItem(OIDC_LOGOUT_STARTED_AT_KEY, Date.now().toString()) + window.location.href = logoutUrl + } catch (err) { + console.error('Logout error:', err) + toast.error('Logout failed') + throw err + } + } + + private generateCodeVerifier(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('') + } + + private async generateCodeChallenge(verifier: string): Promise { + const hash = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(verifier) + ) + + return btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + } +} +const oidcProvider = new OIDCProvider() + +const clearOidcStorage = () => { + localStorage.removeItem('oidc_session') + localStorage.removeItem('oidc_tokens') + sessionStorage.removeItem('oidc_pkce_code_verifier') + sessionStorage.removeItem('oidc_processing') + sessionStorage.removeItem(OIDC_LOGOUT_STATE_KEY) + sessionStorage.removeItem(OIDC_LOGOUT_PENDING_KEY) + sessionStorage.removeItem(OIDC_LOGOUT_STARTED_AT_KEY) + clearPendingAuthMode() + clearPendingCallbackUrl() +} + +const isOidcLogoutPending = () => + typeof window !== 'undefined' && + sessionStorage.getItem(OIDC_LOGOUT_PENDING_KEY) === 'true' + +const hasOidcLogoutReturnState = (returnState: string | null) => + typeof window !== 'undefined' && + Boolean( + returnState && sessionStorage.getItem(OIDC_LOGOUT_STATE_KEY) === returnState + ) + +/* ---------------- HOOK ---------------- */ + +export const useAuth = () => { + const { + user, + isLoading, + isLogoutPending, + setUser, + setLoading, + setLogoutPending, + logout: storeLogout + } = useAuthStore() + + const authEnabled = authConfig.enabled + + const router = useRouter() + const logoutReturnState = + typeof router.query.state === 'string' ? router.query.state : null + /* -------- Handle Logout Return -------- */ + React.useEffect(() => { + if (!router.isReady) return + if (isOidcLogoutPending()) { + const completeOidcLogoutReturn = () => { + if (!isOidcLogoutPending()) return + + clearOidcStorage() + setLogoutPending(false) + storeLogout() + + if (window.location.pathname !== '/auth/login') { + router.replace('/auth/login') + return + } + + if (!logoutReturnState) return + + const nextQuery = { ...router.query } + delete nextQuery.state + + router.replace( + { + pathname: '/auth/login', + query: nextQuery + }, + undefined, + { shallow: true } + ) + } + + if ( + window.location.pathname !== '/auth/login' || + hasOidcLogoutReturnState(logoutReturnState) + ) { + completeOidcLogoutReturn() + return + } + + const timeoutId = window.setTimeout( + completeOidcLogoutReturn, + OIDC_LOGOUT_RETURN_FALLBACK_MS + ) + + return () => window.clearTimeout(timeoutId) + } + }, [logoutReturnState, router, setLogoutPending, storeLogout]) + + /* -------- Restore Session -------- */ + + React.useEffect(() => { + if (isOidcLogoutPending()) return + + try { + const session = localStorage.getItem('oidc_session') + if (session) setUser(JSON.parse(session)) + } catch {} + }, [setUser]) + + /* -------- Callback -------- */ + + const handleOIDCCallback = React.useCallback( + async (code: string) => { + if (sessionStorage.getItem('oidc_processing')) return + + sessionStorage.setItem('oidc_processing', 'true') + + try { + const config = authConfig.oidc + const endpoints = getEndpoints(config.issuer) + + const res = await fetch(endpoints.token, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: config.clientId, + client_secret: config.clientSecret || '', + code, + redirect_uri: config.redirectUri, + code_verifier: + sessionStorage.getItem('oidc_pkce_code_verifier') || '' + }) + }) + + if (!res.ok) { + throw new Error(await res.text()) + } + + const tokens = await res.json() + const payload = JSON.parse(atob(tokens.id_token.split('.')[1])) + + const userData: User = { + id: payload.sub, + email: payload.email, + name: payload.name, + avatar: `https://ui-avatars.com/api/?name=${payload.name}`, + isOnboarded: false, + authProvider: 'oidc' + } + + localStorage.setItem('oidc_session', JSON.stringify(userData)) + localStorage.setItem('oidc_tokens', JSON.stringify(tokens)) + const callbackUrl = getPendingCallbackUrl() + clearPendingCallbackUrl() + + setUser(userData) + router.replace({ + pathname: '/auth/login', + ...(callbackUrl ? { query: { callbackUrl } } : {}) + }) + } catch (err) { + console.error('Callback error:', err) + clearPendingAuthMode() + clearPendingCallbackUrl() + toast.error('Login failed') + router.replace('/auth/login') + } finally { + sessionStorage.removeItem('oidc_processing') + } + }, + [router, setUser] + ) + + const checkSession = React.useCallback(async () => { + const code = new URLSearchParams(window.location.search).get('code') + if (code) await handleOIDCCallback(code) + }, [handleOIDCCallback]) + + /* -------- Login -------- */ + + const login = async (mode: PendingAuthMode = 'login') => { + setLoading(true) + try { + if (mode === 'signup') { + await oidcProvider.signup() + } else { + await oidcProvider.login() + } + } finally { + setLoading(false) + } + } + + const beginOidcFlow = async (mode: PendingAuthMode) => { + const callbackUrl = + typeof router.query.callbackUrl === 'string' + ? router.query.callbackUrl + : null + + setPendingAuthMode(mode) + + if (callbackUrl) { + setPendingCallbackUrl(callbackUrl) + } else { + clearPendingCallbackUrl() + } + + await login(mode) + } + + /* -------- Logout -------- */ + + const logout = async () => { + setLoading(true) + + try { + if (user?.authProvider === 'oidc') { + setLogoutPending(true) + localStorage.removeItem('oidc_session') + clearPendingAuthMode() + clearPendingCallbackUrl() + storeLogout() + await oidcProvider.logout() + return + } + + clearOidcStorage() + setLogoutPending(false) + storeLogout() + + router.replace('/auth/login') + } catch (error) { + setLogoutPending(false) + console.error('Logout flow failed:', error) + } finally { + setLoading(false) + } + } + + return { + user, + isLoading, + isLogoutPending, + isAuthenticated: !!user, + login, + beginOidcFlow, + logout, + checkSession, + authEnabled + } +} diff --git a/src/@hooks/useSessionPersistence.ts b/src/@hooks/useSessionPersistence.ts new file mode 100644 index 000000000..094ce974c --- /dev/null +++ b/src/@hooks/useSessionPersistence.ts @@ -0,0 +1,86 @@ +import { useEffect } from 'react' +import { useAuth } from './useAuth' +import { authConfig } from '../config/auth.config' + +const getEndpoints = (issuer: string) => { + const isAuthentik = issuer.includes('/application/o/') + const isKeycloak = issuer.includes('/realms/') + + let baseUrl: string + if (isAuthentik) { + const match = issuer.match(/(.*\/application\/o\/)[^/]+\/?$/) + baseUrl = match + ? match[1].replace(/\/$/, '') + : issuer.replace(/\/[^/]+?\/?$/, '') + } else { + baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer + } + + return { + token: `${baseUrl}/token/`, + isKeycloak, + isAuthentik + } +} + +export function useSessionPersistence() { + const { user, logout } = useAuth() + + const refreshToken = async (refreshTokenString: string) => { + try { + const config = authConfig.oidc + const endpoints = getEndpoints(config.issuer) + const tokenEndpoint = endpoints.token + + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: config.clientId, + client_secret: config.clientSecret || '', + refresh_token: refreshTokenString + }) + }) + + if (response.ok) { + const newTokens = await response.json() + localStorage.setItem('oidc_tokens', JSON.stringify(newTokens)) + } else { + console.error('Token refresh failed, logging out') + logout() + } + } catch (error) { + console.error('Token refresh error:', error) + } + } + + useEffect(() => { + if (!user) { + return + } + + const interval = setInterval(() => { + const tokens = localStorage.getItem('oidc_tokens') + if (tokens) { + try { + const tokenData = JSON.parse(tokens) + const expiresIn = tokenData.expires_in + const refreshTokenValue = tokenData.refresh_token + + if (expiresIn && expiresIn < 60 && refreshTokenValue) { + refreshToken(refreshTokenValue) + } + } catch (e) { + console.error('Session check error:', e) + } + } + }, 30000) + + return () => clearInterval(interval) + }, [user, logout]) + + return null +} diff --git a/src/@hooks/useSsiAutoConnectPrompt.ts b/src/@hooks/useSsiAutoConnectPrompt.ts index 8d0c1507a..d7d8f4b19 100644 --- a/src/@hooks/useSsiAutoConnectPrompt.ts +++ b/src/@hooks/useSsiAutoConnectPrompt.ts @@ -1,16 +1,20 @@ import { useEffect } from 'react' +import { useRouter } from 'next/router' import { useAccount } from 'wagmi' import appConfig from 'app.config.cjs' import { useSsiWallet } from '@context/SsiWallet' import { useEthersSigner } from './useEthersSigner' import useSsiAllowedChain from './useSsiAllowedChain' import { useUserPreferences } from '@context/UserPreferences' +import { useAuth } from './useAuth' export default function useSsiAutoConnectPrompt(): void { + const router = useRouter() const { isConnected } = useAccount() const { isSsiChainAllowed, isSsiChainReady } = useSsiAllowedChain() const walletClient = useEthersSigner() const { setShowSsiWalletModule } = useUserPreferences() + const { isAuthenticated } = useAuth() const { sessionToken, isSsiStateHydrated, @@ -22,23 +26,38 @@ export default function useSsiAutoConnectPrompt(): void { if (!appConfig.ssiEnabled) return if (!isSsiStateHydrated) return + const isAuthRoute = router.asPath.split('?')[0].startsWith('/auth/') + if (isAuthRoute) { + resetSsiAutoConnectLock() + setShowSsiWalletModule(false) + return + } + if (!isConnected || !isSsiChainReady || !isSsiChainAllowed) { resetSsiAutoConnectLock() setShowSsiWalletModule(false) return } + if (!isAuthenticated) { + resetSsiAutoConnectLock() + setShowSsiWalletModule(false) + return + } + if (!walletClient || sessionToken) return if (!tryAcquireSsiAutoConnectLock()) return setShowSsiWalletModule(true) }, [ isConnected, + isAuthenticated, isSsiChainAllowed, isSsiChainReady, walletClient, sessionToken, isSsiStateHydrated, + router.asPath, tryAcquireSsiAutoConnectLock, resetSsiAutoConnectLock, setShowSsiWalletModule diff --git a/src/@hooks/useSsiConnect.ts b/src/@hooks/useSsiConnect.ts new file mode 100644 index 000000000..0cdf93ba8 --- /dev/null +++ b/src/@hooks/useSsiConnect.ts @@ -0,0 +1,114 @@ +import { useCallback } from 'react' +import { LoggerInstance } from '@oceanprotocol/lib' +import { toast } from 'react-toastify' +import { useEthersSigner } from '@hooks/useEthersSigner' +import { useSsiWallet } from '@context/SsiWallet' +import { useUserPreferences } from '@context/UserPreferences' +import { + connectToWallet, + getWalletKeys, + getWallets, + setSsiWalletApiOverride +} from '@utils/wallet/ssiWallet' +import { SsiWalletDesc, SsiWalletSession } from 'src/@types/SsiWallet' + +interface ConnectSsiOptions { + apiOverride?: string +} + +export default function useSsiConnect() { + const walletClient = useEthersSigner() + const { setShowSsiWalletModule } = useUserPreferences() + const { + setSessionToken, + ssiWalletCache, + setCachedCredentials, + clearVerifierSessionCache, + setIsSsiSessionHydrating, + selectedWallet, + setSelectedWallet, + setSelectedKey, + setSelectedDid + } = useSsiWallet() + + const fetchWallets = useCallback( + async (session: SsiWalletSession) => { + try { + if (!session) return selectedWallet + const wallets = await getWallets(session.token) + setSelectedWallet(wallets[0]) + return wallets[0] + } catch (error) { + LoggerInstance.error(error) + return selectedWallet + } + }, + [selectedWallet, setSelectedWallet] + ) + + const fetchKeys = useCallback( + async (wallet: SsiWalletDesc, session: SsiWalletSession) => { + if (!wallet || !session) return + try { + const keys = await getWalletKeys(wallet, session.token) + setSelectedKey(keys[0]) + } catch (error) { + LoggerInstance.error(error) + } + }, + [setSelectedKey] + ) + + const connectSsi = useCallback( + async ({ apiOverride }: ConnectSsiOptions = {}): Promise => { + setIsSsiSessionHydrating(true) + + try { + if (!walletClient) { + toast.error('Connect your wallet before starting SSI setup.') + return false + } + + ssiWalletCache.clearCredentials() + setCachedCredentials([]) + clearVerifierSessionCache() + + if (apiOverride) { + setSsiWalletApiOverride(apiOverride) + } + + const session = await connectToWallet(walletClient) + setSessionToken(session) + setSelectedDid(undefined) + setSelectedKey(undefined) + const wallet = await fetchWallets(session) + await fetchKeys(wallet, session) + setShowSsiWalletModule(false) + return true + } catch (error) { + LoggerInstance.error(error) + const message = + error instanceof Error ? error.message : 'SSI connection failed' + toast.error(message) + return false + } finally { + setIsSsiSessionHydrating(false) + } + }, + [ + walletClient, + ssiWalletCache, + setCachedCredentials, + clearVerifierSessionCache, + setSessionToken, + setSelectedDid, + setSelectedKey, + fetchWallets, + fetchKeys, + setShowSsiWalletModule, + setIsSsiSessionHydrating + ] + ) + + return { connectSsi } +} diff --git a/src/@images/logout.svg b/src/@images/logout.svg new file mode 100644 index 000000000..fa7a71cdc --- /dev/null +++ b/src/@images/logout.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/@utils/authFlow.ts b/src/@utils/authFlow.ts new file mode 100644 index 000000000..f78a6d6f6 --- /dev/null +++ b/src/@utils/authFlow.ts @@ -0,0 +1,37 @@ +export type PendingAuthMode = 'login' | 'signup' + +const pendingCallbackUrlStorageKey = 'auth_callback_url' +const pendingAuthModeStorageKey = 'auth_mode' + +export function getPendingCallbackUrl(): string | null { + if (typeof window === 'undefined') return null + + return sessionStorage.getItem(pendingCallbackUrlStorageKey) +} + +export function setPendingCallbackUrl(url: string): void { + if (typeof window === 'undefined') return + sessionStorage.setItem(pendingCallbackUrlStorageKey, url) +} + +export function clearPendingCallbackUrl(): void { + if (typeof window === 'undefined') return + sessionStorage.removeItem(pendingCallbackUrlStorageKey) +} + +export function getPendingAuthMode(): PendingAuthMode | null { + if (typeof window === 'undefined') return null + + const mode = sessionStorage.getItem(pendingAuthModeStorageKey) + return mode === 'login' || mode === 'signup' ? mode : null +} + +export function setPendingAuthMode(mode: PendingAuthMode): void { + if (typeof window === 'undefined') return + sessionStorage.setItem(pendingAuthModeStorageKey, mode) +} + +export function clearPendingAuthMode(): void { + if (typeof window === 'undefined') return + sessionStorage.removeItem(pendingAuthModeStorageKey) +} diff --git a/src/@utils/authProvider.tsx b/src/@utils/authProvider.tsx new file mode 100644 index 000000000..d2507d40d --- /dev/null +++ b/src/@utils/authProvider.tsx @@ -0,0 +1,11 @@ +import { ReactNode } from 'react' +import { useSessionPersistence } from '@hooks/useSessionPersistence' + +interface AuthProviderProps { + children: ReactNode +} + +export function AuthProvider({ children }: AuthProviderProps) { + useSessionPersistence() + return <>{children} +} diff --git a/src/@utils/wallet/ssiWallet.ts b/src/@utils/wallet/ssiWallet.ts index 2312cc69c..b66a1bea4 100644 --- a/src/@utils/wallet/ssiWallet.ts +++ b/src/@utils/wallet/ssiWallet.ts @@ -22,6 +22,38 @@ export function getSsiWalletApi(): string { return override || appConfig.ssiWalletApi } +function isWalletActionRejected(error: any): boolean { + const code = error?.code ?? error?.info?.error?.code + const message = String( + error?.shortMessage || + error?.message || + error?.info?.error?.message || + error?.reason || + '' + ).toLowerCase() + + return ( + code === 4001 || + code === 'ACTION_REJECTED' || + message.includes('user rejected') || + message.includes('rejected the request') || + message.includes('action_rejected') || + message.includes('ethers-user-denied') + ) +} + +function getSsiConnectErrorMessage(error: any): string { + if (isWalletActionRejected(error)) { + return 'SSI connection was cancelled in your wallet.' + } + + return ( + error?.response?.data?.message || + error?.message || + 'Failed to connect to SSI wallet' + ) +} + export async function connectToWallet( owner: JsonRpcSigner ): Promise { @@ -63,11 +95,7 @@ export async function connectToWallet( return authResponse.data as SsiWalletSession } catch (error: any) { LoggerInstance.error('SSI connectToWallet failed:', error) - throw new Error( - error?.response?.data?.message || - error?.message || - 'Failed to connect to SSI wallet' - ) + throw new Error(getSsiConnectErrorMessage(error)) } } diff --git a/src/components/@shared/AddToken/index.module.css b/src/components/@shared/AddToken/index.module.css index 99caa3c70..90cb35def 100644 --- a/src/components/@shared/AddToken/index.module.css +++ b/src/components/@shared/AddToken/index.module.css @@ -119,6 +119,25 @@ left: 100%; } +.disabled .logo { + border-color: var(--ink-alpha-15); + background-color: var(--ink-alpha-5); + box-shadow: none; +} + +.disabled .logoWrap::before { + color: var(--ink-alpha-20); +} + +.disabled .text { + background-image: linear-gradient( + 90deg, + var(--ink-alpha-40) 0%, + var(--ink-alpha-40) 100% + ); + background-size: 100% 100%; +} + @media (prefers-reduced-motion: reduce) { .logo, .logoWrap::before, diff --git a/src/components/@shared/AddToken/index.tsx b/src/components/@shared/AddToken/index.tsx index 8dcc3a325..73d135a5c 100644 --- a/src/components/@shared/AddToken/index.tsx +++ b/src/components/@shared/AddToken/index.tsx @@ -18,6 +18,7 @@ interface AddTokenProps { text?: string className?: string minimal?: boolean + disabled?: boolean } export default function AddToken({ @@ -27,15 +28,18 @@ export default function AddToken({ logo, text, className, - minimal + minimal, + disabled = false }: AddTokenProps): ReactElement { const styleClasses = cx({ button: true, minimal, + disabled, [className]: className }) async function handleAddToken() { + if (disabled) return if (!window?.ethereum) return await addTokenToWallet(address, symbol, decimals, logo?.url) @@ -48,6 +52,7 @@ export default function AddToken({ className={styleClasses} style="text" size="small" + disabled={disabled} onClick={handleAddToken} > diff --git a/src/components/@shared/Page/index.tsx b/src/components/@shared/Page/index.tsx index 20ba33504..2f8c3e19b 100644 --- a/src/components/@shared/Page/index.tsx +++ b/src/components/@shared/Page/index.tsx @@ -12,6 +12,7 @@ interface PageProps { description?: string noPageHeader?: boolean headerCenter?: boolean + fullWidth?: boolean } export default function Page({ @@ -20,13 +21,17 @@ export default function Page({ uri, description, noPageHeader, - headerCenter + headerCenter, + fullWidth }: PageProps): ReactElement { const { allowExternalContent } = useUserPreferences() const isHome = uri === '/' // const isSearchPage = uri.startsWith('/search') const isAssetPage = uri.startsWith('/asset') + const mainStyle = fullWidth + ? { display: 'flex', flex: 1, minHeight: 0 } + : undefined return ( <> @@ -47,8 +52,11 @@ export default function Page({ )} {/* Main content - full width for home, contained for others */} -
- {isHome ? children : {children}} +
+ {isHome || fullWidth ? children : {children}}
) diff --git a/src/components/App/index.module.css b/src/components/App/index.module.css index 5fc8ff94f..442777f6a 100644 --- a/src/components/App/index.module.css +++ b/src/components/App/index.module.css @@ -12,10 +12,10 @@ } .main { - padding: 0 0 calc(var(--spacer) * 2) 0; - /* sticky footer technique */ flex: 1; + display: flex; + flex-direction: column; } .main > div[class*='Alert']:first-child { diff --git a/src/components/Auth/AuthGuard/AuthGuard.tsx b/src/components/Auth/AuthGuard/AuthGuard.tsx new file mode 100644 index 000000000..5611fcbf7 --- /dev/null +++ b/src/components/Auth/AuthGuard/AuthGuard.tsx @@ -0,0 +1,83 @@ +import { useEffect, ReactNode } from 'react' +import { useRouter } from 'next/router' +import { useAuth } from '@hooks/useAuth' + +interface AuthGuardProps { + children: ReactNode +} + +export default function AuthGuard({ children }: AuthGuardProps) { + const { isAuthenticated, isLoading, authEnabled } = useAuth() + const router = useRouter() + + const isPublicRoute = (): boolean => { + const path = router.asPath.split('?')[0] + + const exactPublicPaths = [ + '/', + '/auth/login', + '/auth/callback', + '/about', + '/terms', + '/privacy', + '/imprint', + '/cookie-settings' + ] + + if (exactPublicPaths.includes(path)) { + return true + } + + if (path.startsWith('/privacy/')) { + return true + } + + if (path.startsWith('/auth/')) { + return true + } + + return false + } + + const isPublic = isPublicRoute() + const shouldRedirectToLogin = + authEnabled && !isLoading && !isAuthenticated && !isPublic + + useEffect(() => { + if (shouldRedirectToLogin) { + router.replace( + `/auth/login?callbackUrl=${encodeURIComponent(router.asPath)}` + ) + } + }, [router, router.asPath, shouldRedirectToLogin]) + + if (!authEnabled) { + return <>{children} + } + + if ((isLoading && !isPublic) || shouldRedirectToLogin) { + return ( +
+
+
+ ) + } + + return <>{children} +} diff --git a/src/components/Auth/AuthLayout/BrandPanel.module.css b/src/components/Auth/AuthLayout/BrandPanel.module.css new file mode 100644 index 000000000..1ebc88d70 --- /dev/null +++ b/src/components/Auth/AuthLayout/BrandPanel.module.css @@ -0,0 +1,336 @@ +/* ── Full-bleed brand panel ── */ +.brandPanel { + flex: 1 1 50%; + display: flex; + flex-direction: column; + justify-content: space-between; + background: radial-gradient( + circle at 50% 18%, + rgba(97, 165, 194, 0.2) 0%, + rgba(97, 165, 194, 0) 30% + ), + linear-gradient(180deg, #24456c 0%, #165781 38%, #1b4d73 68%, #233a5d 100%); + position: relative; + overflow: hidden; + padding: 40px 36px; +} + +/* ── Decorative waves ── */ +.waves { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 200px; + z-index: 0; + pointer-events: none; +} + +.wavesSvg { + width: 100%; + height: 100%; +} + +/* ── Content wrapper (max-width constrained so text stays readable on ultrawide) ── */ +.content { + position: relative; + z-index: 1; + width: 100%; + max-width: 450px; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 450px; +} + +.top { + margin-bottom: 40px; +} + +.middle { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +.bottom { + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +/* ── Logo ── */ +.logoWrapper svg { + width: 140px; + height: auto; + filter: brightness(0) invert(1); +} + +/* ── Typography ── */ +.title { + font-size: 2rem; + font-weight: 700; + color: white; + margin-bottom: 14px; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.description { + font-size: 0.95rem; + color: rgba(255, 255, 255, 0.65); + margin-bottom: 28px; + line-height: 1.6; +} + +/* ── Feature list ── */ +.features { + display: flex; + flex-direction: column; + gap: 14px; +} + +.feature { + display: flex; + align-items: center; + gap: 12px; +} + +.featureIcon { + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.08); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: rgba(255, 255, 255, 0.7); +} + +.featureText { + color: rgba(255, 255, 255, 0.8); + font-size: 0.9rem; + line-height: 1.4; +} + +/* ── Trust badges ── */ +.trustLabel { + color: rgba(255, 255, 255, 0.62); + font-size: 0.65rem; + margin-bottom: 10px; + text-transform: uppercase; + letter-spacing: 1.5px; +} + +.badges { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.badge { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 20px; + padding: 4px 12px; + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.72); + letter-spacing: 0.3px; +} + +/* ── 4K ── */ +@media (min-width: 2560px) { + .brandPanel { + padding: 72px 56px; + } + + .content { + max-width: 560px; + min-height: 640px; + } + + .logoWrapper svg { + width: 180px; + } + + .title { + font-size: 3rem; + margin-bottom: 20px; + } + + .description { + font-size: 1.1rem; + margin-bottom: 40px; + } + + .features { + gap: 20px; + } + + .featureText { + font-size: 1rem; + } + + .top { + margin-bottom: 56px; + } +} + +/* ── Large desktop ── */ +@media (min-width: 1440px) and (max-width: 2559px) { + .brandPanel { + padding: 42px 40px; + } + + .content { + max-width: 480px; + min-height: 520px; + } + + .title { + font-size: 2.25rem; + } + + .description { + font-size: 1rem; + } +} + +/* ── Standard desktop ── */ +@media (min-width: 1025px) and (max-width: 1439px) { + .brandPanel { + padding: 30px; + } + + .content { + max-width: 410px; + min-height: 430px; + } + + .logoWrapper svg { + width: 120px; + } + + .title { + font-size: 1.75rem; + margin-bottom: 12px; + } + + .description { + font-size: 0.9rem; + margin-bottom: 24px; + } + + .features { + gap: 12px; + } + + .top { + margin-bottom: 32px; + } +} + +/* ── Tablet ── */ +@media (min-width: 769px) and (max-width: 1024px) { + .brandPanel { + flex: none; + width: 100%; + padding: 40px 32px; + } + + .content { + max-width: 100%; + min-height: auto; + } + + .title { + font-size: 2rem; + } + + .bottom { + margin-top: 28px; + } +} + +/* ── Mobile landscape ── */ +@media (min-width: 481px) and (max-width: 768px) { + .brandPanel { + flex: none; + width: 100%; + padding: 32px 24px; + } + + .content { + max-width: 100%; + min-height: auto; + } + + .title { + font-size: 1.75rem; + } + + .description { + font-size: 0.9rem; + margin-bottom: 24px; + } + + .top { + margin-bottom: 28px; + } + + .bottom { + margin-top: 24px; + } +} + +/* ── Mobile ── */ +@media (max-width: 480px) { + .brandPanel { + flex: none; + width: 100%; + padding: 28px 20px; + } + + .content { + max-width: 100%; + min-height: auto; + } + + .logoWrapper svg { + width: 110px; + } + + .title { + font-size: 1.5rem; + margin-bottom: 10px; + } + + .description { + font-size: 0.85rem; + margin-bottom: 20px; + } + + .features { + gap: 10px; + } + + .featureText { + font-size: 0.85rem; + } + + .top { + margin-bottom: 24px; + } + + .bottom { + margin-top: 20px; + padding-top: 20px; + } + + .badge { + font-size: 0.6rem; + padding: 3px 10px; + } +} diff --git a/src/components/Auth/AuthLayout/BrandPanel.tsx b/src/components/Auth/AuthLayout/BrandPanel.tsx new file mode 100644 index 000000000..70cc521c0 --- /dev/null +++ b/src/components/Auth/AuthLayout/BrandPanel.tsx @@ -0,0 +1,62 @@ +import Logo from '@images/logo.svg' +import { authBrandDefaults, type AuthPanelContent } from '../constants' +import { BrandPanelIcon, BrandPanelWaves } from './BrandPanelArtwork' +import styles from './BrandPanel.module.css' + +interface BrandPanelProps { + content: AuthPanelContent +} + +export default function BrandPanel({ content }: BrandPanelProps) { + const title = content.title || authBrandDefaults.title + const description = content.description || authBrandDefaults.description + const featureItems = content.features || authBrandDefaults.features + + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+

{title}

+ +

{description}

+ +
+ {featureItems.map((feature, index) => ( +
+
+ +
+ {feature.text} +
+ ))} +
+
+ +
+

{authBrandDefaults.trustLabel}

+
+ {authBrandDefaults.trustBadges.map((badge) => ( + + {badge} + + ))} +
+
+
+
+ ) +} diff --git a/src/components/Auth/AuthLayout/BrandPanelArtwork.tsx b/src/components/Auth/AuthLayout/BrandPanelArtwork.tsx new file mode 100644 index 000000000..4d9f16033 --- /dev/null +++ b/src/components/Auth/AuthLayout/BrandPanelArtwork.tsx @@ -0,0 +1,140 @@ +import type { SVGProps } from 'react' + +export type BrandPanelIconVariant = + | 'marketplace' + | 'access' + | 'interop' + | 'compute' + +interface BrandPanelIconProps extends SVGProps { + variant: BrandPanelIconVariant +} + +export function BrandPanelIcon({ variant, ...props }: BrandPanelIconProps) { + switch (variant) { + case 'marketplace': + return ( + + + + + + ) + case 'access': + return ( + + + + + ) + case 'interop': + return ( + + + + + + + ) + case 'compute': + return ( + + + + + ) + } +} + +export function BrandPanelWaves(props: SVGProps) { + return ( + + + + + + ) +} diff --git a/src/components/Auth/AuthLayout/LogoutPanel.module.css b/src/components/Auth/AuthLayout/LogoutPanel.module.css new file mode 100644 index 000000000..9e8c51d83 --- /dev/null +++ b/src/components/Auth/AuthLayout/LogoutPanel.module.css @@ -0,0 +1,62 @@ +.panel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 240px; + gap: 20px; + text-align: center; +} + +.header { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; +} + +.title { + margin: 0; + font-size: 22px; + line-height: 1.15; + font-weight: 700; + color: #1a2c3e; +} + +.subtitle { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: #5a6874; +} + +.loaderWrap { + display: flex; + align-items: center; + justify-content: center; +} + +.loader { + min-height: 0; +} + +.waiting { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: #64748b; +} + +@media (max-width: 768px) { + .panel { + min-height: 200px; + } + + .title { + font-size: 20px; + } + + .subtitle { + font-size: 12px; + } +} diff --git a/src/components/Auth/AuthLayout/LogoutPanel.tsx b/src/components/Auth/AuthLayout/LogoutPanel.tsx new file mode 100644 index 000000000..e10adbc3d --- /dev/null +++ b/src/components/Auth/AuthLayout/LogoutPanel.tsx @@ -0,0 +1,20 @@ +import Loader from '@shared/atoms/Loader' +import { authLogoutCopy } from '../constants' +import styles from './LogoutPanel.module.css' + +export default function LogoutPanel() { + return ( +
+
+

{authLogoutCopy.title}

+

{authLogoutCopy.subtitle}

+
+ +
+ +
+ +

{authLogoutCopy.waiting}

+
+ ) +} diff --git a/src/components/Auth/AuthLayout/SetupPanel.module.css b/src/components/Auth/AuthLayout/SetupPanel.module.css new file mode 100644 index 000000000..2ad9ac541 --- /dev/null +++ b/src/components/Auth/AuthLayout/SetupPanel.module.css @@ -0,0 +1,268 @@ +.panel { + display: flex; + flex-direction: column; + gap: 24px; +} + +.header { + display: flex; + flex-direction: column; + gap: 8px; + text-align: center; +} + +.title { + margin: 0; + font-size: 22px; + line-height: 1.15; + font-weight: 700; + color: #1a2c3e; +} + +.subtitle { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: #5a6874; +} + +.accountSwitch { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.5; + color: #5a6874; +} + +.accountSwitchButton { + border: none; + padding: 0; + background: transparent; + font: inherit; + font-weight: 700; + color: #0a4b70; + cursor: pointer; +} + +.accountSwitchButton:hover { + text-decoration: underline; +} + +.progressCard { + display: flex; + flex-direction: column; + gap: 0; + padding: 24px 24px 8px; + border: 1px solid #e2e8f0; + border-radius: 20px; + background: radial-gradient( + circle at top right, + rgba(10, 75, 112, 0.08), + transparent 34% + ), + linear-gradient( + 180deg, + rgba(248, 250, 252, 0.95), + rgba(255, 255, 255, 0.98) + ); +} + +.step { + display: grid; + grid-template-columns: 30px 1fr; + gap: 16px; + min-height: 96px; +} + +.stepLast { + min-height: 0; +} + +.stepRail { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 10px; +} + +.stepMarker { + position: relative; + width: 16px; + height: 16px; + border-radius: 50%; + flex-shrink: 0; +} + +.stepMarkerPending { + border: 2px solid #cbd5e1; + background: white; +} + +.stepMarkerActive { + border: 2px solid #0a4b70; + background: rgba(10, 75, 112, 0.16); + box-shadow: 0 0 0 10px rgba(10, 75, 112, 0.08); + animation: pulse 1.8s ease-in-out infinite; +} + +.stepMarkerComplete { + border: none; + background: var(--gradient-primary); +} + +.stepMarkerComplete::after { + content: ''; + position: absolute; + left: 6px; + top: 3px; + width: 4px; + height: 8px; + border-right: 2px solid white; + border-bottom: 2px solid white; + transform: rotate(45deg); +} + +.stepLine { + width: 2px; + flex: 1; + margin-top: 8px; + border-radius: 999px; + background: linear-gradient( + 180deg, + rgba(10, 75, 112, 0.28), + rgba(203, 213, 225, 0.4) + ); +} + +.stepBody { + padding-bottom: 20px; +} + +.stepTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.stepTitle { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + color: #223655; +} + +.stepBadge { + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.stepBadgePending { + background: #f8fafc; + color: #64748b; +} + +.stepBadgeActive { + background: rgba(10, 75, 112, 0.12); + color: #0a4b70; +} + +.stepBadgeComplete { + background: rgba(22, 163, 74, 0.12); + color: #15803d; +} + +.stepDescription { + margin: 0; + font-size: 0.95rem; + line-height: 1.6; + color: #64748b; +} + +.footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; +} + +.actionButton { + min-width: 220px; + border: none; + border-radius: 999px; + padding: 14px 20px; + font-size: 15px; + font-weight: 700; + color: white; + background: var(--gradient-primary); + box-shadow: 1px 2px 8px var(--box-shadow-color); + cursor: pointer; + transition: transform 0.2s ease, opacity 0.2s ease; +} + +.actionButton:hover { + transform: translateY(-1px); + opacity: 0.96; +} + +.actionButton:disabled { + cursor: default; + opacity: 0.72; + transform: none; +} + +.readyState { + display: inline-flex; + align-items: center; + gap: 10px; + border-radius: 999px; + padding: 12px 16px; + background: rgba(22, 163, 74, 0.08); + color: #15803d; + font-size: 0.95rem; + font-weight: 700; +} + +.readyDot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #16a34a; + box-shadow: 0 0 0 8px rgba(22, 163, 74, 0.12); +} + +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(10, 75, 112, 0.18); + } + 50% { + box-shadow: 0 0 0 10px rgba(10, 75, 112, 0.04); + } +} + +@media (max-width: 768px) { + .title { + font-size: 20px; + } + + .subtitle { + font-size: 12px; + } + + .progressCard { + padding: 20px 18px 0; + } + + .stepTitleRow { + flex-direction: column; + align-items: flex-start; + } + + .actionButton { + width: 100%; + } +} diff --git a/src/components/Auth/AuthLayout/SetupPanel.tsx b/src/components/Auth/AuthLayout/SetupPanel.tsx new file mode 100644 index 000000000..3f2641265 --- /dev/null +++ b/src/components/Auth/AuthLayout/SetupPanel.tsx @@ -0,0 +1,245 @@ +import { useAccount } from 'wagmi' +import { useModal } from 'connectkit' +import appConfig from 'app.config.cjs' +import { useSsiWallet } from '@context/SsiWallet' +import useSsiAllowedChain from '@hooks/useSsiAllowedChain' +import useSsiChainGuard from '@hooks/useSsiChainGuard' +import { useAuth } from '@hooks/useAuth' +import { getPendingAuthMode } from '@utils/authFlow' +import useSsiConnect from '@hooks/useSsiConnect' +import { authSetupCopy } from '../constants' +import styles from './SetupPanel.module.css' + +type StepStatus = 'complete' | 'active' | 'pending' +type SetupAction = 'connectWallet' | 'switchNetwork' | 'connectSsi' | null + +interface SetupStepItem { + title: string + description: string + status: StepStatus +} + +function SetupStep({ + title, + description, + status, + isLast = false +}: { + title: string + description: string + status: StepStatus + isLast?: boolean +}) { + return ( +
+
+ + {!isLast && } +
+
+
+

{title}

+ + {status === 'complete' + ? 'Complete' + : status === 'active' + ? 'In progress' + : 'Pending'} + +
+

{description}

+
+
+ ) +} + +function getSetupSubtitle( + authMode: ReturnType, + isSsiEnabled: boolean +) { + if (authMode === 'signup') { + return isSsiEnabled + ? authSetupCopy.signupSubtitle + : authSetupCopy.signupWalletOnlySubtitle + } + + return isSsiEnabled + ? authSetupCopy.subtitle + : authSetupCopy.walletOnlySubtitle +} + +export default function SetupPanel() { + const { isConnected } = useAccount() + const { setOpen } = useModal() + const { user, logout } = useAuth() + const { connectSsi } = useSsiConnect() + const { sessionToken, isSsiStateHydrated, isSsiSessionHydrating } = + useSsiWallet() + const { isSsiChainAllowed, isSsiChainReady } = useSsiAllowedChain() + const { ensureAllowedChainForSsi } = useSsiChainGuard() + const authMode = getPendingAuthMode() + const isSsiEnabled = appConfig.ssiEnabled + + const isWalletReady = isConnected + const isSsiReady = Boolean(sessionToken) + const shouldRequireSsi = isSsiEnabled + const isSetupReady = shouldRequireSsi + ? isWalletReady && isSsiStateHydrated && isSsiReady + : isWalletReady + const shouldSwitchNetwork = + isSsiEnabled && isWalletReady && (!isSsiChainReady || !isSsiChainAllowed) + const subtitle = getSetupSubtitle(authMode, isSsiEnabled) + + const steps: SetupStepItem[] = [ + { + title: authSetupCopy.ssoStep, + description: authSetupCopy.ssoMeta, + status: 'complete' + }, + { + title: authSetupCopy.walletStep, + description: isWalletReady + ? authSetupCopy.walletComplete + : authSetupCopy.walletActive, + status: isWalletReady ? 'complete' : 'active' + } + ] + + if (shouldRequireSsi) { + steps.push({ + title: authSetupCopy.ssiStep, + description: isSsiReady + ? authSetupCopy.ssiComplete + : !isWalletReady + ? authSetupCopy.ssiPending + : shouldSwitchNetwork + ? authSetupCopy.ssiNetwork + : isSsiSessionHydrating + ? authSetupCopy.ssiConnecting + : authSetupCopy.ssiActive, + status: isSsiReady ? 'complete' : isWalletReady ? 'active' : 'pending' + }) + } + + const currentAction: SetupAction = !isWalletReady + ? 'connectWallet' + : shouldRequireSsi && shouldSwitchNetwork + ? 'switchNetwork' + : shouldRequireSsi && !isSsiReady + ? 'connectSsi' + : null + + const actionLabel = + currentAction === 'connectWallet' + ? authSetupCopy.connectWallet + : currentAction === 'switchNetwork' + ? authSetupCopy.switchNetwork + : currentAction === 'connectSsi' + ? isSsiSessionHydrating + ? authSetupCopy.connectingSsi + : authSetupCopy.connectSsi + : null + + const handleAction = async () => { + if (currentAction === 'connectWallet') { + setOpen(true) + return + } + + if (currentAction === 'switchNetwork') { + ensureAllowedChainForSsi() + return + } + + if (currentAction === 'connectSsi') { + await connectSsi() + } + } + + const handleAccountSwitch = () => { + logout().catch((error) => { + console.error('Account switch logout failed:', error) + }) + } + + const greeting = user?.name + ? `${ + authMode === 'signup' + ? authSetupCopy.signupGreeting + : authSetupCopy.greeting + }, ${user.name}!` + : authSetupCopy.title + + return ( +
+
+

{greeting}

+

{subtitle}

+

+ {authSetupCopy.wrongAccount}{' '} + +

+
+ +
+ {steps.map((step, index) => ( + + ))} +
+ +
+ {isSetupReady ? ( +
+ + {authSetupCopy.redirecting} +
+ ) : ( + <> + {actionLabel && ( + + )} + + )} +
+
+ ) +} diff --git a/src/components/Auth/AuthLayout/index.module.css b/src/components/Auth/AuthLayout/index.module.css new file mode 100644 index 000000000..9b28de816 --- /dev/null +++ b/src/components/Auth/AuthLayout/index.module.css @@ -0,0 +1,136 @@ +.page { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 0; + padding: 24px 32px; + background: white; +} + +.card { + display: flex; + width: 100%; + max-width: 1060px; + border-radius: 24px; + overflow: hidden; + box-shadow: 0 -8px 24px rgba(15, 23, 42, 0.04), + 0 22px 48px rgba(15, 23, 42, 0.1); +} + +.formPanel { + flex: 1 1 50%; + min-width: 0; + padding: 40px 36px; + display: flex; + flex-direction: column; + justify-content: center; + background: white; + gap: 40px; +} + +.pillTabs { + display: flex; + gap: 10px; +} + +.formContent { + width: 100%; + min-height: 240px; + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.pillTab { + flex: 1; + padding: 12px 20px; + border-radius: 999px; + border: 1px solid #e2e8f0; + background: white; + font-size: 14px; + font-weight: 700; + color: #64748b; + cursor: pointer; + box-shadow: 0 4px 12px rgba(1, 32, 55, 0.04); + transition: all 0.2s ease; +} + +.pillTab:hover { + background: #f8fafc; + border-color: #cbd5e1; + color: var(--clr-navy); +} + +.pillTabActive { + background: var(--gradient-primary); + color: white; + border-color: transparent; + box-shadow: 1px 2px 8px var(--box-shadow-color); +} + +.pillTabActive:hover { + background: var(--gradient-primary); + border-color: transparent; + color: white; + opacity: 0.95; +} + +@media (min-width: 2560px) { + .page { + padding: 40px 56px; + } + + .card { + max-width: 1220px; + } +} + +@media (min-width: 1440px) and (max-width: 2559px) { + .card { + max-width: 1120px; + } +} + +@media (min-width: 1025px) and (max-width: 1439px) { + .card { + max-width: 980px; + } + + .formPanel { + padding: 36px 30px; + } +} + +@media (min-width: 769px) and (max-width: 1024px) { + .card { + flex-direction: column; + max-width: 600px; + } + + .formPanel { + padding: 32px 24px; + } + + .formContent { + min-height: 0; + } +} + +@media (max-width: 768px) { + .page { + padding: 16px; + } + + .card { + flex-direction: column; + } + + .formPanel { + padding: 24px 16px; + } + + .formContent { + min-height: 0; + } +} diff --git a/src/components/Auth/AuthLayout/index.tsx b/src/components/Auth/AuthLayout/index.tsx new file mode 100644 index 000000000..1ca36ad4b --- /dev/null +++ b/src/components/Auth/AuthLayout/index.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react' +import { useAuth } from '@hooks/useAuth' +import LoginForm from '../Login/LoginForm' +import SignupForm from '../Signup/SignupForm' +import { + authTabLabels, + type AuthPanelContent, + type AuthTab +} from '../constants' +import BrandPanel from './BrandPanel' +import LogoutPanel from './LogoutPanel' +import SetupPanel from './SetupPanel' +import styles from './index.module.css' + +interface AuthLayoutProps { + content: AuthPanelContent + initialTab?: AuthTab +} + +export default function AuthLayout({ + content, + initialTab = 'login' +}: AuthLayoutProps) { + const { isAuthenticated, isLogoutPending } = useAuth() + const [activeTab, setActiveTab] = useState(initialTab) + + useEffect(() => { + setActiveTab(initialTab) + }, [initialTab]) + + return ( +
+
+ +
+ {!isAuthenticated && !isLogoutPending && ( +
+ + +
+ )} + +
+ {isLogoutPending ? ( + + ) : isAuthenticated ? ( + + ) : activeTab === 'login' ? ( + + ) : ( + + )} +
+
+
+
+ ) +} diff --git a/src/components/Auth/Login/LoginForm.module.css b/src/components/Auth/Login/LoginForm.module.css new file mode 100644 index 000000000..54a3e6804 --- /dev/null +++ b/src/components/Auth/Login/LoginForm.module.css @@ -0,0 +1,184 @@ +/* ── Form header ── */ +.formHeader { + margin-bottom: 28px; + text-align: center; +} + +.title { + font-size: 22px; + font-weight: 700; + color: #1a2c3e; + margin-bottom: 6px; +} + +.subtitle { + font-size: 13px; + color: #5a6874; +} + +/* ── Social / provider buttons ── */ +.socialButtons { + display: flex; + flex-direction: column; + gap: 12px; +} + +.socialButton { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 12px 20px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + color: #1e293b; + cursor: pointer; + transition: all 0.15s ease; +} + +.socialButton:hover:not(:disabled) { + background: #f8fafc; + border-color: #0a4b70; +} + +.socialButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.socialButton.loading { + opacity: 0.7; +} + +.icon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.buttonContent { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-width: 220px; +} + +/* ── Demo notice ── */ +.demoNotice { + margin-top: 24px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 10px 16px; + background: transparent; + border: 1px dashed #cbd5e1; + border-radius: 8px; + font-size: 12px; + color: #64748b; + transition: all 0.15s ease; +} + +.demoNotice:hover { + background: #f8fafc; + border-color: #94a3b8; +} + +.demoDot { + width: 6px; + height: 6px; + background: #0a4b70; + border-radius: 50%; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* ── 4K ── */ +@media (min-width: 2560px) { + .title { + font-size: 32px; + margin-bottom: 10px; + } + + .subtitle { + font-size: 16px; + } + + .formHeader { + margin-bottom: 36px; + } + + .socialButton { + padding: 14px 24px; + font-size: 16px; + } + + .icon { + width: 22px; + height: 22px; + } +} + +/* ── Standard desktop ── */ +@media (min-width: 1025px) and (max-width: 1439px) { + .title { + font-size: 20px; + } + + .subtitle { + font-size: 12px; + } + + .socialButton { + padding: 10px 16px; + font-size: 13px; + } + + .formHeader { + margin-bottom: 24px; + } +} + +/* ── Mobile ── */ +@media (max-width: 480px) { + .title { + font-size: 20px; + } + + .subtitle { + font-size: 12px; + } + + .socialButton { + padding: 10px 16px; + font-size: 13px; + } + + .icon { + width: 16px; + height: 16px; + } + + .formHeader { + margin-bottom: 20px; + } + + .demoNotice { + margin-top: 20px; + font-size: 11px; + } +} diff --git a/src/components/Auth/Login/LoginForm.tsx b/src/components/Auth/Login/LoginForm.tsx new file mode 100644 index 000000000..dd483f32c --- /dev/null +++ b/src/components/Auth/Login/LoginForm.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react' +import { useAuth } from '@hooks/useAuth' +import { authConfig } from '../../../config/auth.config' +import { authLoginCopy } from '../constants' +import { SsoIcon } from '../SsoIcons' +import styles from './LoginForm.module.css' + +export default function LoginForm() { + const { beginOidcFlow } = useAuth() + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleOIDCLogin = async () => { + setIsSubmitting(true) + try { + await beginOidcFlow('login') + } catch { + setIsSubmitting(false) + } + } + + const showOIDC = authConfig.oidc.issuer && authConfig.oidc.clientId + + return ( +
+
+

{authLoginCopy.title}

+

{authLoginCopy.subtitle}

+
+ +
+ {showOIDC && ( + + )} +
+ +
+ + {authLoginCopy.demoNotice} +
+
+ ) +} diff --git a/src/components/Auth/Login/index.tsx b/src/components/Auth/Login/index.tsx new file mode 100644 index 000000000..9da943488 --- /dev/null +++ b/src/components/Auth/Login/index.tsx @@ -0,0 +1,87 @@ +import { useEffect } from 'react' +import { useRouter } from 'next/router' +import appConfig from 'app.config.cjs' +import { useAuth } from '@hooks/useAuth' +import { useSsiWallet } from '@context/SsiWallet' +import { toast } from 'react-toastify' +import { clearPendingAuthMode } from '@utils/authFlow' +import AuthLayout from '../AuthLayout' +import type { AuthPanelContent, AuthTab } from '../constants' +import { useAccount } from 'wagmi' + +interface LoginProps { + content: AuthPanelContent + initialTab?: AuthTab +} + +export default function Login({ content, initialTab = 'login' }: LoginProps) { + const { isAuthenticated, authEnabled } = useAuth() + const { isConnected } = useAccount() + const { sessionToken, isSsiStateHydrated } = useSsiWallet() + const router = useRouter() + const { callbackUrl, error } = router.query + const isSsiEnabled = appConfig.ssiEnabled + + useEffect(() => { + if (error) { + switch (error) { + case 'access_denied': + toast.error('Access denied. Please try again.') + break + case 'auth_failed': + toast.error('Authentication failed. Please try again.') + break + default: + toast.error('Authentication error. Please try again.') + } + const nextQuery = { ...router.query } + delete nextQuery.error + + router.replace( + { + pathname: '/auth/login', + query: nextQuery + }, + undefined, + { shallow: true } + ) + } + }, [error, router]) + + useEffect(() => { + if (!authEnabled) { + router.push('/') + } + }, [authEnabled, router]) + + useEffect(() => { + if (!isAuthenticated) return + if (!isConnected) return + if (isSsiEnabled) { + if (!isSsiStateHydrated) return + if (!sessionToken) return + } + + const redirectTo = (callbackUrl as string) || '/profile' + const timeoutId = window.setTimeout(() => { + clearPendingAuthMode() + router.replace(redirectTo) + }, 900) + + return () => window.clearTimeout(timeoutId) + }, [ + callbackUrl, + isAuthenticated, + isConnected, + isSsiEnabled, + isSsiStateHydrated, + router, + sessionToken + ]) + + if (!authEnabled) { + return null + } + + return +} diff --git a/src/components/Auth/Signup/SignupForm.module.css b/src/components/Auth/Signup/SignupForm.module.css new file mode 100644 index 000000000..26e50a158 --- /dev/null +++ b/src/components/Auth/Signup/SignupForm.module.css @@ -0,0 +1,163 @@ +/* ── Form header ── */ +.formHeader { + margin-bottom: 28px; + display: flex; + flex-direction: column; + align-items: center; +} + +.title { + font-size: 22px; + font-weight: 700; + color: #1a2c3e; + margin-bottom: 6px; +} + +.subtitle { + font-size: 13px; + color: #5a6874; +} + +/* ── Social / provider buttons ── */ +.socialButtons { + display: flex; + flex-direction: column; + gap: 12px; +} + +.socialButton { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 12px 20px; + background: white; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + color: #1e293b; + cursor: pointer; + transition: all 0.15s ease; +} + +.socialButton:hover:not(:disabled) { + background: #f8fafc; + border-color: #0a4b70; +} + +.socialButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.socialButton.loading { + opacity: 0.7; +} + +.icon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.buttonContent { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-width: 220px; +} + +/* ── Terms ── */ +.terms { + margin-top: 20px; + font-size: 11px; + color: #94a3b8; + text-align: center; + line-height: 1.5; +} + +.terms a { + color: #0a4b70; + text-decoration: none; +} + +.terms a:hover { + text-decoration: underline; +} + +/* ── 4K ── */ +@media (min-width: 2560px) { + .title { + font-size: 32px; + margin-bottom: 10px; + } + + .subtitle { + font-size: 16px; + } + + .formHeader { + margin-bottom: 36px; + } + + .socialButton { + padding: 14px 24px; + font-size: 16px; + } + + .icon { + width: 22px; + height: 22px; + } +} + +/* ── Standard desktop ── */ +@media (min-width: 1025px) and (max-width: 1439px) { + .title { + font-size: 20px; + } + + .subtitle { + font-size: 12px; + } + + .socialButton { + padding: 10px 16px; + font-size: 13px; + } + + .formHeader { + margin-bottom: 24px; + } +} + +/* ── Mobile ── */ +@media (max-width: 480px) { + .title { + font-size: 20px; + } + + .subtitle { + font-size: 12px; + } + + .socialButton { + padding: 10px 16px; + font-size: 13px; + } + + .icon { + width: 16px; + height: 16px; + } + + .formHeader { + margin-bottom: 20px; + } + + .terms { + margin-top: 16px; + } +} diff --git a/src/components/Auth/Signup/SignupForm.tsx b/src/components/Auth/Signup/SignupForm.tsx new file mode 100644 index 000000000..57fbd342b --- /dev/null +++ b/src/components/Auth/Signup/SignupForm.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react' +import Link from 'next/link' +import { useAuth } from '@hooks/useAuth' +import { useUserPreferences } from '@context/UserPreferences' +import { authConfig } from '../../../config/auth.config' +import { authSignupCopy } from '../constants' +import { SsoIcon } from '../SsoIcons' +import styles from './SignupForm.module.css' + +export default function SignupForm() { + const { beginOidcFlow } = useAuth() + const { privacyPolicySlug } = useUserPreferences() + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleOIDCSignup = async () => { + setIsSubmitting(true) + try { + await beginOidcFlow('signup') + } catch { + setIsSubmitting(false) + } + } + + const showOIDC = authConfig.oidc.issuer && authConfig.oidc.clientId + + return ( +
+
+

{authSignupCopy.title}

+

{authSignupCopy.subtitle}

+
+ +
+ {showOIDC && ( + + )} +
+ +
+ {authSignupCopy.termsIntro}{' '} + + {authSignupCopy.termsLabel} + {' '} + and{' '} + + {authSignupCopy.privacyLabel} + +
+
+ ) +} diff --git a/src/components/Auth/SsoIcons.tsx b/src/components/Auth/SsoIcons.tsx new file mode 100644 index 000000000..84ef9d777 --- /dev/null +++ b/src/components/Auth/SsoIcons.tsx @@ -0,0 +1,67 @@ +import type { SVGProps } from 'react' + +export type SsoIconVariant = 'building_key' | 'user_plus' + +interface SsoIconProps extends SVGProps { + variant: SsoIconVariant +} + +export function SsoIcon({ variant, ...props }: SsoIconProps) { + switch (variant) { + case 'building_key': + return ( + + + + + + + ) + case 'user_plus': + return ( + + + + + + + ) + } +} diff --git a/src/components/Auth/constants.ts b/src/components/Auth/constants.ts new file mode 100644 index 000000000..2068220a7 --- /dev/null +++ b/src/components/Auth/constants.ts @@ -0,0 +1,99 @@ +import type { BrandPanelIconVariant } from './AuthLayout/BrandPanelArtwork' + +export type AuthTab = 'login' | 'signup' + +export interface AuthFeature { + icon: BrandPanelIconVariant + text: string +} + +export interface AuthPanelContent { + title: string + description: string + features?: AuthFeature[] +} + +export const OIDC_LOGOUT_PENDING_KEY = 'oidc_logout_pending' +export const OIDC_LOGOUT_STATE_KEY = 'oidc_logout_state' +export const OIDC_LOGOUT_STARTED_AT_KEY = 'oidc_logout_started_at' +export const OIDC_LOGOUT_RETURN_FALLBACK_MS = 1500 + +export const authTabLabels: Record = { + login: 'Sign in', + signup: 'Create account' +} + +export const authBrandDefaults: { + title: string + description: string + features: AuthFeature[] + trustLabel: string + trustBadges: string[] +} = { + title: 'Ocean Enterprise Marketplace', + description: + 'Discover, publish and manage data, software and AI services with enterprise-grade governance and trusted access control.', + features: [ + { icon: 'marketplace', text: 'Publish and discover service offerings' }, + { icon: 'access', text: 'Control access with verified credentials' }, + { icon: 'interop', text: 'Standardized, interoperable metadata' }, + { icon: 'compute', text: 'Private computation with Compute-to-Data' } + ], + trustLabel: 'Built for trusted data exchange', + trustBadges: ['SSI Verification', 'Gaia-X Aligned', 'Compute-to-Data'] +} + +export const authLoginCopy = { + title: 'Welcome back', + subtitle: "Sign in to your organization's data marketplace", + ssoLabel: 'Continue with Company SSO', + ssoLoadingLabel: 'Redirecting to login...', + demoNotice: 'Demo Mode: No real authentication required' +} + +export const authSignupCopy = { + title: 'Get started', + subtitle: "Create your organization's marketplace account", + ssoLabel: 'Sign up with Company SSO', + ssoLoadingLabel: 'Redirecting to signup...', + termsIntro: 'By creating an account, you agree to our', + termsLabel: 'Terms of Service', + privacyLabel: 'Privacy Policy' +} + +export const authSetupCopy = { + title: 'One more step to enter the marketplace', + subtitle: 'Connect your wallet and SSI to finish secure access setup.', + walletOnlySubtitle: 'Connect your wallet to finish secure access setup.', + greeting: 'Welcome back', + signupGreeting: 'Welcome', + signupSubtitle: + 'Connect your wallet and SSI to finish setting up secure access.', + signupWalletOnlySubtitle: + 'Connect your wallet to finish setting up secure access.', + ssoStep: 'Company SSO', + ssoMeta: 'Company sign-in complete', + walletStep: 'Connect your Web3 wallet', + walletPending: 'Connect your Web3 wallet to continue', + walletActive: 'Connect your Web3 wallet to continue', + walletComplete: 'Wallet connected', + ssiStep: 'Establish SSI session', + ssiPending: 'Connect your wallet first to unlock SSI setup', + ssiNetwork: 'Switch to an SSI-supported network to continue', + ssiActive: 'Connect your SSI wallet to finish secure access', + ssiConnecting: 'Confirm the SSI request in your wallet', + ssiComplete: 'SSI session established', + connectWallet: 'Connect wallet', + connectSsi: 'Connect SSI wallet', + connectingSsi: 'Connecting SSI...', + switchNetwork: 'Switch network', + redirecting: 'Access ready. Redirecting you now...', + wrongAccount: 'Signed in with a different company account?', + wrongAccountAction: 'Use another account' +} + +export const authLogoutCopy = { + title: 'Logging you out', + subtitle: 'Redirecting to Authentik to confirm sign out', + waiting: 'Please wait a moment' +} diff --git a/src/components/Header/AuthEntry.tsx b/src/components/Header/AuthEntry.tsx new file mode 100644 index 000000000..0ffdd7764 --- /dev/null +++ b/src/components/Header/AuthEntry.tsx @@ -0,0 +1,60 @@ +import { MouseEventHandler, ReactElement, ReactNode } from 'react' +import Link from 'next/link' +import { useAuth } from '@hooks/useAuth' +import { useRouter } from 'next/router' + +interface AuthEntryProps { + authenticatedContent: ReactNode + loginClassName: string + buttonContentClassName: string + buttonTextClassName: string + loginLabel?: string + onLoginClick?: MouseEventHandler +} + +export default function AuthEntry({ + authenticatedContent, + loginClassName, + buttonContentClassName, + buttonTextClassName, + loginLabel = 'Login', + onLoginClick +}: AuthEntryProps): ReactElement { + const { isAuthenticated, authEnabled } = useAuth() + const router = useRouter() + + const path = router.asPath.split('?')[0] + const isAuthRoute = authEnabled && path.startsWith('/auth/') + + if (!authEnabled) { + return <>{authenticatedContent} + } + + if (isAuthRoute) { + return <> + } + + if (isAuthenticated) { + return <>{authenticatedContent} + } + + const content = ( + + {loginLabel} + + ) + + if (onLoginClick) { + return ( + + ) + } + + return ( + + {content} + + ) +} diff --git a/src/components/Header/Menu.tsx b/src/components/Header/Menu.tsx index 2fb4be964..996af9a5d 100644 --- a/src/components/Header/Menu.tsx +++ b/src/components/Header/Menu.tsx @@ -14,6 +14,8 @@ import { SsiWallet } from '@components/Header/SsiWallet' import Upload from '@images/publish.svg' import BurgerIcon from '@images/burgerIcon.svg' // You'll need to add a burger icon import CloseIcon from '@images/closeIcon.svg' // You'll need to add a close icon +import { useAuth } from '@hooks/useAuth' +import AuthEntry from './AuthEntry' const cx = classNames.bind(styles) @@ -52,8 +54,12 @@ export function MenuLink({ name, link, className }: MenuItem) { export default function Menu(): ReactElement { const { validatedSupportedChains } = useMarketMetadata() const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) + const { isAuthenticated, authEnabled } = useAuth() const router = useRouter() + const path = router.asPath.split('?')[0] + const isAuthRoute = authEnabled && path.startsWith('/auth/') + const canAccessWalletControls = !authEnabled || isAuthenticated const isPublishRoute = router.pathname.startsWith('/publish') const isCatalogRoute = @@ -61,8 +67,9 @@ export default function Menu(): ReactElement { router.query.sort === 'indexedMetadata.event.block' && router.query.sortOrder === 'desc' - const showPublishButton = !isPublishRoute - const showCatalogButton = !isCatalogRoute + const canShowProtectedCtas = isAuthenticated && !isAuthRoute + const showPublishButton = canShowProtectedCtas && !isPublishRoute + const showCatalogButton = canShowProtectedCtas && !isCatalogRoute const publishLink = '/publish/1' const catalogLink = '/search?sort=indexedMetadata.event.block&sortOrder=desc' @@ -83,6 +90,11 @@ export default function Menu(): ReactElement { const handleWalletClick = () => { setIsMobileMenuOpen(false) } + const handleLoginClick = (event: MouseEvent) => { + event.preventDefault() + router.push('/auth/login') + setIsMobileMenuOpen(false) + } return (
diff --git a/src/components/Header/Wallet/Details.module.css b/src/components/Header/Wallet/Details.module.css index 24d7be265..8b9fa298d 100644 --- a/src/components/Header/Wallet/Details.module.css +++ b/src/components/Header/Wallet/Details.module.css @@ -1,143 +1,290 @@ .details { background: #ffffff; - padding: 2rem 1.75rem; - min-width: 22rem; + padding: 1.75rem; + min-width: 23rem; font-family: 'Inter', sans-serif; color: var(--navy-700); } -/* Profile & Bookmark links */ -.profileLink, -.bookmarksLink { +.section { + border-top: 1px solid #e0e0e0; + padding-top: 1.25rem; + margin-top: 1.25rem; +} + +.section:first-child, +.userInfo { + border-top: 0; + padding-top: 0; + margin-top: 0; +} + +.menuRow { display: flex; align-items: center; gap: 0.75rem; - font-weight: 700; - font-size: 1.25rem; + width: 100%; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + text-align: left; margin-bottom: 1rem; color: var(--navy-700); } -.profileLink img, -.bookmarksLink svg { +.menuRow:last-child { + margin-bottom: 0; +} + +.menuRowIcon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.menuRowIcon img, +.menuRowIcon svg { width: 1.75rem; height: 1.75rem; margin: 0; } -.bookmarksButton { + +.menuRowLabel { + position: relative; + display: inline-block; font-family: var(--font-family); font-weight: 700; font-size: 17px; line-height: 80%; text-transform: uppercase; - text-align: center; + text-align: left; color: #0a4b70; + text-decoration: none; } -.profileButton { - font-family: var(--font-family); - font-weight: 700; - font-size: 17px; - line-height: 80%; - text-transform: uppercase; - text-align: center; - color: #0a4b70; + +.menuRowLabel::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -0.16rem; + height: 2px; + background-color: currentColor; + transform: scaleX(0); + transform-origin: 0 50%; + transition: transform 0.2s ease-out; } -.bookmarksLink svg { +.menuRow:hover .menuRowLabel::after, +.menuRow:focus .menuRowLabel::after { + transform: scaleX(1); +} + +.menuRowDisabled { + cursor: not-allowed; +} + +.menuRowDisabled .menuRowLabel { + color: #94a3b8; +} + +.menuRowDisabled:hover .menuRowLabel, +.menuRowDisabled:focus .menuRowLabel { + text-decoration: none; +} + +.menuRowDisabled:hover .menuRowLabel::after, +.menuRowDisabled:focus .menuRowLabel::after { + transform: scaleX(0); +} + +.menuRowDisabled .menuRowIcon { + opacity: 0.4; +} + +.bookmarksRow svg { fill: var(--yellow-400); } -/* Divider between sections */ -.actions { - border-top: 1px solid #e0e0e0; - margin-top: 1.25rem; - padding-top: 1.25rem; - display: flex; - flex-direction: column; - gap: 1rem; +.placeholderAvatar { + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + background: linear-gradient(135deg, #d8e4f0 0%, #eef4fa 100%); + border: 1px solid #c7d7e7; } -/* Wallet name section */ .walletInfo { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.6rem; font-size: 1.1rem; - border-bottom: 1px solid #e0e0e0; - padding-bottom: 1.25rem; } -.walletLogoWrap { +.walletHeading { font-weight: 600; color: var(--navy-700); + font-size: 1.1rem; +} + +.walletDescription { + font-size: 0.9rem; + color: #64748b; + text-transform: none; + word-break: break-word; } -/* AddTokenList buttons */ -.walletInfo button { +.walletAddressRow { display: flex; align-items: center; - font-size: 1rem; - color: var(--neutral-850); - background: none; - border: none; - padding: 0.35rem 0; + gap: 0.4rem; + flex-wrap: wrap; +} + +.actionButton { + display: flex; + align-items: flex-start; + gap: 0.75rem; + width: 100%; + padding: 0.15rem 0; + border: 0; + background: transparent; + text-align: left; cursor: pointer; - gap: 0.5rem; + transition: color 0.18s ease, opacity 0.18s ease, transform 0.18s ease; } -.walletInfo button:hover { - color: var(--navy-700); +.actionButton + .actionButton { + margin-top: 0.95rem; +} + +.actionButtonCompact { + align-items: center; +} + +.actionButton:hover, +.actionButton:focus-visible { + outline: 0; +} + +.actionButton:focus-visible { + outline: 2px solid rgba(10, 75, 112, 0.18); + outline-offset: 2px; + border-radius: 0.4rem; +} + +.actionButtonDanger { + color: #d92d20; +} + +.actionIconBadge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + flex-shrink: 0; + color: #0a4b70; + transition: transform 0.18s ease, color 0.18s ease, opacity 0.18s ease; +} + +.actionIconBadgeDanger { + color: #dc2626; } -.walletLogo { - width: 1.5rem; - height: 1.5rem; - margin-right: 0.5rem; +.actionGlyph { + width: 1.55rem; + height: 1.55rem; } -/* Disconnect + Switch Wallet */ -.actions p { +.actionContent { display: flex; flex-direction: column; - gap: 0.5rem; - margin: 0; + gap: 0.15rem; + min-width: 0; + padding-top: 0.05rem; } -.walletActionRow { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; + +.actionButtonCompact .actionContent { + justify-content: center; + padding-top: 0; } -.walletActionIcon { +.actionTitle { position: relative; display: inline-block; - z-index: 1; - width: 1.6rem; - height: 100%; - flex-shrink: 0; - margin: 0; -} - -.walletActionRow button, -.walletActionRow button:hover { + font-family: var(--font-family); font-size: 1rem; font-weight: 500; + line-height: 1.2; color: var(--navy-700); - background: none; - border: none; - text-align: left; - padding: 0; - cursor: pointer; + text-decoration: none; } -.walletActionRow button:hover { - text-decoration: underline; +.actionTitle::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -0.14rem; + height: 2px; + background-color: currentColor; + transform: scaleX(0); + transform-origin: 0 50%; + transition: transform 0.2s ease-out; } -/* --- Responsive Enhancements --- */ +.actionButton:hover .actionTitle::after, +.actionButton:focus-visible .actionTitle::after { + transform: scaleX(1); +} + +.actionButton:hover .actionTitle, +.actionButton:focus-visible .actionTitle { + text-decoration: none; +} + +.actionButton:hover .actionIconBadge, +.actionButton:focus-visible .actionIconBadge { + transform: translateX(0.16rem); +} + +.actionButtonDanger .actionTitle { + color: #ef4444; +} + +.actionDescription { + font-size: 0.78rem; + line-height: 1.35; + color: #64748b; +} + +.actionButtonDanger .actionDescription { + color: #94a3b8; +} + +.userInfo { + border-top: 1px solid #e0e0e0; + margin-top: 1.25rem; + padding-top: 1rem; + font-size: 0.85rem; +} + +.userEmail { + font-weight: 500; + color: var(--navy-700); + margin-bottom: 0.25rem; + word-break: break-all; +} + +.userProvider { + font-size: 0.75rem; + color: #64748b; + text-transform: capitalize; +} -/* Adjust spacing and text for smaller screens */ @media (max-width: 480px) { .details { padding: 1.25rem 1rem; @@ -147,20 +294,18 @@ border-radius: 1rem; } - .profileLink, - .bookmarksLink { - font-size: 1rem; + .menuRow { gap: 0.5rem; } - .profileLink img, - .bookmarksLink svg { + .menuRowIcon img, + .menuRowIcon svg, + .placeholderAvatar { width: 1.25rem; height: 1.25rem; } - .bookmarksButton, - .profileButton { + .menuRowLabel { font-size: 0.95rem; } @@ -168,49 +313,30 @@ font-size: 0.95rem; } - .walletActionIcon { - width: 1.25rem; - } - - .walletActionRow button { - font-size: 0.95rem; + .actionButton { + gap: 0.65rem; } -} -/* High-res screens (1440px and above) */ -@media (min-width: 1440px) { - .details { - min-width: 24rem; + .actionGlyph { + width: 1.4rem; + height: 1.4rem; } - .profileLink, - .bookmarksLink { - font-size: 1.35rem; + .actionTitle { + font-size: 0.95rem; } - .walletInfo { - font-size: 1.2rem; + .actionDescription { + font-size: 0.78rem; } - .walletActionIcon { - width: 1.8rem; + .userInfo { + font-size: 0.75rem; } } -/* Ultra-wide displays (4K) */ -@media (min-width: 2560px) { +@media (min-width: 1440px) { .details { - padding: 3rem 2.5rem; - font-size: 1.25rem; - } - - .walletActionIcon { - width: 2rem; - } - - .profileLink img, - .bookmarksLink svg { - width: 2rem; - height: 2rem; + min-width: 24rem; } } diff --git a/src/components/Header/Wallet/Details.tsx b/src/components/Header/Wallet/Details.tsx index cf203b726..9a227931d 100644 --- a/src/components/Header/Wallet/Details.tsx +++ b/src/components/Header/Wallet/Details.tsx @@ -1,23 +1,115 @@ -import { ReactElement } from 'react' -import Button from '@shared/atoms/Button' -// import { useOrbis } from '@context/DirectMessages' -import { useDisconnect, useAccount, useConnect, useConnectors } from 'wagmi' +import { ReactElement, ReactNode } from 'react' +import { useDisconnect, useAccount } from 'wagmi' import styles from './Details.module.css' import Avatar from '@components/@shared/atoms/Avatar' import Bookmark from '@images/bookmark.svg' import DisconnectWallet from '@images/disconnect.svg' -import SwitchWallet from '@images/switchWallet.svg' -import { MenuLink } from '../Menu' +import LogoutIcon from '@images/logout.svg' +import Copy from '@shared/atoms/Copy' import AddTokenList from './AddTokenList' import { useSsiWallet } from '@context/SsiWallet' import { disconnectFromWallet } from '@utils/wallet/ssiWallet' import { LoggerInstance } from '@oceanprotocol/lib' +import { useAuth } from '@hooks/useAuth' +import { useModal } from 'connectkit' +import { useRouter } from 'next/router' +import { useUserPreferences } from '@context/UserPreferences' -export default function Details(): ReactElement { +interface DetailsProps { + onRequestClose?: () => void +} + +function formatWalletAddress(address: string): string { + if (!address) return '' + return `${address.slice(0, 8)}...${address.slice(-4)}` +} + +interface MenuRowProps { + icon: ReactNode + label: string + onClick?: () => void + disabled?: boolean + className?: string +} + +function MenuRow({ + icon, + label, + onClick, + disabled = false, + className +}: MenuRowProps): ReactElement { + return ( + + ) +} + +interface ActionButtonProps { + icon: ReactNode + title: string + description?: string + onClick: () => void + tone?: 'default' | 'danger' +} + +function ActionButton({ + icon, + title, + description, + onClick, + tone = 'default' +}: ActionButtonProps): ReactElement { + const isDanger = tone === 'danger' + const hasDescription = Boolean(description) + + return ( + + ) +} + +export default function Details({ + onRequestClose +}: DetailsProps): ReactElement { const { connector: activeConnector, address: accountId } = useAccount() - const connectors = useConnectors() - const { connect } = useConnect() const { disconnect } = useDisconnect() + const { logout, isAuthenticated, user, authEnabled } = useAuth() + const { setOpen } = useModal() + const router = useRouter() + const { showOnboardingModule } = useUserPreferences() const { setSessionToken, @@ -38,72 +130,131 @@ export default function Details(): ReactElement { } } - const handleConnectClick = async () => { - const connectorToUse = activeConnector || connectors[0] - if (connectorToUse) { - connect({ connector: connectorToUse }) - } else { - LoggerInstance.warn('No connector available to switch to.') + const isWalletConnected = Boolean(accountId) + const hasMarketplaceSession = authEnabled && isAuthenticated && Boolean(user) + const walletLabel = activeConnector?.name || 'Web3 wallet disconnected' + const walletDescription = isWalletConnected + ? formatWalletAddress(accountId) + : 'Connect your web3 wallet to restore marketplace actions' + const showTokenList = + isWalletConnected && activeConnector?.name === 'MetaMask' + const showActionDescriptions = showOnboardingModule + + const handleNavigation = async (href: string) => { + onRequestClose?.() + await router.push(href) + } + + const handleConnectWallet = () => { + onRequestClose?.() + setOpen(true) + } + + const handleDisconnectWallet = async () => { + disconnect() + // eslint-disable-next-line promise/param-names + await new Promise((r) => setTimeout(r, 500)) + await disconnectSsiWallet() + } + + const handleLogout = async () => { + try { + if (isWalletConnected) { + await handleDisconnectWallet() + } else { + await disconnectSsiWallet() + } + } catch (error) { + console.error('Error disconnecting wallet/SSI:', error) } + + await logout() + onRequestClose?.() } return (
-
    -
  • - - -
  • -
  • - - -
  • -
  • -
    - - {/* */} - {activeConnector?.name} - - {/* */} - {activeConnector?.name === 'MetaMask' && } -
    -
    -
    - - -
    +
    + + ) : ( +
    -
    - - +
    +
    +
    {walletLabel}
    + {isWalletConnected ? ( +
    + + {walletDescription} + +
    + ) : ( +
    {walletDescription}
    + )} + {showTokenList && } +
    +
    + +
    + } + title={ + isWalletConnected ? 'Disconnect web3 wallet' : 'Connect web3 wallet' + } + description={ + showActionDescriptions + ? isWalletConnected + ? 'Stop the active wallet connection for this browser session.' + : 'Reconnect your wallet to restore web3 actions in the marketplace.' + : undefined + } + onClick={ + isWalletConnected ? handleDisconnectWallet : handleConnectWallet + } + /> + {hasMarketplaceSession && ( + } + title="Sign out of marketplace" + description={ + showActionDescriptions + ? isWalletConnected + ? 'End your marketplace session and disconnect this linked wallet.' + : 'End your marketplace session on this browser.' + : undefined + } + onClick={handleLogout} + tone="danger" + /> + )} +
    + + {hasMarketplaceSession && user && ( +
    +
    {user.email}
    +
    + Signed in with {user.authProvider}
    -
  • -
+
+ )} ) } diff --git a/src/components/Header/Wallet/index.tsx b/src/components/Header/Wallet/index.tsx index a8e03e7dc..2268a84be 100644 --- a/src/components/Header/Wallet/index.tsx +++ b/src/components/Header/Wallet/index.tsx @@ -20,20 +20,18 @@ export default function Wallet(): ReactElement { return (
{accountId && } - {!accountId ? ( + tooltipRef.current?.hide?.()} /> + } + trigger="click focus mouseenter" + disabled={isSsiModalOpen} + onCreate={(instance) => { + tooltipRef.current = instance + }} + > - ) : ( - } - trigger="click focus mouseenter" - disabled={isSsiModalOpen} - onCreate={(instance) => { - tooltipRef.current = instance - }} - > - - - )} +
) } diff --git a/src/components/Home/Menu/Menu.tsx b/src/components/Home/Menu/Menu.tsx index 3e414b380..0913c1f45 100644 --- a/src/components/Home/Menu/Menu.tsx +++ b/src/components/Home/Menu/Menu.tsx @@ -6,10 +6,14 @@ import Wallet from '../../Header/Wallet' import styles from './index.module.css' import { useMarketMetadata } from '@context/MarketMetadata' import UserPreferences from '../../Header/UserPreferences' -import { SsiWallet } from './SsiWallet' +import AuthEntry from '../../Header/AuthEntry' +import { useAuth } from '@hooks/useAuth' +import { SsiWallet } from '../../Header/SsiWallet' export default function Menu(): ReactElement { const { validatedSupportedChains } = useMarketMetadata() + const { isAuthenticated, authEnabled } = useAuth() + const canAccessWalletControls = !authEnabled || isAuthenticated return ( ) diff --git a/src/components/Home/Menu/SsiWallet/index.module.css b/src/components/Home/Menu/SsiWallet/index.module.css deleted file mode 100644 index 91b129db3..000000000 --- a/src/components/Home/Menu/SsiWallet/index.module.css +++ /dev/null @@ -1,335 +0,0 @@ -.panelColumn { - width: 500px; - display: flex; - flex-direction: column; -} -.panelColumn h3 { - font-weight: 700; - font-size: 18px; - color: #303031; -} -.panelColumn label { - font-weight: 700; - font-size: 14px; - color: var(--clr-slate-70); -} - -.panelRow { - display: flex; - flex-direction: row; -} - -.widthAuto { - width: auto; -} - -.widthFitContent { - width: fit-content; -} - -.marginRight2 { - margin-right: 2em; -} - -.ssiPanel { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - font-family: var(--font-family); - font-weight: 700; - line-height: 137%; - text-align: center; - color: #fff; - transition: all 0.3s ease; - border: none; - background-color: transparent; - cursor: pointer; - font-size: 20px; -} -.text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} -.iconWrapper { - display: flex; - align-items: center; - justify-content: center; -} - -.icon { - width: 16px; - height: 16px; - transition: transform 0.3s ease; -} -@media screen and (max-width: 480px) { - .ssiPanel { - padding: 0.1em 0.2em; - font-size: 14px; - } - - .marginRight2 { - margin-right: 0em; - } - .icon { - width: 12px; - height: 12px; - } - .text { - max-width: 40px; - } - .panelColumn { - width: 100%; - } - .panelColumn h3 { - font-size: 14px; - } - .panelColumn label { - font-size: 12px; - } -} - -@media screen and (min-width: 481px) and (max-width: 768px) { - .ssiPanel { - padding: 0.4em 0.8em; - } - .text { - max-width: 80px; - } -} - -@media screen and (min-width: 768px) and (max-width: 1150px) { - .text { - font-size: 16px; - } -} - -.connected { - color: var(--neutral-200); - background-color: transparent; -} - -.connected:hover { - transform: scale(1.05); -} - -.connected:hover .icon { - transform: scale(1.2); -} - -.disconnected { - background-color: transparent; -} - -.disconnected:hover { - transform: scale(1.05); -} - -.disconnected:hover .icon { - transform: scale(1.2); -} - -.marginBottom7px { - margin-bottom: 7.5px; -} - -.marginBottom1 { - margin-bottom: 1em; -} - -.marginBottom2 { - margin-bottom: 2em; -} - -.marginBottom3 { - margin-bottom: 3em; -} - -.padding1 { - padding-top: 1em; - padding-bottom: 1em; -} - -.width100p { - width: 100%; -} - -.dialogBorder { - width: 548px; - max-width: 100%; -} - -@media screen and (max-width: 768px) { - .dialogBorder { - width: 100%; - } -} - -.closeButton { - border-radius: 20px; - padding: 4px 20px; - width: 79px; - height: 32px; - font-weight: 700; - font-size: 14px; - line-height: 114%; - text-align: center; - color: #0a4b70; - box-shadow: 1px 2px 8px 0 var(--black-alpha-15); - background: rgba(48, 48, 49, 0.12); -} - -.resetButton { - border-radius: 20px; - padding: 4px 20px; - width: 169px; - height: 32px; - font-weight: 700; - font-size: 14px; - line-height: 114%; - text-align: center; - color: #0a4b70; - box-shadow: 1px 2px 8px 0 var(--black-alpha-15); - background: linear-gradient( - 135deg, - #fdd655 0%, - rgba(253, 214, 85, 0.8) 27.47%, - rgba(253, 214, 85, 0.7) 43.49%, - rgba(253, 214, 85, 0.8) 55.43%, - #fdd655 100% - ); -} - -.inputField { - border: 1px solid rgba(10, 75, 112, 0.5); - border-radius: 20px; - padding: 10px 30px; - width: 500px; - height: 40px; - font-weight: 400; - font-size: 14px; - color: #303031; - font-family: var(--font-family-base); - - box-shadow: none; - background: var(--background-content); -} - -.buttonStyles { - width: 300px; - height: 32px; - display: flex; - flex-direction: row; - margin: 0 auto; - gap: 8px; -} - -@media screen and (max-width: 480px) { - .inputField { - height: 35px; - font-size: var(--font-size-small); - } - .dialogBorder { - width: 100%; - } - .closeButton { - border-radius: 20px; - padding: 4px 10px; - width: 20px; - font-size: 8px; - } - - .resetButton { - border-radius: 20px; - padding: 4px 20px; - width: 160px; - height: 32px; - font-weight: 700; - font-size: 8px; - } - - .inputField { - width: 100%; - height: 40px; - font-weight: 400; - font-size: 8px; - } - .buttonStyles { - width: 200px; - } -} -@media screen and (min-width: 481px) and (max-width: 768px) { - .panelColumn { - width: 100%; - } - .panelColumn h3 { - font-size: 16px; - } - .panelColumn label { - font-size: 14px; - } - .dialogBorder { - width: 85%; - } - .closeButton { - border-radius: 20px; - padding: 4px 10px; - width: 20px; - font-size: 10px; - } - - .resetButton { - border-radius: 20px; - width: 170px; - font-size: 10px; - } - - .inputField { - width: 100%; - height: 40px; - font-weight: 400; - font-size: 10px; - } - .buttonStyles { - width: 300px; - } -} - -@media screen and (min-width: 481px) and (max-width: 768px) and (height: 893px) { - .ssiPanel { - padding: 0.3em 0.3em; - font-size: 0.85em; - display: flex; - align-items: center; - } - - .panelRow { - gap: 0.5em; - align-items: center; - } - - .marginRight2 { - margin-right: 0.8em; - } - - .inputField { - height: 36px; - padding: calc(var(--spacer) / 5); - } - - .panelRow > * { - vertical-align: middle; - margin-top: auto; - margin-bottom: auto; - } -} - -.list { - margin: 1em 0em 0em 2em; -} - -.listItem { - display: list-item; -} diff --git a/src/components/Home/Menu/SsiWallet/index.tsx b/src/components/Home/Menu/SsiWallet/index.tsx deleted file mode 100644 index ecbefb916..000000000 --- a/src/components/Home/Menu/SsiWallet/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { ReactElement } from 'react' -import SsiWalletControl from '@components/@shared/SsiWalletControl' -import styles from './index.module.css' - -export function SsiWallet(): ReactElement { - return ( - - ) -} diff --git a/src/components/Home/Menu/index.module.css b/src/components/Home/Menu/index.module.css index 5700dd48d..357d1645f 100644 --- a/src/components/Home/Menu/index.module.css +++ b/src/components/Home/Menu/index.module.css @@ -41,6 +41,35 @@ font-size: 22px; } +.loginButton { + background: white; + border: none; + border-radius: 20px; + box-shadow: 1px 2px 8px var(--black-alpha-15); + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + padding: 3px 16px; + min-width: 118px; + height: 40px; +} + +.buttonContent { + display: flex; + align-items: center; + justify-content: center; +} + +.buttonText { + font-family: var(--font-family); + font-weight: 700; + font-size: 20px; + line-height: 92%; + text-align: center; + color: #0a4b70; +} + .navigation { white-space: nowrap; overflow-y: hidden; @@ -173,6 +202,14 @@ svg.caret { font-size: 18px; margin-top: 1rem; } + .loginButton { + min-width: 96px; + height: 32px; + padding: 3px 12px; + } + .buttonText { + font-size: 16px; + } .demoText { white-space: nowrap; color: #fdd655; @@ -199,6 +236,14 @@ svg.caret { align-content: baseline; align-self: flex-end; } + .loginButton { + min-width: 96px; + height: 32px; + padding: 3px 12px; + } + .buttonText { + font-size: 16px; + } } @media screen and (min-width: 1026px) and (max-width: 1250px) { @@ -216,6 +261,14 @@ svg.caret { align-content: baseline; align-self: flex-end; } + .loginButton { + min-width: 96px; + height: 32px; + padding: 3px 12px; + } + .buttonText { + font-size: 16px; + } } @media screen and (min-width: 1251px) and (max-width: 1549px) { .demoText { diff --git a/src/config/auth.config.ts b/src/config/auth.config.ts new file mode 100644 index 000000000..a83e1c5a1 --- /dev/null +++ b/src/config/auth.config.ts @@ -0,0 +1,15 @@ +export const authConfig = { + enabled: process.env.NEXT_PUBLIC_AUTH_ENABLED === 'true', + provider: process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'mock', + oidc: { + issuer: process.env.NEXT_PUBLIC_OIDC_ISSUER || '', + clientId: process.env.NEXT_PUBLIC_OIDC_CLIENT_ID || '', + clientSecret: process.env.NEXT_PUBLIC_OIDC_CLIENT_SECRET || '', + redirectUri: + process.env.NEXT_PUBLIC_OIDC_REDIRECT_URI || + 'http://localhost:8008/auth/callback', + scope: 'openid profile email', + responseType: 'code', + pkceMethod: 'S256' + } +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index a70c339f1..18a61ac84 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -11,13 +11,15 @@ import '@oceanprotocol/typographies/css/ocean-typo.css' import '../stylesGlobal/styles.css' import Decimal from 'decimal.js' import MarketMetadataProvider from '@context/MarketMetadata' - import { WagmiProvider } from 'wagmi' import { ConnectKitProvider } from 'connectkit' import { connectKitTheme, wagmiConfig } from '@utils/wallet' import { FilterProvider } from '@context/Filter' import { SsiWalletProvider } from '@context/SsiWallet' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AuthProvider } from '@utils/authProvider' +import AuthGuard from '@components/Auth/AuthGuard/AuthGuard' + const queryClient = new QueryClient() function MyApp({ Component, pageProps }: AppProps): ReactElement { const [mounted, setMounted] = useState(false) @@ -45,9 +47,13 @@ function MyApp({ Component, pageProps }: AppProps): ReactElement { - - - + + + + + + + diff --git a/src/pages/auth/callback/index.tsx b/src/pages/auth/callback/index.tsx new file mode 100644 index 000000000..ce690a102 --- /dev/null +++ b/src/pages/auth/callback/index.tsx @@ -0,0 +1,73 @@ +import type { GetServerSideProps } from 'next' +import { useEffect } from 'react' +import { useRouter } from 'next/router' +import { useAuth } from '@hooks/useAuth' +import { authConfig } from '../../../config/auth.config' + +export default function AuthCallback() { + const router = useRouter() + const { checkSession } = useAuth() + const { code, error } = router.query + + useEffect(() => { + const run = async () => { + await checkSession() + } + + if (code) { + run().catch((callbackError) => { + console.error('OAuth callback error:', callbackError) + router.replace('/auth/login?error=auth_failed') + }) + } else if (error) { + console.error('OAuth error:', error) + router.replace('/auth/login?error=auth_failed') + } + }, [code, error, checkSession, router]) + + return ( +
+
+
+ +

Completing authentication...

+
+
+ ) +} + +export const getServerSideProps: GetServerSideProps = async () => { + if (!authConfig.enabled) { + return { + redirect: { + destination: '/', + permanent: false + } + } + } + + return { + props: {} + } +} diff --git a/src/pages/auth/login/index.tsx b/src/pages/auth/login/index.tsx new file mode 100644 index 000000000..40af95714 --- /dev/null +++ b/src/pages/auth/login/index.tsx @@ -0,0 +1,48 @@ +import type { GetServerSideProps } from 'next' +import { ReactElement } from 'react' +import router, { useRouter } from 'next/router' +import Login from '../../../components/Auth/Login' +import Page from '../../../components/@shared/Page' +import type { AuthFeature, AuthTab } from '../../../components/Auth/constants' +import { authConfig } from '../../../config/auth.config' +import content from '../../../../content/auth/login.json' + +export default function AuthLogin(): ReactElement { + const pageRouter = useRouter() + const { title, description, features } = content + const typedFeatures = features as AuthFeature[] + const initialTab = + pageRouter.query.tab === 'signup' + ? ('signup' as AuthTab) + : ('login' as AuthTab) + + return ( + + + + ) +} + +export const getServerSideProps: GetServerSideProps = async () => { + if (!authConfig.enabled) { + return { + redirect: { + destination: '/', + permanent: false + } + } + } + + return { + props: {} + } +} diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx index 8b4752e5f..c17b77cbd 100644 --- a/src/pages/profile/index.tsx +++ b/src/pages/profile/index.tsx @@ -1,7 +1,6 @@ import { ReactElement, useEffect, useState } from 'react' import Page from '@shared/Page' import ProfilePage from '../../components/Profile' -import { accountTruncate } from '@utils/wallet' import ProfileProvider from '@context/Profile' import { useRouter } from 'next/router' import { useAccount } from 'wagmi' @@ -9,10 +8,20 @@ import { isAddress } from 'ethers' export default function PageProfile(): ReactElement { const router = useRouter() - const { address: accountId } = useAccount() + const { address: accountId, isConnecting, isReconnecting } = useAccount() const [finalAccountId, setFinalAccountId] = useState() const [ownAccount, setOwnAccount] = useState(false) + useEffect(() => { + if (!router.isReady) return + if (isConnecting || isReconnecting) return + if (accountId) return + + router.replace( + `/auth/login?callbackUrl=${encodeURIComponent(router.asPath)}` + ) + }, [accountId, isConnecting, isReconnecting, router]) + // Have accountId in path take over, if not present fall back to web3 useEffect(() => { async function init() { @@ -37,10 +46,14 @@ export default function PageProfile(): ReactElement { init() }, [router, accountId]) + if (!accountId || !finalAccountId) { + return null + } + return ( diff --git a/src/stylesGlobal/Auth.module.css b/src/stylesGlobal/Auth.module.css new file mode 100644 index 000000000..e69de29bb