diff --git a/.env.example b/.env.example index 8bf22aa..10f325f 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,28 @@ # Environment variables # Required: Set the base URL of the application. Mostly used for the SEO tags. No trailing slash. -NEXT_PUBLIC_BASE_URL= +NEXT_PUBLIC_BASE_URL=http://localhost:3005 # ------ Dev ------ # Optional: Set the dev mode ("true" or "false"). Default to "false". NEXT_PUBLIC_DEV_MODE=false +# ------ Telemetry ------ + +# Optional: Set the log level ("error", "warn", "info", "debug"). Default to "info". +NEXT_PUBLIC_LOG_LEVEL=info + # ------ Verida Network ------ # Optional: Set the Verida Network ("myrtle", "banksia", "devnet", "local"). Default to "banksia". NEXT_PUBLIC_VERIDA_NETWORK=banksia -# Optional: Set the Verida RPC URL. Default to the SDK default public RPC URL. Strongly suggested to set a custom paid one for reliability. Production deployments should set this. -NEXT_PUBLIC_VERIDA_RPC_URL= +# Required: Set the Verida DID for this application. Required for Verida auth. +NEXT_PUBLIC_VERIDA_APP_DID= # Data connector server URL NEXT_PUBLIC_DCS_URL= # Endpoint to initiate auth consent for API token with Verida Vault -NEXT_PUBLIC_VAULT_AUTH_ENDPOINT= \ No newline at end of file +NEXT_PUBLIC_VAULT_AUTH_ENDPOINT= diff --git a/README.md b/README.md index fe49b51..02a07ff 100644 --- a/README.md +++ b/README.md @@ -24,4 +24,4 @@ Run the development server: yarn run dev ``` -The application will be available at [http://localhost:3000](http://localhost:3000) +The application will be available at [http://localhost:3005](http://localhost:3005) diff --git a/eslint.config.mjs b/eslint.config.mjs index 13ecd15..86cfff3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -25,6 +25,7 @@ const config = [ rules: { "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/no-empty-object-type": "off", "no-console": "warn", "prettier/prettier": "warn", }, diff --git a/next.config.ts b/next.config.ts index bc2a70e..0a1f715 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,23 +5,11 @@ const nextConfig: NextConfig = { // TODO: Enable the check again. This is now to disable next.js checking node_modules type errors on building the app ignoreBuildErrors: true, }, - webpack: (config, { isServer }) => { + webpack: (config) => { config.node = { __dirname: true, } - config.module.rules.push({ - test: /\.(woff|woff2|eot|ttf|otf)$/, - use: { - loader: "file-loader", - options: { - name: "[name].[ext]", - publicPath: "/_next/static/fonts/", - outputPath: `${isServer ? "../" : ""}static/fonts/`, - }, - }, - }) - return config }, images: { diff --git a/package.json b/package.json index 2a06120..0c180a0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "version": "npx genversion --es6 --double ./src/config/version.ts", "postinstall": "yarn run version", "predev": "yarn run version", - "dev": "next dev", + "dev": "next dev -p 3005", "prebuild": "yarn run version", "build": "next build", "prestart": "yarn run version", @@ -20,7 +20,7 @@ "fix": "yarn run fix:format && yarn run fix:lint" }, "dependencies": { - "@headlessui/react": "^2.2.0", + "@radix-ui/react-accordion": "^1.2.8", "@radix-ui/react-alert-dialog": "^1.1.5", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.3", @@ -34,8 +34,9 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.7", - "@verida/types": "^4.4.0", - "@verida/web-helpers": "^4.4.1", + "@tanstack/query-sync-storage-persister": "^5.74.6", + "@tanstack/react-query": "^5.74.4", + "@tanstack/react-query-persist-client": "^5.74.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "genversion": "^3.2.0", @@ -55,10 +56,12 @@ "@eslint/eslintrc": "^3.2.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.10", + "@tanstack/react-query-devtools": "^5.74.6", "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/node": "^22.12.0", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", + "@verida/types": "^4.4.1", "autoprefixer": "^10.4.20", "eslint": "^9.19.0", "eslint-config-next": "^15.1.6", @@ -66,12 +69,9 @@ "eslint-plugin-prettier": "^5.2.3", "file-loader": "^6.2.0", "postcss": "^8.5.1", - "prettier": "^3.4.2", + "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", "tailwindcss": "^3.4.17", - "typescript": "^5.7.3" - }, - "resolutions": { - "qrcode-with-logos": "1.0.3" + "typescript": "^5.8.3" } } diff --git a/public/fonts/Sora-Regular.ttf b/public/fonts/Sora-Regular.ttf deleted file mode 100644 index b1f11ea..0000000 Binary files a/public/fonts/Sora-Regular.ttf and /dev/null differ diff --git a/public/images/verida_vault_logo_for_connect.png b/public/images/verida_vault_logo_for_connect.png deleted file mode 100644 index de7a1fb..0000000 Binary files a/public/images/verida_vault_logo_for_connect.png and /dev/null differ diff --git a/src/app/(connected)/credits/loading.tsx b/src/app/(connected)/credits/loading.tsx new file mode 100644 index 0000000..7e520f8 --- /dev/null +++ b/src/app/(connected)/credits/loading.tsx @@ -0,0 +1,4 @@ +export default function CreditsLoading() { + return
Loading...
+} +CreditsLoading.displayName = "CreditsLoading" diff --git a/src/app/(connected)/credits/page.tsx b/src/app/(connected)/credits/page.tsx index c12357e..b1acfb4 100644 --- a/src/app/(connected)/credits/page.tsx +++ b/src/app/(connected)/credits/page.tsx @@ -1,24 +1,21 @@ "use client" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -// shadcn/ui components (adjust imports to match your actual setup) import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +// eslint-disable-next-line @typescript-eslint/no-unused-vars import { getAccount, getVdaPrice, submitDeposit } from "@/features/dcs/api" import type { BillingAccount } from "@/features/dcs/interfaces" import { accountBalance, accountCredits } from "@/features/dcs/utils" -import { useVerida } from "@/features/verida/hooks/use-verida" // Regex for validating Ethereum/Polygon addresses const ethAddressRegex = /^0x[a-fA-F0-9]{40}$/ export default function CreditsPage() { - const { getAccountSessionToken, webUserInstanceRef } = useVerida() - // Track current step (1 or 2) const [step, setStep] = useState(1) @@ -83,54 +80,56 @@ export default function CreditsPage() { } // If we get here, we have transactionHash - const sessionToken = await getAccountSessionToken() - const veridaAccount = webUserInstanceRef.current.getAccount() + // const sessionToken = await getAccountSessionToken() try { - await submitDeposit( - veridaAccount, - sessionToken, - walletAddress, - tokenAmount, - transactionHash - ) + // await submitDeposit( + // veridaAccount, + // sessionToken, + // walletAddress, + // tokenAmount, + // transactionHash + // ) // Reload account so new credits are displayed - await loadAccount() + // await loadAccount() // Reset the form setWalletAddress("") setTokenAmount("") setTransactionHash("") handleBack() - } catch (err: any) { - setSubmitError(`${err.message}`) + } catch (error) { + setSubmitError( + error instanceof Error ? error.message : "An unknown error occurred" + ) } } - async function loadAccount() { - const sessionToken = await getAccountSessionToken() - const account = await getAccount(sessionToken) + // const loadAccount = useCallback(async () => { + // // const sessionToken = await getAccountSessionToken() + // const account = await getAccount(sessionToken) - if (account) { - setAccount(account) - const credits = await accountCredits(sessionToken, account) - console.log(credits) - if (credits) { - setCredits(credits) - } - } - } + // if (account) { + // setAccount(account) + // const credits = await accountCredits(sessionToken, account) + // // eslint-disable-next-line no-console -- TODO: Replace with Logger + // console.log(credits) + // if (credits) { + // setCredits(credits) + // } + // } + // }, []) // A function that does something on mount - async function onLoad() { - await loadAccount() - } + const onLoad = useCallback(async () => { + // await loadAccount() + }, []) // Run the function once, when the component mounts useEffect(() => { onLoad() - }, []) + }, [onLoad]) return (
diff --git a/src/app/(connected)/dashboard/loading.tsx b/src/app/(connected)/dashboard/loading.tsx new file mode 100644 index 0000000..493499a --- /dev/null +++ b/src/app/(connected)/dashboard/loading.tsx @@ -0,0 +1,4 @@ +export default function DashboardLoading() { + return
Loading...
+} +DashboardLoading.displayName = "DashboardLoading" diff --git a/src/app/(connected)/dashboard/page.tsx b/src/app/(connected)/dashboard/page.tsx index 5421d97..b2573e0 100644 --- a/src/app/(connected)/dashboard/page.tsx +++ b/src/app/(connected)/dashboard/page.tsx @@ -1,113 +1,107 @@ "use client" import { Info } from "lucide-react" -import Link from "next/link" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { accountUsage, getAccount, registerAccount } from "@/features/dcs/api" import type { BillingAccount, UsageStats } from "@/features/dcs/interfaces" import { accountCredits } from "@/features/dcs/utils" -import { useVerida } from "@/features/verida/hooks/use-verida" +import { useVeridaAuth } from "@/features/verida-auth/hooks/use-verida-auth" -// webUserInstanceRef export default function DashboardPage() { - const { did, profile, getAccountSessionToken } = useVerida() + const { authDetails } = useVeridaAuth() + + // TODO: Optimise by creating queries for these states and a mutation for the registration const [account, setAccount] = useState(null) const [credits, setCredits] = useState(null) const [usageStats, setUsageStats] = useState(null) - // const imgSrc = `data:image/png;base64,${profile?.avatarUri}` - - async function handleRegisterClick() { - // Place your registration logic here - const sessionToken = await getAccountSessionToken() - const newAccount = await registerAccount(sessionToken) - - if (newAccount) { - loadAccount() + const loadAccount = useCallback(async () => { + if (!authDetails?.token) { + return } - } - // A function that does something on mount - async function loadAccount() { - const sessionToken = await getAccountSessionToken() - const account = await getAccount(sessionToken) + const account = await getAccount(authDetails.token) if (account) { setAccount(account) - setCredits(await accountCredits(sessionToken, account)) - setUsageStats(await accountUsage(sessionToken)) + setCredits(await accountCredits(authDetails.token, account)) + setUsageStats(await accountUsage(authDetails.token)) } - } + }, [authDetails]) + + const handleRegisterClick = useCallback(async () => { + if (!authDetails?.token) { + return + } + + const newAccount = await registerAccount(authDetails.token) + + if (newAccount) { + loadAccount() + } + }, [authDetails, loadAccount]) - // Run the function once, when the component mounts useEffect(() => { loadAccount() - }, []) + }, [loadAccount]) return ( -
+

Dashboard

-

- Welcome {profile?.name} ( - {did}) +

+ {/* TODO: Display user name from its profile, have to fetch it */} + Welcome {authDetails?.did}

- {!account && ( - -
- {/* Info icon (instead of a warning or error icon) */} - -
- Registration required! - - You must register your developer account, and obtain 200 free - VDA credits for API usage. - -
-
- -
-
)} - {account && ( -
-
- {credits && ( -

- You currently have{" "} - - - {credits} credits - - . - -

- )} -
-
- {usageStats && ( -
-

- {usageStats.connectedAccounts} accounts have - connected to your application. -

-

- {usageStats.requests} API requests have been - served. -

-
- )} -
-
- )}
) } - DashboardPage.displayName = "DashboardPage" diff --git a/src/app/(connected)/layout.tsx b/src/app/(connected)/layout.tsx index 01c8de9..fb2033d 100644 --- a/src/app/(connected)/layout.tsx +++ b/src/app/(connected)/layout.tsx @@ -1,11 +1,6 @@ -import { AppConnectionHandler } from "@/components/app-connection-handler" import { AppProviders } from "@/components/app-providers" -import { SidebarNav } from "@/components/sidebar-nav" - -export const metadata = { - title: "My App", - description: "Generated by Next.js", -} +import { AppAuthenticationHandler } from "@/features/auth/components/app-authentication-handler" +import { Sidebar } from "@/features/sidebar/components/sidebar" export interface AppLayoutProps { children: React.ReactNode @@ -15,14 +10,13 @@ export default function AppLayout(props: AppLayoutProps) { const { children } = props return ( - + -
- {/* Sidebar */} +
-
+
{/* */}
@@ -32,7 +26,7 @@ export default function AppLayout(props: AppLayoutProps) {
- + ) } AppLayout.displayName = "AppLayout" diff --git a/src/app/(connected)/sandbox/api-requests/loading.tsx b/src/app/(connected)/sandbox/api-requests/loading.tsx new file mode 100644 index 0000000..38a80f0 --- /dev/null +++ b/src/app/(connected)/sandbox/api-requests/loading.tsx @@ -0,0 +1,4 @@ +export default function ApiRequestsLoading() { + return
Loading...
+} +ApiRequestsLoading.displayName = "ApiRequestsLoading" diff --git a/src/app/(connected)/sandbox/api-requests/page.tsx b/src/app/(connected)/sandbox/api-requests/page.tsx index 701face..7c0897f 100644 --- a/src/app/(connected)/sandbox/api-requests/page.tsx +++ b/src/app/(connected)/sandbox/api-requests/page.tsx @@ -12,7 +12,13 @@ import React, { useEffect, useState } from "react" import ReactMarkdown from "react-markdown" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" import { DialogHeader } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" @@ -28,14 +34,12 @@ import { Textarea } from "@/components/ui/textarea" import { apiEndpoints } from "@/config/apiEndpoints" import { commonConfig } from "@/config/common" import { SCHEMA_MAP } from "@/features/dcs/schemas" +import { SANDBOX_AUTH_TOKEN_STORAGE_KEY } from "@/features/sandbox/constants" -// Example "apiEndpoints" object from endpoints.js -// (truncated for brevity—include all endpoints as needed) const BASE_API = commonConfig.DCS_URL type EndpointKey = keyof typeof apiEndpoints -// For code language selection const CODE_LANGUAGES = ["curl", "nodejs", "jquery", "php", "python"] as const type CodeLang = (typeof CODE_LANGUAGES)[number] @@ -69,7 +73,7 @@ export default function ApiRequestsPage() { // On mount, load the veridaKey from localStorage useEffect(() => { - const key = localStorage.getItem("authToken") + const key = localStorage.getItem(SANDBOX_AUTH_TOKEN_STORAGE_KEY) setVeridaKey(key) }, [router]) @@ -452,139 +456,141 @@ print(response.json())` const codeSamples = buildCodeExamples() return ( -
- - - Developer API Interface - - -

Use this interface to easily generate API queries on your data.

-

- API queries are using the token saved at the - Token Info page -

+ + + Developer API Interface + + Use this interface to easily generate API queries on your data. + + + API queries are using the token saved on the{" "} + + Token Info + {" "} + page + + + +
+

API Endpoint

+
-
-

API Endpoint

+ {/* Endpoint Selector */} +
+ + +
+
+              {apiEndpoints[endpoint]!.documentation}
+            
+
- {/* Endpoint Selector */} -
- - -
-
-                {apiEndpoints[endpoint]!.documentation}
-              
-
-
+ {/* Base URL Input (optional) */} +
+ + setBaseUrl(e.target.value)} + placeholder="https://your.custom.server" + disabled={true} + /> +
- {/* Base URL Input (optional) */} -
- - setBaseUrl(e.target.value)} - placeholder="https://your.custom.server" - disabled={true} - /> + {/* URL Variables */} + {Object.entries(apiEndpoints[endpoint]!.urlVariables || {}).length > + 0 && ( +
+

URL Variables

+ {renderUrlVariableFields()}
+ )} - {/* URL Variables */} - {Object.entries(apiEndpoints[endpoint]!.urlVariables || {}).length > - 0 && ( -
-

URL Variables

- {renderUrlVariableFields()} -
- )} + {/* Params */} + {Object.entries(apiEndpoints[endpoint]!.params || {}).length > 0 && ( +
+

Parameters

+ {renderParamsFields()} +
+ )} - {/* Params */} - {Object.entries(apiEndpoints[endpoint]!.params || {}).length > 0 && ( -
-

Parameters

- {renderParamsFields()} + {/* Run Endpoint Button */} + + + {/* Code Examples */} +
+
+
+ +
- )} - - {/* Run Endpoint Button */} - - - {/* Code Examples */} -
-
-
- - -
-
- setShowPrivateKey(!!checked)} - /> - -
+
+ setShowPrivateKey(!!checked)} + /> +
+
-
-
-                {codeSamples[codeLang]}
-              
-
+
+
+              {codeSamples[codeLang]}
+            
+
- {/* Result */} -
-

- Result {resultError ? "(Error)" : ""} -

-
-
-                {resultError}
-                {result}
-              
-
+ {/* Result */} +
+

+ Result {resultError ? "(Error)" : ""} +

+
+
+              {resultError}
+              {result}
+            
- - -
+
+ + ) } diff --git a/src/app/(connected)/sandbox/browse-data/loading.tsx b/src/app/(connected)/sandbox/browse-data/loading.tsx new file mode 100644 index 0000000..b8ba30f --- /dev/null +++ b/src/app/(connected)/sandbox/browse-data/loading.tsx @@ -0,0 +1,4 @@ +export default function BrowseDataLoading() { + return
Loading...
+} +BrowseDataLoading.displayName = "BrowseDataLoading" diff --git a/src/app/(connected)/sandbox/browse-data/page.tsx b/src/app/(connected)/sandbox/browse-data/page.tsx index fcf23ee..cb642ca 100644 --- a/src/app/(connected)/sandbox/browse-data/page.tsx +++ b/src/app/(connected)/sandbox/browse-data/page.tsx @@ -7,7 +7,13 @@ import React, { useEffect, useState } from "react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" // shadcn/ui components (adjust imports to match your project) import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" import { Dialog, DialogContent, @@ -28,6 +34,7 @@ import { } from "@/components/ui/select" import { commonConfig } from "@/config/common" import { SCHEMA_MAP } from "@/features/dcs/schemas" +import { SANDBOX_AUTH_TOKEN_STORAGE_KEY } from "@/features/sandbox/constants" interface ApiItem { [key: string]: any @@ -72,7 +79,7 @@ export default function BrowseDataPage() { // If no key, redirect to /login // ---------------------------- useEffect(() => { - const key = localStorage.getItem("authToken") + const key = localStorage.getItem(SANDBOX_AUTH_TOKEN_STORAGE_KEY) if (!key) { router.push("/sandbox/generate-token") return @@ -90,11 +97,10 @@ export default function BrowseDataPage() { // else use defaults. For simplicity, we’ll just load from localStorage here. // ---------------------------- useEffect(() => { - // Default to "Connections" schema or the first in SCHEMA_MAP + // Default to "Email" schema or the first in SCHEMA_MAP // or load from localStorage, etc. - const storedSchema = - localStorage.getItem("schema") || SCHEMA_MAP["Connections"] - setSchema(storedSchema!) + const storedSchema = localStorage.getItem("schema") || SCHEMA_MAP["Email"] + setSchema(storedSchema || "") // In your original code, limit=10, offset=0 by default setLimit(10) @@ -263,14 +269,21 @@ export default function BrowseDataPage() { } } - // ---------------------------- - // Render - // ---------------------------- return ( -
+
Browse Data + + Use this interface to browse the data connected via your API key. + + + API queries are using the token saved on the{" "} + + Token Info + {" "} + page + {/* Error Alert */} @@ -281,14 +294,6 @@ export default function BrowseDataPage() { )} -

- Use this interface to browse the data connected via your API key. -

-

- API queries are using the token saved at the - Token Info page -

- {/* Query Form */}
{/* Schema */} diff --git a/src/app/(connected)/sandbox/generate-token/loading.tsx b/src/app/(connected)/sandbox/generate-token/loading.tsx new file mode 100644 index 0000000..4537ba7 --- /dev/null +++ b/src/app/(connected)/sandbox/generate-token/loading.tsx @@ -0,0 +1,4 @@ +export default function GenerateTokenLoading() { + return
Loading...
+} +GenerateTokenLoading.displayName = "GenerateTokenLoading" diff --git a/src/app/(connected)/sandbox/generate-token/page.tsx b/src/app/(connected)/sandbox/generate-token/page.tsx index d9906f3..f91441e 100644 --- a/src/app/(connected)/sandbox/generate-token/page.tsx +++ b/src/app/(connected)/sandbox/generate-token/page.tsx @@ -1,18 +1,32 @@ "use client" -import { Disclosure } from "@headlessui/react" -import Image from "next/image" -import React, { useEffect, useState } from "react" - +import Link from "next/link" +import React, { useEffect, useMemo, useState } from "react" + +import { VeridaNetworkLogo } from "@/assets/verida-network-logo" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" import { commonConfig } from "@/config/common" import { fetchScopes } from "@/features/dcs/api" import type { Scope } from "@/features/dcs/interfaces" -import { useVerida } from "@/features/verida/hooks/use-verida" +import { getTokenGeneratedPageRoute } from "@/features/routes/utils" +import { useVeridaAuth } from "@/features/verida-auth/hooks/use-verida-auth" +import type { VeridaAuthRequest } from "@/features/verida-auth/type" +import { buildVeridaAuthRequestUrl } from "@/features/verida-auth/utils" const DEFAULT_SCOPES = [ "api:ds-query", @@ -22,17 +36,13 @@ const DEFAULT_SCOPES = [ "ds:social-email", ] -const AUTH_ENDPOINT = commonConfig.VAULT_AUTH_ENDPOINT -const RETURN_URL = `${commonConfig.BASE_URL}/sandbox/token-generated` - export default function GenerateApiKeyPage() { const [scopes, setScopes] = useState>({}) - const { did } = useVerida() - const [selectedScopes, setSelectedScopes] = useState([ - ...DEFAULT_SCOPES, - ]) + const { authDetails } = useVeridaAuth() + const [selectedScopes, setSelectedScopes] = useState(DEFAULT_SCOPES) async function onLoad() { + // TODO: Optimise with a React Query hook setScopes(await fetchScopes()) } @@ -40,15 +50,21 @@ export default function GenerateApiKeyPage() { onLoad() }, []) - function buildConnectUrl(): string { - const redirectUrl = new URL(AUTH_ENDPOINT) - for (const scope of selectedScopes) { - redirectUrl.searchParams.append("scopes", scope) + const sandboxVeridaAuthRequestUrl = useMemo(() => { + if (!authDetails?.did) { + return "#" } - redirectUrl.searchParams.append("redirectUrl", RETURN_URL) - redirectUrl.searchParams.append("appDID", did!) - return redirectUrl.toString() - } + + const request: VeridaAuthRequest = { + appDid: authDetails.did, + payer: "app", + scopes: selectedScopes, + redirectUrl: `${commonConfig.BASE_URL}${getTokenGeneratedPageRoute()}`, + } + + const url = buildVeridaAuthRequestUrl(request) + return url.toString() + }, [authDetails?.did, selectedScopes]) function handleScopeToggle(scope: string, checked: boolean) { setSelectedScopes((prev) => { @@ -63,153 +79,116 @@ export default function GenerateApiKeyPage() { }) } - function handleConnectVerida() { - window.location.href = buildConnectUrl() - } - - // Render return ( -
- - - Connect to Verida - - - -

- Use this example page to select the scopes you wish to request, then - click Connect Verida to be directed to the Verida - Vault to obtain an API authentication token. -

- - {/* Scopes list */} -
- {Object.entries(scopes).length === 0 && ( -

Loading scopes...

- )} - - {/* Collapsible sections for different scope groups */} -
- {/* API Scopes Section */} - - {({ open }) => ( -
- - API Scopes {open ? "-" : "+"} - - -
- {Object.entries(scopes) - .filter(([key, scope]) => scope.type === "api") - .map(([scopeKey, scopeDef], idx) => { - const isChecked = selectedScopes.includes(scopeKey) - return ( -
- - handleScopeToggle(scopeKey, checked) - } - /> - -
- ) - })} + + + Connect to Verida + + Use this example page to select the scopes you wish to request, then + click Connect Verida to be + directed to the Verida Vault to obtain an API authentication token. + + + + {Object.entries(scopes).length === 0 && ( +

Loading scopes...

+ )} + + + Operations (API) Scopes + +
+ {Object.entries(scopes) + .filter(([, scope]) => scope.type === "api") + .map(([scopeKey, scopeDef], idx) => { + const isChecked = selectedScopes.includes(scopeKey) + return ( +
+ + handleScopeToggle(scopeKey, checked) + } + /> +
- -
- )} - - - {/* Datastore Scopes Section */} - - {({ open }) => ( -
- - Datastore Scopes {open ? "-" : "+"} - - -
- {Object.entries(scopes) - .filter(([key, scope]) => scope.type === "ds") - .map(([scopeKey, scopeDef], idx) => { - const isChecked = selectedScopes.includes(scopeKey) - return ( -
- - handleScopeToggle(scopeKey, checked) - } - /> - -
- ) - })} + ) + })} +
+ + + + Data Scopes + +
+ {Object.entries(scopes) + .filter(([, scope]) => scope.type === "ds") + .map(([scopeKey, scopeDef], idx) => { + const isChecked = selectedScopes.includes(scopeKey) + return ( +
+ + handleScopeToggle(scopeKey, checked) + } + /> +
- -
- )} - -
-
- - - - {/* Connect Section */} -
- - Connect Verida - -
-

Connect URL:

-

{buildConnectUrl()}

-
+ ) + })} +
+ + + + +
+ +
+

Connect URL:

+

{sandboxVeridaAuthRequestUrl}

- - -
+
+ + ) } diff --git a/src/app/(connected)/sandbox/loading.tsx b/src/app/(connected)/sandbox/loading.tsx new file mode 100644 index 0000000..628bb86 --- /dev/null +++ b/src/app/(connected)/sandbox/loading.tsx @@ -0,0 +1,4 @@ +export default function SandboxLoading() { + return
Loading...
+} +SandboxLoading.displayName = "SandboxLoading" diff --git a/src/app/(connected)/sandbox/page.tsx b/src/app/(connected)/sandbox/page.tsx index 8863c0e..aaaab36 100644 --- a/src/app/(connected)/sandbox/page.tsx +++ b/src/app/(connected)/sandbox/page.tsx @@ -1,6 +1,4 @@ -"use client" - -export default function SandboxIndexPage() { +export default function SandboxPage() { return (

Sandbox

@@ -12,3 +10,4 @@ export default function SandboxIndexPage() {
) } +SandboxPage.displayName = "SandboxPage" diff --git a/src/app/(connected)/sandbox/token-generated/loading.tsx b/src/app/(connected)/sandbox/token-generated/loading.tsx new file mode 100644 index 0000000..dd33eb7 --- /dev/null +++ b/src/app/(connected)/sandbox/token-generated/loading.tsx @@ -0,0 +1,4 @@ +export default function TokenGeneratedLoading() { + return
Loading...
+} +TokenGeneratedLoading.displayName = "TokenGeneratedLoading" diff --git a/src/app/(connected)/sandbox/token-generated/page.tsx b/src/app/(connected)/sandbox/token-generated/page.tsx index 32af375..f6bb9b0 100644 --- a/src/app/(connected)/sandbox/token-generated/page.tsx +++ b/src/app/(connected)/sandbox/token-generated/page.tsx @@ -2,31 +2,37 @@ import Link from "next/link" import { useSearchParams } from "next/navigation" -import { useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" import { fetchTokenData } from "@/features/dcs/api" - -function maskToken(token: string) { - // If shorter than 16, just show all - if (token.length < 16) return token - const start = token.slice(0, 8) - const end = token.slice(-8) - return `${start}..............${end}` -} +import { SANDBOX_AUTH_TOKEN_STORAGE_KEY } from "@/features/sandbox/constants" export default function ApiKeyGeneratedPage() { const searchParams = useSearchParams() const [apiKey, setApiKey] = useState("") const [apiKeySaved, setApiKeySaved] = useState(false) - const [tokenData, setTokenData] = useState({}) + const [tokenData, setTokenData] = useState>({}) - function saveApiKey(key?: string) { - localStorage.setItem("authToken", key || apiKey) - } + const saveApiKey = useCallback( + (key?: string) => { + localStorage.setItem(SANDBOX_AUTH_TOKEN_STORAGE_KEY, key || apiKey) + setApiKeySaved(true) + }, + [apiKey] + ) - async function onLoad() { + const onLoad = useCallback(async () => { // Get the "auth_token" parameter const key = searchParams.get("auth_token") if (key) { @@ -34,48 +40,73 @@ export default function ApiKeyGeneratedPage() { const result = await fetchTokenData(key) setTokenData(result) - if (!localStorage.getItem("authToken")) { + if (!localStorage.getItem(SANDBOX_AUTH_TOKEN_STORAGE_KEY)) { saveApiKey(key) setApiKeySaved(true) } } - } + }, [searchParams, saveApiKey]) useEffect(() => { onLoad() - }, []) + }, [onLoad]) return ( -
-

Auth Token Generated

-
-

+ + + Auth Token + Congratulations! You have successfully created a Verida Auth Token. You can now use it to{" "} - Browse your data or{" "} - Make API requests. -

- -
{maskToken(apiKey)}
- {apiKeySaved && ( - - Key saved - - This Auth Token has been saved to local storage so you can use - easily it with the sandbox - - - )} - {apiKey && !apiKeySaved && ( - - )} -
-
-

Token Data

-
{JSON.stringify(tokenData, null, 2)}
-
-
+ + browse your data + {" "} + or{" "} + + make API requests + + . + + + +
+ {apiKey ? ( +
+ + + {apiKeySaved ? ( + + Token saved + + This Auth Token has been saved to local storage so you can + use easily it with the sandbox + + + ) : ( + + )} +
+ ) : ( + + No token found + + )} +
+ {tokenData ? ( +
+

Token info

+
+              {JSON.stringify(tokenData, null, 2)}
+            
+
+ ) : null} +
+ ) } diff --git a/src/app/(connected)/sandbox/token-info/loading.tsx b/src/app/(connected)/sandbox/token-info/loading.tsx new file mode 100644 index 0000000..b80b3f7 --- /dev/null +++ b/src/app/(connected)/sandbox/token-info/loading.tsx @@ -0,0 +1,4 @@ +export default function TokenInfoLoading() { + return
Loading...
+} +TokenInfoLoading.displayName = "TokenInfoLoading" diff --git a/src/app/(connected)/sandbox/token-info/page.tsx b/src/app/(connected)/sandbox/token-info/page.tsx index 222dc87..c7516a2 100644 --- a/src/app/(connected)/sandbox/token-info/page.tsx +++ b/src/app/(connected)/sandbox/token-info/page.tsx @@ -1,12 +1,13 @@ "use client" -import React, { useEffect, useState } from "react" +import React, { useCallback, useEffect, useState } from "react" -import { Alert } from "@/components/ui/alert" +import { Alert, AlertDescription } from "@/components/ui/alert" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -17,109 +18,94 @@ import { import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" -// Example API fetcher (replace with your actual API logic) import { fetchTokenData } from "@/features/dcs/api" - -// Helper to mask token (first 8, then "..............", then last 8) -function maskToken(token: string) { - // If shorter than 16, just show all - if (token.length < 16) return token - const start = token.slice(0, 8) - const end = token.slice(-8) - return `${start}..............${end}` -} +import { SANDBOX_AUTH_TOKEN_STORAGE_KEY } from "@/features/sandbox/constants" export default function TokenInfoPage() { - const [token, setToken] = useState("") - const [tokenInfo, setTokenInfo] = useState(null) - const [error, setError] = useState("") - - // State for "Load token" dialog - const [dialogOpen, setDialogOpen] = useState(false) - const [newToken, setNewToken] = useState("") - - // State for "View current token" dialog - const [viewDialogOpen, setViewDialogOpen] = useState(false) - const [viewToken, setViewToken] = useState("") - - // On page load, read token from localStorage - useEffect(() => { - const storedToken = localStorage.getItem("authToken") - if (storedToken) { - setToken(storedToken) - // Auto-fetch info - handleFetchTokenInfo(storedToken) + const [token, setTokenInternal] = useState("") + const [tokenInfo, setTokenInfo] = useState | null>( + null + ) + const [error, setError] = useState("") + const [loadDialogOpen, setLoadDialogOpen] = useState(false) + const [newToken, setNewToken] = useState("") + + const setToken = useCallback((_token: string | null) => { + setTokenInternal(_token || "") + if (_token) { + localStorage.setItem(SANDBOX_AUTH_TOKEN_STORAGE_KEY, _token) + } else { + localStorage.removeItem(SANDBOX_AUTH_TOKEN_STORAGE_KEY) } }, []) - // Fetch token info from your API - async function handleFetchTokenInfo(forcedToken?: string) { + const handleClearToken = useCallback(() => { + setToken(null) + setTokenInfo(null) + }, [setToken]) + + const handleFetchTokenInfo = useCallback(async (_token: string) => { try { setError("") setTokenInfo(null) - - const currentToken = forcedToken ?? token - if (!currentToken) { - setError("No token to fetch info for.") - return - } - - const data = await fetchTokenData(currentToken) - setTokenInfo(data) - } catch (err: any) { - setError(err.message) + const _tokenInfo = await fetchTokenData(_token) + setTokenInfo(_tokenInfo) + } catch (error) { + setError( + error instanceof Error + ? error.message + : "Something went wrong getting the token info" + ) } - } - - // Load (save) new token from the "Load token" dialog - function handleLoadToken() { - localStorage.setItem("authToken", newToken) - setToken(newToken) - setDialogOpen(false) - handleFetchTokenInfo(newToken) - } - - function handleClearToken() { - localStorage.removeItem("authToken") - setToken("") - } + }, []) - // View the current token in a dialog - function handleViewCurrentToken() { - const storedToken = localStorage.getItem("authToken") + useEffect(() => { + // Triggered only on mount + const storedToken = localStorage.getItem(SANDBOX_AUTH_TOKEN_STORAGE_KEY) if (storedToken) { - setViewToken(storedToken) - setViewDialogOpen(true) - } else { - setError("No token found in local storage.") + setToken(storedToken) } - } + }, [setToken]) - // Copy token to clipboard - function handleCopyToken() { - navigator.clipboard.writeText(viewToken).then(() => {}) - } + useEffect(() => { + if (!token) { + return + } - return ( -
- - - Token Info - + handleFetchTokenInfo(token).catch(() => { + // TODO: handle error + }) + }, [handleFetchTokenInfo, token]) - + const handleLoadToken = useCallback(() => { + setToken(newToken) + setLoadDialogOpen(false) + setNewToken("") + }, [newToken, setToken]) + + return ( + + + Token Info + + + {token ? ( +
+ + +
+ ) : ( + + No token loaded + + )} +
{token ? ( -
- - -
+ ) : ( - No token loaded - )} - -
- {/* Load token dialog */} - + @@ -140,72 +126,27 @@ export default function TokenInfoPage() { onChange={(e) => setNewToken(e.target.value)} />
- - + + + - - {token && ( -
- {/* View current token dialog */} - - - - - Current Token - - Below is your full token from local storage. - - - -
- -
- - -
-
- - - - -
-
- - {/* Manually re-fetch token info */} - -
- )} -
- - {/* Error or token info display */} - {error &&

Error: {error}

} - {tokenInfo && ( -
-              {JSON.stringify(tokenInfo, null, 2)}
-            
)} -
-
-
+ + {error ? ( + + Error: {error} + + ) : null} + {tokenInfo ? ( +
+            {JSON.stringify(tokenInfo, null, 2)}
+          
+ ) : null} + + ) } diff --git a/src/app/auth/loading.tsx b/src/app/auth/loading.tsx new file mode 100644 index 0000000..a510f93 --- /dev/null +++ b/src/app/auth/loading.tsx @@ -0,0 +1,4 @@ +export default function AuthLoading() { + return
Loading...
+} +AuthLoading.displayName = "AuthLoading" diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx new file mode 100644 index 0000000..5dd7809 --- /dev/null +++ b/src/app/auth/page.tsx @@ -0,0 +1,45 @@ +"use client" + +import { useRouter } from "next/navigation" +import { useEffect } from "react" + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { ConnectButton } from "@/features/auth/components/connect-button" +import { getRootPageRoute } from "@/features/routes/utils" +import { VERIDA_AUTH_ERROR_MESSAGES } from "@/features/verida-auth/constants" +import { useVeridaAuth } from "@/features/verida-auth/hooks/use-verida-auth" +import { useVeridaAuthResponse } from "@/features/verida-auth/hooks/use-verida-auth-response" + +export default function AuthPage() { + const router = useRouter() + const { setToken } = useVeridaAuth() + const authResponse = useVeridaAuthResponse() + + useEffect(() => { + if (authResponse.status === "success") { + setToken(authResponse.token) + // TODO: get the redirectPath from the state + router.replace(getRootPageRoute()) + } + }, [authResponse, router, setToken]) + + if (authResponse.status === "error") { + const errorInfo = VERIDA_AUTH_ERROR_MESSAGES[authResponse.error] + + return ( +
+ + {errorInfo.title} + + {authResponse.errorDescription || errorInfo.description} + + + +
+ ) + } + + // TODO: Display something while processing the response or if there is no response at all + return null +} +AuthPage.displayName = "AuthPage" diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..f8994dc --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,15 @@ +"use client" + +import { sora } from "@/styles/fonts" +import { cn } from "@/styles/utils" + +export default function GlobalError() { + return ( + + +
Something went wrong!
+ + + ) +} +GlobalError.displayName = "GlobalError" diff --git a/src/app/page.tsx b/src/app/page.tsx index c5a6763..455f2c1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,14 +1,14 @@ -import { RootConnectionHandler } from "@/components/root-connection-handler" -import { VeridaConnectButton } from "@/features/verida/components/verida-connect-button" +import { ConnectButton } from "@/features/auth/components/connect-button" +import { RootAuthenticationHandler } from "@/features/auth/components/root-authentication-handler" export default function RootPage() { return ( - +

Verida: AI Developer Admin

- +
-
+ ) } RootPage.displayName = "RootPage" diff --git a/src/assets/verida-network-logo.tsx b/src/assets/verida-network-logo.tsx new file mode 100644 index 0000000..9a5573d --- /dev/null +++ b/src/assets/verida-network-logo.tsx @@ -0,0 +1,57 @@ +import { type SVGProps } from "react" + +interface Props extends SVGProps {} + +export function VeridaNetworkLogo(props: Props) { + return ( + + + + + + + + + + + + + ) +} diff --git a/src/components/app-connection-handler.tsx b/src/components/app-connection-handler.tsx deleted file mode 100644 index 0d8069e..0000000 --- a/src/components/app-connection-handler.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client" - -import { redirect, usePathname, useSearchParams } from "next/navigation" - -import { useVerida } from "@/features/verida/hooks/use-verida" - -export interface AppConnectionHandlerProps { - children: React.ReactNode -} - -export function AppConnectionHandler(props: AppConnectionHandlerProps) { - const { children } = props - - const pathName = usePathname() - const searchParams = useSearchParams() - const { isConnected } = useVerida() - - if (!isConnected) { - const fullPath = `${pathName}${searchParams.toString() ? `?${searchParams.toString()}` : ""}` - const encodedRedirectPath = encodeURIComponent(fullPath) - // Ensure to use the same `redirectPath` query parameter as in `RootConnectionHandler`. - // TODO: Use nuqs to factorise the redirect path search param. - redirect(`/?redirectPath=${encodedRedirectPath}`) - } - - return <>{children} -} -AppConnectionHandler.displayName = "AppConnectionHandler" diff --git a/src/components/root-connection-handler.tsx b/src/components/root-connection-handler.tsx deleted file mode 100644 index c465b69..0000000 --- a/src/components/root-connection-handler.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client" - -import { redirect, useSearchParams } from "next/navigation" - -import { useVerida } from "@/features/verida/hooks/use-verida" - -const DEFAULT_REDIRECT_PATH = "/dashboard" - -export interface RootConnectionHandlerProps { - children: React.ReactNode -} - -export function RootConnectionHandler(props: RootConnectionHandlerProps) { - const { children } = props - - const { isConnected, isConnecting } = useVerida() - - const searchParams = useSearchParams() - // Ensure to use the same `redirectPath` search parameter as in `AppConnectionHandler`. - // TODO: Use nuqs to factorise the redirect path search param. - const redirectPath = searchParams.get("redirectPath") || DEFAULT_REDIRECT_PATH - - if (isConnected) { - redirect(decodeURIComponent(redirectPath)) - } - - if (isConnecting) { - return ( -
-

Connecting...

-
- ) - } - - return <>{children} -} -RootConnectionHandler.displayName = "RootConnectionHandler" diff --git a/src/components/root-providers.tsx b/src/components/root-providers.tsx index 4c7e1fc..68b9473 100644 --- a/src/components/root-providers.tsx +++ b/src/components/root-providers.tsx @@ -4,8 +4,9 @@ import { NuqsAdapter } from "nuqs/adapters/next/app" import { Suspense } from "react" import { TooltipProvider } from "@/components/ui/tooltip" +import { QueriesProvider } from "@/features/queries/queries-provider" import { ThemesProvider } from "@/features/themes/themes-provider" -import { VeridaProvider } from "@/features/verida/components/verida-provider" +import { VeridaAuthProvider } from "@/features/verida-auth/components/verida-auth-provider" export interface RootProvidersProps { children: React.ReactNode @@ -21,7 +22,9 @@ export function RootProviders(props: RootProvidersProps) { - {children} + + {children} + diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..335906c --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" +import * as React from "react" + +import { cn } from "@/styles/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/config/common.ts b/src/config/common.ts index 200c012..363823f 100644 --- a/src/config/common.ts +++ b/src/config/common.ts @@ -7,9 +7,9 @@ const commonConfigCheckResult = CommonConfigSchema.safeParse({ // the code(like here). It also allows us to have shorter names. BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, DEV_MODE: process.env.NEXT_PUBLIC_DEV_MODE, - VERIDA_NETWORK: process.env.NEXT_PUBLIC_VERIDA_NETWORK, - VERIDA_RPC_URL: process.env.NEXT_PUBLIC_VERIDA_RPC_URL, + LOG_LEVEL: process.env.NEXT_PUBLIC_LOG_LEVEL, VAULT_AUTH_ENDPOINT: process.env.NEXT_PUBLIC_VAULT_AUTH_ENDPOINT, + VERIDA_APP_DID: process.env.NEXT_PUBLIC_VERIDA_APP_DID, DCS_URL: process.env.NEXT_PUBLIC_DCS_URL, isClient: !(typeof window === "undefined"), appVersion: version, diff --git a/src/config/schemas.ts b/src/config/schemas.ts index 4854c57..1baeea1 100644 --- a/src/config/schemas.ts +++ b/src/config/schemas.ts @@ -1,32 +1,15 @@ -import { Network } from "@verida/types" import { z } from "zod" export const CommonConfigSchema = z.object({ BASE_URL: z.string().url(), DCS_URL: z.string().url(), VAULT_AUTH_ENDPOINT: z.string().url(), + VERIDA_APP_DID: z.string(), DEV_MODE: z .string() .optional() .transform((value) => value === "true"), - VERIDA_NETWORK: z - .enum(["myrtle", "banksia", "devnet", "local"]) - .default("banksia") - .transform((value) => { - return value === "myrtle" - ? Network.MYRTLE - : value === "banksia" - ? Network.BANKSIA - : value === "devnet" - ? Network.DEVNET - : value === "local" - ? Network.LOCAL - : Network.BANKSIA - }), - VERIDA_RPC_URL: z - .union([z.string().url(), z.literal("")]) - .optional() - .transform((value) => (value === "" ? undefined : value)), + LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"), isClient: z.boolean(), appVersion: z.string(), }) diff --git a/src/constants/app.ts b/src/constants/app.ts index f59a37f..0fad5da 100644 --- a/src/constants/app.ts +++ b/src/constants/app.ts @@ -1,7 +1,5 @@ -export const APP_NAME = "Verida: AI Developer Console" +export const APP_NAME = "Verida AI Developer Console" -export const APP_TITLE = "Verida: AI Developer Console" +export const APP_TITLE = "Verida AI Developer Console" export const APP_DESCRIPTION = "Developer Console for Verida AI developers" - -export const VERIDA_APPLICATION_CONTEXT_NAME = "Verida: Vault" diff --git a/src/features/auth/components/app-authentication-handler.tsx b/src/features/auth/components/app-authentication-handler.tsx new file mode 100644 index 0000000..2c503fa --- /dev/null +++ b/src/features/auth/components/app-authentication-handler.tsx @@ -0,0 +1,30 @@ +"use client" + +import type { ReactNode } from "react" + +import { useAuthRedirection } from "@/features/auth/hooks/use-auth-redirection" +import { useVeridaAuth } from "@/features/verida-auth/hooks/use-verida-auth" + +export interface AppAuthenticationHandlerProps { + children: ReactNode +} + +export function AppAuthenticationHandler(props: AppAuthenticationHandlerProps) { + const { children } = props + + const { status } = useVeridaAuth() + const { redirectToAuthPage } = useAuthRedirection() + + // TODO: Check the granted scopes compared to the expected ones. If they are + // not the same we need to handle it (e.g. disabled impacted features, inform + // user, provide ways to fix it by re-authenticating, etc.) + + // TODO: Handle when the token is invalid + + if (status === "unauthenticated") { + redirectToAuthPage() + } + + return <>{children} +} +AppAuthenticationHandler.displayName = "AppAuthenticationHandler" diff --git a/src/features/auth/components/authentication-loading.tsx b/src/features/auth/components/authentication-loading.tsx new file mode 100644 index 0000000..ab73030 --- /dev/null +++ b/src/features/auth/components/authentication-loading.tsx @@ -0,0 +1,24 @@ +import type { ComponentProps } from "react" + +import { cn } from "@/styles/utils" + +export interface AuthenticationLoadingProps + extends Omit, "children"> {} + +export function AuthenticationLoading(props: AuthenticationLoadingProps) { + const { className, ...divProps } = props + + return ( +
+

Connecting...

+

+ Please wait while we establish a secure connection. This might take a + moment. +

+
+ ) +} +AuthenticationLoading.displayName = "AuthenticationLoading" diff --git a/src/features/auth/components/connect-button.tsx b/src/features/auth/components/connect-button.tsx new file mode 100644 index 0000000..7b55605 --- /dev/null +++ b/src/features/auth/components/connect-button.tsx @@ -0,0 +1,30 @@ +import Link from "next/link" +import type { ComponentProps } from "react" + +import { VeridaNetworkLogo } from "@/assets/verida-network-logo" +import { Button } from "@/components/ui/button" +import { buildAuthUrl } from "@/features/auth/utils" +import { cn } from "@/styles/utils" + +const authUrl = buildAuthUrl() + +export interface ConnectButtonProps + extends Omit, "children" | "onClick"> {} + +export function ConnectButton(props: ConnectButtonProps) { + const { className, ...buttonProps } = props + + return ( + + ) +} +ConnectButton.displayName = "ConnectButton" diff --git a/src/features/auth/components/root-authentication-handler.tsx b/src/features/auth/components/root-authentication-handler.tsx new file mode 100644 index 0000000..f89b2de --- /dev/null +++ b/src/features/auth/components/root-authentication-handler.tsx @@ -0,0 +1,38 @@ +"use client" + +import { redirect } from "next/navigation" +import type { ReactNode } from "react" + +import { AuthenticationLoading } from "@/features/auth/components/authentication-loading" +import { useAuthRedirectPathState } from "@/features/auth/hooks/use-auth-redirect-path-state" +import { useVeridaAuth } from "@/features/verida-auth/hooks/use-verida-auth" + +export interface RootAuthenticationHandlerProps { + children: ReactNode +} + +export function RootAuthenticationHandler( + props: RootAuthenticationHandlerProps +) { + const { children } = props + + const { status } = useVeridaAuth() + const { redirectPath } = useAuthRedirectPathState() + + if (status === "authenticated") { + redirect(redirectPath) + } + + if (status === "loading") { + return ( +
+ +
+ ) + } + + // TODO: Handle when the token is invalid + + return <>{children} +} +RootAuthenticationHandler.displayName = "RootAuthenticationHandler" diff --git a/src/features/auth/hooks/use-auth-redirect-path-state.ts b/src/features/auth/hooks/use-auth-redirect-path-state.ts new file mode 100644 index 0000000..b848d0f --- /dev/null +++ b/src/features/auth/hooks/use-auth-redirect-path-state.ts @@ -0,0 +1,22 @@ +import { createSerializer, parseAsString, useQueryState } from "nuqs" + +import { getDefaultRedirectPathAfterAuthentication } from "@/features/routes/utils" + +const DEFAULT_REDIRECT_PATH = getDefaultRedirectPathAfterAuthentication() + +export function useAuthRedirectPathState() { + const [redirectPath, setRedirectPath] = useQueryState( + "redirectPath", + parseAsString + ) + + const serializeRedirectPath = createSerializer({ + redirectPath: parseAsString, + }) + + return { + redirectPath: redirectPath || DEFAULT_REDIRECT_PATH, + setRedirectPath, + serializeRedirectPath, + } +} diff --git a/src/features/auth/hooks/use-auth-redirection.ts b/src/features/auth/hooks/use-auth-redirection.ts new file mode 100644 index 0000000..aaf3c08 --- /dev/null +++ b/src/features/auth/hooks/use-auth-redirection.ts @@ -0,0 +1,30 @@ +import { redirect, usePathname, useSearchParams } from "next/navigation" +import { useCallback, useMemo } from "react" + +import { useAuthRedirectPathState } from "@/features/auth/hooks/use-auth-redirect-path-state" +import { getRootPageRoute } from "@/features/routes/utils" + +export function useAuthRedirection() { + const { serializeRedirectPath } = useAuthRedirectPathState() + + const pathName = usePathname() + const searchParams = useSearchParams() + + const targetUrl = useMemo( + () => + `${pathName}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`, + [pathName, searchParams] + ) + + const redirectToAuthPage = useCallback(() => { + redirect( + serializeRedirectPath(getRootPageRoute(), { + redirectPath: targetUrl, + }) + ) + }, [serializeRedirectPath, targetUrl]) + + return { + redirectToAuthPage, + } +} diff --git a/src/features/auth/utils.ts b/src/features/auth/utils.ts new file mode 100644 index 0000000..5412c41 --- /dev/null +++ b/src/features/auth/utils.ts @@ -0,0 +1,15 @@ +import { commonConfig } from "@/config/common" +import { + buildVeridaAuthRequest, + buildVeridaAuthRequestUrl, +} from "@/features/verida-auth/utils" + +/** + * Builds a Verida authentication URL for user login + * + * @returns The complete Verida authentication URL + */ +export function buildAuthUrl() { + const request = buildVeridaAuthRequest(commonConfig.BASE_URL) + return buildVeridaAuthRequestUrl(request) +} diff --git a/src/features/dcs/api.ts b/src/features/dcs/api.ts index 8c8ec78..70ec677 100644 --- a/src/features/dcs/api.ts +++ b/src/features/dcs/api.ts @@ -6,7 +6,7 @@ import type { IAccount } from "@verida/types" const BASE_API = `${commonConfig.DCS_URL}/api/rest/v1` export async function getAccount( - sessionToken: string + authToken: string ): Promise { try { // Make API request to fetch data @@ -14,27 +14,28 @@ export async function getAccount( method: "GET", headers: { "Content-Type": "application/json", - "X-API-Key": sessionToken, + "Authorization": `Bearer ${authToken}`, }, }) if (!response.ok) { - if (response.status == 404) { + if (response.status === 404) { return } - throw new Error(`HTTP error ${response.status}`) + throw new Error(`HTTP error ${response.status}: ${response.statusText}`) } const result = await response.json() + + // TODO: Properly validate the response to avoid assertions return result.account } catch (error) { - // eslint-disable-next-line prettier/prettier - throw new Error("Error getting Verida records", { cause: error }) + throw new Error("Error getting billing account", { cause: error }) } } export async function registerAccount( - sessionToken: string + authToken: string ): Promise { try { // Make API request to fetch data @@ -42,24 +43,24 @@ export async function registerAccount( method: "GET", headers: { "Content-Type": "application/json", - "X-API-Key": sessionToken, + "Authorization": `Bearer ${authToken}`, }, }) - + if (!response.ok) { if (response.status == 404) { return } throw new Error(`HTTP error ${response.status}`) } - - return getAccount(sessionToken) + + return getAccount(authToken) } catch (error) { throw new Error("Error getting Verida records", { cause: error }) } } -export async function submitDeposit(userAccount: IAccount, sessionToken: string, fromAddress: string, amount: string, txnId: string): Promise { +export async function submitDeposit(userAccount: IAccount, authToken: string, fromAddress: string, amount: string, txnId: string): Promise { const proofMessage = `txn: ${txnId}\nfrom: ${fromAddress}\namount: ${amount}` const keyring = await userAccount.keyring('Verida: Vault') const signature = await keyring.sign(proofMessage) @@ -75,7 +76,7 @@ export async function submitDeposit(userAccount: IAccount, sessionToken: string, }), headers: { "Content-Type": "application/json", - "X-API-Key": sessionToken + "Authorization": `Bearer ${authToken}`, } }) @@ -83,19 +84,19 @@ export async function submitDeposit(userAccount: IAccount, sessionToken: string, const json = await response.json() throw new Error(`${json.error}`) } - + } catch (error: unknown) { const err = error as Error throw new Error(err.message) } } -export async function getVdaPrice(sessionToken: string): Promise { +export async function getVdaPrice(authToken: string): Promise { const response = await fetch(`${BASE_API}/app/vda-price`, { method: "GET", headers: { "Content-Type": "application/json", - "X-API-Key": sessionToken + "Authorization": `Bearer ${authToken}`, } }) @@ -103,12 +104,12 @@ export async function getVdaPrice(sessionToken: string): Promise { return json.price } -export async function connectedAccountCount(sessionToken: string): Promise { +export async function connectedAccountCount(authToken: string): Promise { const response = await fetch(`${BASE_API}/app/account-count`, { method: "GET", headers: { "Content-Type": "application/json", - "X-API-Key": sessionToken + "Authorization": `Bearer ${authToken}`, } }) @@ -116,12 +117,12 @@ export async function connectedAccountCount(sessionToken: string): Promise json.count } -export async function accountUsage(sessionToken: string): Promise { +export async function accountUsage(authToken: string): Promise { const response = await fetch(`${BASE_API}/app/usage`, { method: "GET", headers: { - "Content-Type": "application/json", - "X-API-Key": sessionToken + "Content-Type": "application/json", + "Authorization": `Bearer ${authToken}`, } }) @@ -139,7 +140,7 @@ export async function fetchScopes(): Promise> { return scopeResponse.scopes } -export async function fetchTokenData(authToken: string): Promise { +export async function fetchTokenData(authToken: string): Promise> { const response = await fetch(`${BASE_API}/auth/token?tokenId=${encodeURIComponent(authToken)}`) if (!response.ok) { throw new Error("Failed to fetch token data") @@ -147,4 +148,4 @@ export async function fetchTokenData(authToken: string): Promise { const result = await response.json() return result - } \ No newline at end of file + } diff --git a/src/features/dcs/utils.ts b/src/features/dcs/utils.ts index 43d7adc..c768135 100644 --- a/src/features/dcs/utils.ts +++ b/src/features/dcs/utils.ts @@ -15,11 +15,11 @@ export function accountBalance(account: BillingAccount): number { } export async function accountCredits( - sessionToken: string, + authToken: string, account: BillingAccount ) { const balance = accountBalance(account) - const vdaPrice = await getVdaPrice(sessionToken) + const vdaPrice = await getVdaPrice(authToken) return parseInt((balance / 100.0 / vdaPrice).toString()) } diff --git a/src/features/queries/queries-provider.tsx b/src/features/queries/queries-provider.tsx new file mode 100644 index 0000000..d337587 --- /dev/null +++ b/src/features/queries/queries-provider.tsx @@ -0,0 +1,105 @@ +"use client" + +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister" +import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query" +import { ReactQueryDevtools } from "@tanstack/react-query-devtools" +import { + PersistQueryClientProvider, + removeOldestQuery, +} from "@tanstack/react-query-persist-client" +import type { ReactNode } from "react" + +import { + getLogger, + invalidateQueries, + logError, +} from "@/features/queries/utils" + +const PERSISTENCE_MAX_AGE = 1000 * 60 * 60 * 24 * 5 // 5 days +const GC_TIME = Infinity + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: true, + gcTime: GC_TIME, + retry: 2, + }, + }, + queryCache: new QueryCache({ + onError: (cause, query) => { + const logger = getLogger(query.meta?.logCategory) + logError(cause, logger, query.meta?.errorMessage) + }, + }), + mutationCache: new MutationCache({ + onSettled: (_data, _error, _variables, _context, mutation) => { + const logger = getLogger(mutation.meta?.logCategory) + + const keys = mutation.meta?.onSettledInvalidationQueryKeys + if (keys) { + invalidateQueries(queryClient, keys, logger) + } + }, + onSuccess: (_data, _variables, _context, mutation) => { + const logger = getLogger(mutation.meta?.logCategory) + + const keys = mutation.meta?.onSuccessInvalidationQueryKeys + if (keys) { + invalidateQueries(queryClient, keys, logger) + } + }, + onError: (cause, _variables, _context, mutation) => { + const logger = getLogger(mutation.meta?.logCategory) + + const keys = mutation.meta?.onErrorInvalidationQueryKeys + if (keys) { + invalidateQueries(queryClient, keys, logger) + } + + logError(cause, logger, mutation.meta?.errorMessage) + }, + }), +}) + +// For security reasons, do not persist user's private data +const localStoragePersister = createSyncStoragePersister({ + storage: typeof window !== "undefined" ? window.localStorage : undefined, + retry: removeOldestQuery, +}) + +export interface QueriesProviderProps { + children: ReactNode +} + +export function QueriesProvider(props: QueriesProviderProps) { + const { children } = props + + return ( + + {children} + + + ) +} +QueriesProvider.displayName = "QueriesProvider" diff --git a/src/features/queries/react-query.d.ts b/src/features/queries/react-query.d.ts new file mode 100644 index 0000000..1ac2004 --- /dev/null +++ b/src/features/queries/react-query.d.ts @@ -0,0 +1,23 @@ +import "@tanstack/react-query" +import { QueryKey } from "@tanstack/react-query" + +interface CustomQueryMeta extends Record { + logCategory: string + errorMessage: string + persist?: boolean +} + +interface CustomMutationMeta extends Record { + logCategory: string + errorMessage: string + onSettledInvalidationQueryKeys?: QueryKey + onSuccessInvalidationQueryKeys?: QueryKey + onErrorInvalidationQueryKeys?: QueryKey +} + +declare module "@tanstack/react-query" { + interface Register { + queryMeta: CustomQueryMeta + mutationMeta: CustomMutationMeta + } +} diff --git a/src/features/queries/types.ts b/src/features/queries/types.ts new file mode 100644 index 0000000..39fa87a --- /dev/null +++ b/src/features/queries/types.ts @@ -0,0 +1,6 @@ +// TODO: Could try to use UseQueryOptions from the tanstack package +export type UseQueryOptions = { + enabled?: boolean + staleTime?: number + gcTime?: number +} diff --git a/src/features/queries/utils.ts b/src/features/queries/utils.ts new file mode 100644 index 0000000..2ce3c03 --- /dev/null +++ b/src/features/queries/utils.ts @@ -0,0 +1,22 @@ +import { QueryClient, type QueryKey } from "@tanstack/react-query" + +import { Logger } from "@/features/telemetry/logger" + +export function getLogger(logCategory?: string) { + return Logger.create(logCategory || "queries") +} + +export function invalidateQueries( + queryClient: QueryClient, + queryKeys: QueryKey, + logger: Logger +) { + logger.debug("Invalidating queries", { keys: queryKeys }) + queryClient.invalidateQueries({ queryKey: queryKeys }).then(() => { + logger.debug("Successfully invalidated queries", { keys: queryKeys }) + }) +} + +export function logError(error: Error, logger: Logger, errorMessage?: string) { + logger.error(errorMessage ? new Error(errorMessage, { cause: error }) : error) +} diff --git a/src/features/routes/utils.ts b/src/features/routes/utils.ts new file mode 100644 index 0000000..7079062 --- /dev/null +++ b/src/features/routes/utils.ts @@ -0,0 +1,43 @@ +export function getRootPageRoute() { + return `/` +} + +export function getAuthPageRoute(basePath?: string) { + return `${basePath ?? ""}/auth` +} + +export function getCreditsPageRoute() { + return "/credits" +} + +export function getDashboardPageRoute() { + return "/dashboard" +} + +export function getSandboxPageRoute() { + return "/sandbox" +} + +export function getApiRequestsPageRoute() { + return `${getSandboxPageRoute()}/api-requests` +} + +export function getBrowseDataPageRoute() { + return `${getSandboxPageRoute()}/browse-data` +} + +export function getGenerateTokenPageRoute() { + return `${getSandboxPageRoute()}/generate-token` +} + +export function getTokenGeneratedPageRoute() { + return `${getSandboxPageRoute()}/token-generated` +} + +export function getTokenInfoPageRoute() { + return `${getSandboxPageRoute()}/token-info` +} + +export function getDefaultRedirectPathAfterAuthentication() { + return getDashboardPageRoute() +} diff --git a/src/features/sandbox/constants.ts b/src/features/sandbox/constants.ts new file mode 100644 index 0000000..7b09129 --- /dev/null +++ b/src/features/sandbox/constants.ts @@ -0,0 +1,2 @@ +export const SANDBOX_AUTH_TOKEN_STORAGE_KEY = + "verida_dev_console_sandbox_auth_token" diff --git a/src/components/sidebar-nav.tsx b/src/features/sidebar/components/sidebar.tsx similarity index 78% rename from src/components/sidebar-nav.tsx rename to src/features/sidebar/components/sidebar.tsx index e140501..cb6ff2c 100644 --- a/src/components/sidebar-nav.tsx +++ b/src/features/sidebar/components/sidebar.tsx @@ -4,15 +4,12 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { Button } from "@/components/ui/button" -import { navItems } from "@/config/nav" -import type { NavItem } from "@/config/nav" -import { useVerida } from "@/features/verida/hooks/use-verida" +import { SIDEBAR_ITEMS } from "@/features/sidebar/constants" +import type { SidebarItem } from "@/features/sidebar/types" +import { useVeridaAuth } from "@/features/verida-auth/hooks/use-verida-auth" import { cn } from "@/styles/utils" -// If you have a classNames utility - -// Example simplified styling for the sidebar link: -function SidebarLink({ item }: { item: NavItem }) { +function SidebarLink({ item }: { item: SidebarItem }) { const pathname = usePathname() const isActive = pathname === item.href @@ -31,13 +28,13 @@ function SidebarLink({ item }: { item: NavItem }) { ) } -export function SidebarNav() { - const { disconnect } = useVerida() +export function Sidebar() { + const { disconnect } = useVeridaAuth() return (