feat: add unified login UX/UI

This commit is contained in:
p2arthur
2026-01-13 14:15:17 -08:00
parent a6622d2cf8
commit 7bf43ceda6
28 changed files with 4926 additions and 508 deletions

View File

@ -0,0 +1,47 @@
# Publishing to npm
This repo already has the package source under `packages/web3auth-algorand` and the UI add-on under `packages/web3auth-algorand-ui`. Both use plain TypeScript builds (no bundler).
## Prereqs
- npm account with 2FA configured (recommended).
- Access to the `@tokenizerwa` npm scope (or change `name` in each `package.json` to your scope).
- Node 20+.
## Build locally
```bash
cd packages/web3auth-algorand
npm install
npm run build # emits dist/
cd ../web3auth-algorand-ui
npm install
npm run build
```
To double-check what will be published:
```bash
npm pack --dry-run
```
## Publish steps
1) Set the version you want in each `package.json` (`version` field). Use semver.
2) Make sure `files` includes `dist` (already set) and that `dist` exists (run `npm run build`).
3) Log in once if needed: `npm login`.
4) From each package folder, publish:
```bash
cd packages/web3auth-algorand
npm publish --access public
cd ../web3auth-algorand-ui
npm publish --access public
```
## Releasing updates
- Bump the version in the package you changed.
- Rebuild that package.
- Publish only the changed package(s).
## Troubleshooting
- 403 errors: ensure your npm token owns the scope or rename the package.
- Missing files in npm: verify `npm pack --dry-run` includes `dist` and `package.json` points to `dist/index.js`.
- Type errors: run `npm run build` to catch missing types before publishing.

View File

@ -0,0 +1,72 @@
# @tokenizerwa/web3auth-algorand
Algorand + Web3Auth hook and provider that turns a Google login into an Algorand signer. Ships the Web3Auth wiring, Algorand account derivation, and signer utilities so you can drop it into any React app.
## Whats inside
- `AlgorandWeb3AuthProvider` wraps your app and bootstraps Web3Auth + Algorand account derivation.
- `useWeb3Auth()` access connection state, Google profile info, and the derived Algorand account.
- `createWeb3AuthSigner(...)` get an AlgoKit-compatible `TransactionSigner`.
- Helper utilities for balance/amount formatting and account inspection.
## Install
```bash
npm install @tokenizerwa/web3auth-algorand
# peer deps you likely already have
npm install react @web3auth/modal
```
## Quick start
```tsx
import { AlgorandWeb3AuthProvider, useWeb3Auth, createWeb3AuthSigner } from '@tokenizerwa/web3auth-algorand'
const WEB3AUTH_CLIENT_ID = import.meta.env.VITE_WEB3AUTH_CLIENT_ID
function ConnectButton() {
const { isConnected, algorandAccount, login, logout, isLoading } = useWeb3Auth()
if (isConnected && algorandAccount) {
const signer = createWeb3AuthSigner(algorandAccount)
// use signer with AlgoKit / algosdk
}
return (
<button onClick={isConnected ? logout : login} disabled={isLoading}>
{isConnected ? 'Disconnect' : 'Sign in with Google'}
</button>
)
}
export function App() {
return (
<AlgorandWeb3AuthProvider
config={{
clientId: WEB3AUTH_CLIENT_ID,
// optional overrides:
// web3AuthNetwork: WEB3AUTH_NETWORK.SAPPHIRE_MAINNET,
// chainConfig: { rpcTarget: 'https://mainnet-api.algonode.cloud', displayName: 'Algorand MainNet' },
}}
>
<ConnectButton />
</AlgorandWeb3AuthProvider>
)
}
```
## UI add-on (optional)
If you want a plug-and-play Google button and dropdown, install the UI companion:
```bash
npm install @tokenizerwa/web3auth-algorand-ui
```
```tsx
import { AlgorandWeb3AuthProvider } from '@tokenizerwa/web3auth-algorand'
import { Web3AuthGoogleButton } from '@tokenizerwa/web3auth-algorand-ui'
<AlgorandWeb3AuthProvider config={{ clientId: WEB3AUTH_CLIENT_ID }}>
<Web3AuthGoogleButton />
</AlgorandWeb3AuthProvider>
```
## Notes
- You need a Web3Auth client id from the Web3Auth dashboard.
- Default network is Sapphire Devnet + Algorand TestNet RPC; override via `config.chainConfig` and `config.web3AuthNetwork`.
- The provider must wrap any component that calls `useWeb3Auth`.

View File

@ -0,0 +1,35 @@
{
"name": "@tokenizerwa/web3auth-algorand",
"version": "0.1.0",
"description": "Algorand + Web3Auth React provider, hooks, and signer helpers for Google login flows.",
"license": "MIT",
"type": "module",
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"files": [
"dist",
"src"
],
"sideEffects": false,
"peerDependencies": {
"react": ">=18.0.0"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"clean": "rimraf dist"
},
"dependencies": {
"@web3auth/base": "^9.7.0",
"@web3auth/base-provider": "^9.7.0",
"@web3auth/modal": "^9.7.0",
"algosdk": "^3.0.0"
},
"devDependencies": {
"typescript": "^5.1.6",
"rimraf": "^5.0.5"
}
}

View File

@ -0,0 +1,5 @@
export * from './provider'
export * from './types'
export * from './utils/algorandAdapter'
export * from './utils/web3authIntegration'
export * from './web3authConfig'

View File

@ -0,0 +1,187 @@
import { Web3Auth } from '@web3auth/modal'
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { AlgorandAccountFromWeb3Auth, getAlgorandAccount } from './utils/algorandAdapter'
import { getWeb3AuthUserInfo, initWeb3Auth, logoutFromWeb3Auth, Web3AuthUserInfo } from './web3authConfig'
import { type AlgorandWeb3AuthConfig } from './types'
export interface Web3AuthContextType {
isConnected: boolean
isLoading: boolean
isInitialized: boolean
error: string | null
web3AuthInstance: Web3Auth | null
algorandAccount: AlgorandAccountFromWeb3Auth | null
userInfo: Web3AuthUserInfo | null
login: () => Promise<void>
logout: () => Promise<void>
refreshUserInfo: () => Promise<void>
}
const Web3AuthContext = createContext<Web3AuthContextType | undefined>(undefined)
export function AlgorandWeb3AuthProvider({ config, children }: { config: AlgorandWeb3AuthConfig; children: ReactNode }) {
const [isInitialized, setIsInitialized] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [web3AuthInstance, setWeb3AuthInstance] = useState<Web3Auth | null>(null)
const [algorandAccount, setAlgorandAccount] = useState<AlgorandAccountFromWeb3Auth | null>(null)
const [userInfo, setUserInfo] = useState<Web3AuthUserInfo | null>(null)
useEffect(() => {
const initializeWeb3Auth = async () => {
try {
if (!config?.clientId) {
setError('Web3Auth clientId is required')
return
}
setIsLoading(true)
setError(null)
const web3auth = await initWeb3Auth(config)
setWeb3AuthInstance(web3auth)
if (web3auth.status === 'connected' && web3auth.provider) {
setIsConnected(true)
try {
const account = await getAlgorandAccount(web3auth.provider)
setAlgorandAccount(account)
} catch (err) {
setError('Failed to derive Algorand account. Please reconnect.')
}
try {
const userInformation = await getWeb3AuthUserInfo()
if (userInformation) {
setUserInfo(userInformation)
}
} catch (err) {
console.error('Failed to fetch user info:', err)
}
}
setIsInitialized(true)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Web3Auth'
console.error('WEB3AUTH: Initialization error:', err)
setError(errorMessage)
setIsInitialized(true)
} finally {
setIsLoading(false)
}
}
initializeWeb3Auth()
}, [config])
const login = async () => {
if (!web3AuthInstance) {
setError('Web3Auth not initialized')
return
}
if (!isInitialized) {
setError('Web3Auth is still initializing, please try again')
return
}
try {
setIsLoading(true)
setError(null)
const web3authProvider = await web3AuthInstance.connect()
if (!web3authProvider) {
throw new Error('Failed to connect Web3Auth provider')
}
setIsConnected(true)
try {
const account = await getAlgorandAccount(web3authProvider)
setAlgorandAccount(account)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to derive Algorand account'
setError(errorMessage)
}
try {
const userInformation = await getWeb3AuthUserInfo()
if (userInformation) {
setUserInfo(userInformation)
}
} catch (err) {
console.error('LOGIN: Failed to fetch user info:', err)
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Login failed'
console.error('LOGIN: Error:', err)
setError(errorMessage)
setIsConnected(false)
setAlgorandAccount(null)
} finally {
setIsLoading(false)
}
}
const logout = async () => {
try {
setIsLoading(true)
setError(null)
await logoutFromWeb3Auth()
setIsConnected(false)
setAlgorandAccount(null)
setUserInfo(null)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Logout failed'
console.error('LOGOUT: Error:', err)
setError(errorMessage)
setIsConnected(false)
setAlgorandAccount(null)
setUserInfo(null)
} finally {
setIsLoading(false)
}
}
const refreshUserInfo = async () => {
try {
const userInformation = await getWeb3AuthUserInfo()
if (userInformation) {
setUserInfo(userInformation)
}
} catch (err) {
console.error('REFRESH: Failed:', err)
}
}
const value: Web3AuthContextType = {
isConnected,
isLoading,
isInitialized,
error,
web3AuthInstance,
algorandAccount,
userInfo,
login,
logout,
refreshUserInfo,
}
return <Web3AuthContext.Provider value={value}>{children}</Web3AuthContext.Provider>
}
export function useWeb3Auth(): Web3AuthContextType {
const context = useContext(Web3AuthContext)
if (context === undefined) {
throw new Error('useWeb3Auth must be used within an AlgorandWeb3AuthProvider')
}
return context
}

View File

@ -0,0 +1,31 @@
import { CHAIN_NAMESPACES, WEB3AUTH_NETWORK, type IWeb3AuthConfiguration } from '@web3auth/base'
import type { CommonPrivateKeyProviderConfig } from '@web3auth/base-provider'
export type { AlgorandAccountFromWeb3Auth } from './utils/algorandAdapter'
export interface AlgorandChainConfig extends CommonPrivateKeyProviderConfig['config']['chainConfig'] {}
export interface AlgorandWeb3AuthConfig {
clientId: string
web3AuthNetwork?: WEB3AUTH_NETWORK
chainConfig?: AlgorandChainConfig
uiConfig?: IWeb3AuthConfiguration['uiConfig']
}
export const DEFAULT_ALGORAND_CHAIN_CONFIG: AlgorandChainConfig = {
chainNamespace: CHAIN_NAMESPACES.OTHER,
chainId: '0x1',
rpcTarget: 'https://testnet-api.algonode.cloud',
displayName: 'Algorand TestNet',
blockExplorerUrl: 'https://testnet.algoexplorer.io',
ticker: 'ALGO',
tickerName: 'Algorand',
}
export const DEFAULT_UI_CONFIG: IWeb3AuthConfiguration['uiConfig'] = {
appName: 'Algorand Web3Auth',
mode: 'light',
loginMethodsOrder: ['google'],
}
export const DEFAULT_WEB3AUTH_NETWORK = WEB3AUTH_NETWORK.SAPPHIRE_DEVNET

View File

@ -0,0 +1,79 @@
import { IProvider } from '@web3auth/base'
import algosdk from 'algosdk'
export interface AlgorandAccountFromWeb3Auth {
address: string
mnemonic: string
secretKey: Uint8Array
}
export async function getAlgorandAccount(provider: IProvider): Promise<AlgorandAccountFromWeb3Auth> {
if (!provider) {
throw new Error('Provider is required to derive Algorand account')
}
try {
const privKey = await provider.request({
method: 'private_key',
})
if (!privKey || typeof privKey !== 'string') {
throw new Error('Failed to retrieve private key from Web3Auth provider')
}
const cleanHexKey = privKey.startsWith('0x') ? privKey.slice(2) : privKey
const privateKeyBytes = new Uint8Array(Buffer.from(cleanHexKey, 'hex'))
const ed25519SecretKey = privateKeyBytes.slice(0, 32)
const mnemonic = algosdk.secretKeyToMnemonic(ed25519SecretKey)
const accountFromMnemonic = algosdk.mnemonicToSecretKey(mnemonic)
return {
address: accountFromMnemonic.addr,
mnemonic: mnemonic,
secretKey: accountFromMnemonic.sk,
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to derive Algorand account from Web3Auth: ${error.message}`)
}
throw error
}
}
export function createAlgorandSigner(secretKey: Uint8Array) {
return async (transactions: Uint8Array[]): Promise<Uint8Array[]> => {
const signedTxns: Uint8Array[] = []
for (const txn of transactions) {
try {
const signedTxn = algosdk.signTransaction(txn, secretKey)
signedTxns.push(signedTxn.blob)
} catch (error) {
throw new Error(`Failed to sign transaction: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
return signedTxns
}
}
export function isValidAlgorandAddress(address: string): boolean {
if (!address || typeof address !== 'string') {
return false
}
try {
algosdk.decodeAddress(address)
return true
} catch {
return false
}
}
export function getPublicKeyFromSecretKey(secretKey: Uint8Array): Uint8Array {
if (secretKey.length !== 64) {
throw new Error(`Invalid secret key length: expected 64 bytes, got ${secretKey.length}`)
}
return secretKey.slice(32)
}

View File

@ -0,0 +1,125 @@
import algosdk, { TransactionSigner } from 'algosdk'
import { type AlgorandAccountFromWeb3Auth } from './algorandAdapter'
export interface AlgorandTransactionSigner {
sign: (transactions: Uint8Array[]) => Promise<Uint8Array[]>
sender?: string
}
export function createWeb3AuthSigner(account: AlgorandAccountFromWeb3Auth): TransactionSigner {
const sk = account.secretKey
const addr = account.address
const secretKey: Uint8Array =
sk instanceof Uint8Array
? sk
: Array.isArray(sk)
? Uint8Array.from(sk)
: (() => {
throw new Error('Web3Auth secretKey is not a Uint8Array (or number[]). Cannot sign transactions.')
})()
return algosdk.makeBasicAccountTransactionSigner({
addr,
sk: secretKey,
})
}
export function createWeb3AuthSignerObject(account: AlgorandAccountFromWeb3Auth): AlgorandTransactionSigner {
const signerFn = createWeb3AuthSigner(account)
const sign = async (transactions: Uint8Array[]) => {
const txns = transactions.map((b) => algosdk.decodeUnsignedTransaction(b))
const signed = await signerFn(txns, txns.map((_, i) => i))
return signed
}
return {
sign,
sender: account.address,
}
}
export function createWeb3AuthMultiSigSigner(account: AlgorandAccountFromWeb3Auth) {
return {
signer: createWeb3AuthSigner(account),
sender: account.address,
account,
}
}
export function getWeb3AuthAccountInfo(account: AlgorandAccountFromWeb3Auth) {
const decodedAddress = algosdk.decodeAddress(account.address)
return {
address: account.address,
publicKeyBytes: decodedAddress.publicKey,
publicKeyBase64: Buffer.from(decodedAddress.publicKey).toString('base64'),
secretKeyHex: Buffer.from(account.secretKey).toString('hex'),
mnemonicPhrase: account.mnemonic,
}
}
export function verifyWeb3AuthSignature(signedTransaction: Uint8Array, account: AlgorandAccountFromWeb3Auth): boolean {
try {
const decodedTxn = algosdk.decodeSignedTransaction(signedTransaction)
const txnSigner = decodedTxn.sig?.signers?.[0] ?? decodedTxn.sig?.signer
if (!txnSigner) return false
const decodedAddress = algosdk.decodeAddress(account.address)
return Buffer.from(txnSigner).equals(decodedAddress.publicKey)
} catch (error) {
console.error('Error verifying signature:', error)
return false
}
}
export function analyzeTransactionGroup(transactions: Uint8Array[]) {
return {
count: transactions.length,
totalSize: transactions.reduce((sum, txn) => sum + txn.length, 0),
averageSize: transactions.reduce((sum, txn) => sum + txn.length, 0) / transactions.length,
}
}
export function formatAmount(amount: bigint | number, decimals: number = 6): string {
const amountStr = amount.toString()
const decimalPoints = decimals
if (amountStr.length <= decimalPoints) {
return `0.${amountStr.padStart(decimalPoints, '0')}`
}
const integerPart = amountStr.slice(0, -decimalPoints)
const decimalPart = amountStr.slice(-decimalPoints)
return `${integerPart}.${decimalPart}`
}
export function parseAmount(amount: string, decimals: number = 6): bigint {
const trimmed = amount.trim()
if (!trimmed) {
throw new Error('Amount is required')
}
if (!/^\d+(\.\d+)?$/.test(trimmed)) {
throw new Error('Invalid amount format')
}
const [integerPart = '0', decimalPart = ''] = trimmed.split('.')
if (decimalPart.length > decimals) {
throw new Error(`Too many decimal places (maximum ${decimals})`)
}
const paddedDecimal = decimalPart.padEnd(decimals, '0')
const combined = integerPart + paddedDecimal
return BigInt(combined)
}
export function hasSufficientBalance(balance: bigint, requiredAmount: bigint, minFee: bigint = BigInt(1000)): boolean {
const totalRequired = requiredAmount + minFee
return balance >= totalRequired
}

View File

@ -0,0 +1,97 @@
import { CHAIN_NAMESPACES, IProvider, WEB3AUTH_NETWORK, type IWeb3AuthConfiguration } from '@web3auth/base'
import { CommonPrivateKeyProvider, type CommonPrivateKeyProviderConfig } from '@web3auth/base-provider'
import { Web3Auth, type Web3AuthOptions } from '@web3auth/modal'
import {
DEFAULT_ALGORAND_CHAIN_CONFIG,
DEFAULT_UI_CONFIG,
DEFAULT_WEB3AUTH_NETWORK,
type AlgorandWeb3AuthConfig,
} from './types'
let web3authInstance: Web3Auth | null = null
const sanitizeChainConfig = (chainConfig?: CommonPrivateKeyProviderConfig['config']['chainConfig']) => {
if (!chainConfig) return DEFAULT_ALGORAND_CHAIN_CONFIG
return {
...DEFAULT_ALGORAND_CHAIN_CONFIG,
...chainConfig,
chainNamespace: chainConfig.chainNamespace ?? CHAIN_NAMESPACES.OTHER,
}
}
const sanitizeUiConfig = (uiConfig?: IWeb3AuthConfiguration['uiConfig']) => ({
...DEFAULT_UI_CONFIG,
...uiConfig,
})
export async function initWeb3Auth(config: AlgorandWeb3AuthConfig): Promise<Web3Auth> {
if (web3authInstance) {
return web3authInstance
}
const chainConfig = sanitizeChainConfig(config.chainConfig)
const uiConfig = sanitizeUiConfig(config.uiConfig)
const web3AuthNetwork = config.web3AuthNetwork ?? DEFAULT_WEB3AUTH_NETWORK
const privateKeyProvider = new CommonPrivateKeyProvider({
config: {
chainConfig,
},
})
const web3AuthConfig: Web3AuthOptions = {
clientId: config.clientId,
web3AuthNetwork,
privateKeyProvider,
uiConfig,
}
web3authInstance = new Web3Auth(web3AuthConfig)
await web3authInstance.initModal()
return web3authInstance
}
export function getWeb3AuthInstance(): Web3Auth | null {
return web3authInstance
}
export function getWeb3AuthProvider(): IProvider | null {
return web3authInstance?.provider || null
}
export function isWeb3AuthConnected(): boolean {
return web3authInstance?.status === 'connected'
}
export interface Web3AuthUserInfo {
email?: string
name?: string
profileImage?: string
[key: string]: unknown
}
export async function getWeb3AuthUserInfo(): Promise<Web3AuthUserInfo | null> {
if (!web3authInstance || !isWeb3AuthConnected()) {
return null
}
try {
const userInfo = await web3authInstance.getUserInfo()
return userInfo as Web3AuthUserInfo
} catch {
return null
}
}
export async function logoutFromWeb3Auth(): Promise<void> {
if (!web3authInstance) {
return
}
try {
await web3authInstance.logout()
} finally {
web3authInstance = null
}
}

View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "Node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"jsx": "react-jsx",
"types": ["react"]
},
"include": ["src/**/*"]
}