mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Python & REST API setup instructions
This commit is contained in:
parent
841a1dd85b
commit
1e9e29d0bf
@ -49,8 +49,8 @@ curl https://api.hexclave.com/api/v1/ \
|
||||
| `X-Stack-Access-Token` | string | Client only | Optional. The current user's access token. Used to act on behalf of a specific user. |
|
||||
|
||||
<Info>
|
||||
To see how to use these headers in various programming languages, see the
|
||||
[examples](/guides/going-further/backend-integration).
|
||||
To set up a backend in JavaScript, Python, or another language using the REST
|
||||
API, see [Setup](/guides/getting-started/setup).
|
||||
</Info>
|
||||
|
||||
## Getting Started
|
||||
|
||||
@ -75,7 +75,6 @@
|
||||
{
|
||||
"group": "Going Further",
|
||||
"pages": [
|
||||
"guides/going-further/backend-integration",
|
||||
"guides/going-further/cli",
|
||||
"guides/going-further/local-vs-cloud-dashboard",
|
||||
"guides/going-further/hexclave-config"
|
||||
@ -298,6 +297,10 @@
|
||||
{
|
||||
"source": "/guides/going-further/stack-app",
|
||||
"destination": "/sdk/objects/stack-app"
|
||||
},
|
||||
{
|
||||
"source": "/guides/going-further/backend-integration",
|
||||
"destination": "/guides/getting-started/setup"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -278,6 +278,6 @@ Use a JWT viewer such as [jwt.io](https://jwt.io/) to inspect tokens and verify
|
||||
## Related Concepts
|
||||
|
||||
* [API Keys](/guides/apps/api-keys/overview) - Alternative authentication method for server-to-server communication
|
||||
* [Backend Integration](/guides/going-further/backend-integration) - How to verify JWTs in your backend
|
||||
* [Setup](/guides/getting-started/setup) - How to verify user sessions in your backend
|
||||
* [Permissions](/guides/apps/rbac/overview) - Understanding user permissions (not included in JWTs)
|
||||
* [Teams](/guides/apps/teams/overview) - Understanding team context in JWTs
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,127 +0,0 @@
|
||||
---
|
||||
title: "Integrating with Backends"
|
||||
description: "Integrate Hexclave with your own server with the REST APIs"
|
||||
sidebarTitle: "Integrating with Backends"
|
||||
---
|
||||
|
||||
To authenticate your endpoints, you need to send the user's access token in the headers of the request to your server, and then make a request to Stack's server API to verify the user's identity.
|
||||
|
||||
## Sending requests to your server endpoints
|
||||
|
||||
To authenticate your own server endpoints using Stack's server API, you need to protect your endpoints by sending the user's access token in the headers of the request.
|
||||
|
||||
On the client side, you can retrieve the access token from the `user` object by calling `user.getAuthJson()`. This will return an object containing `accessToken`.
|
||||
|
||||
Then, you can call your server endpoint with these two tokens in the headers, like this:
|
||||
|
||||
```typescript
|
||||
const { accessToken } = await user.getAuthJson();
|
||||
const response = await fetch('/api/users/me', {
|
||||
headers: {
|
||||
'x-stack-access-token': accessToken,
|
||||
},
|
||||
// your other options and parameters
|
||||
});
|
||||
```
|
||||
|
||||
## Authenticating the user on the server endpoints
|
||||
|
||||
Hexclave provides two methods for authenticating users on your server endpoints:
|
||||
|
||||
1. **JWT Verification**: A fast, lightweight approach that validates the user's token locally without making external requests. While efficient, it provides only essential user information encoded in the JWT.
|
||||
2. **REST API Verification**: Makes a request to Hexclave's servers to validate the token and retrieve comprehensive user information. This method provides access to the complete, up-to-date user profile.
|
||||
|
||||
### Using JWT
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Node.js">
|
||||
```javascript
|
||||
// you need to install the jose library if it's not already installed
|
||||
import * as jose from 'jose';
|
||||
|
||||
// you can cache this and refresh it with a low frequency
|
||||
const jwks = jose.createRemoteJWKSet(new URL("https://api.hexclave.com/api/v1/projects/<your-project-id>/.well-known/jwks.json"));
|
||||
|
||||
const accessToken = 'access token from the headers';
|
||||
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(accessToken, jwks);
|
||||
console.log('Authenticated user with ID:', payload.sub);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.log('Invalid user');
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
# you need to install PyJWT and cryptography libraries if they're not already installed
|
||||
# pip install PyJWT[crypto] requests
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from jwt import PyJWKClient
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
# you can cache this and refresh it with a low frequency
|
||||
jwks_client = PyJWKClient("https://api.hexclave.com/api/v1/projects/<your-project-id>/.well-known/jwks.json")
|
||||
|
||||
access_token = 'access token from the headers'
|
||||
|
||||
try:
|
||||
signing_key = jwks_client.get_signing_key_from_jwt(access_token)
|
||||
payload = jwt.decode(
|
||||
access_token,
|
||||
signing_key.key,
|
||||
algorithms=["ES256"],
|
||||
audience="<your-project-id>"
|
||||
)
|
||||
print('Authenticated user with ID:', payload['sub'])
|
||||
except Exception as error:
|
||||
print(error)
|
||||
print('Invalid user')
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Using the REST API
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Node.js">
|
||||
```javascript
|
||||
const url = 'https://api.hexclave.com/api/v1/users/me';
|
||||
const headers = {
|
||||
'x-stack-access-type': 'server',
|
||||
'x-stack-project-id': 'generated on the Hexclave dashboard',
|
||||
'x-stack-secret-server-key': 'generated on the Hexclave dashboard',
|
||||
'x-stack-access-token': 'access token from the headers',
|
||||
};
|
||||
|
||||
const response = await fetch(url, { headers });
|
||||
if (response.status === 200) {
|
||||
console.log('User is authenticated', await response.json());
|
||||
} else {
|
||||
console.log('User is not authenticated', response.status, await response.text());
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = 'https://api.hexclave.com/api/v1/users/me'
|
||||
headers = {
|
||||
'x-stack-access-type': 'server',
|
||||
'x-stack-project-id': 'generated on the Hexclave dashboard',
|
||||
'x-stack-secret-server-key': 'generated on the Hexclave dashboard',
|
||||
'x-stack-access-token': 'access token from the headers',
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
if response.status_code == 200:
|
||||
print('User is authenticated', response.json())
|
||||
else:
|
||||
print('User is not authenticated', response.status_code, response.text)
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
@ -320,7 +320,7 @@ See [Payments](/guides/apps/payments/overview) for checkout and entitlement usag
|
||||
|
||||
`dbSync.externalDatabases` defines external databases that Hexclave can sync to.
|
||||
|
||||
For more information on connecting Hexclave with your own backend and database workflows, see [Integrating with Backends](/guides/going-further/backend-integration) and the [REST API overview](/api/overview).
|
||||
For more information on connecting Hexclave with your own backend and database workflows, see [Setup](/guides/getting-started/setup) and the [REST API overview](/api/overview).
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
|
||||
1
docs-mintlify/images/setup-tools/python.svg
Normal file
1
docs-mintlify/images/setup-tools/python.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Python</title><path fill="#3776AB" d="M11.92 0c-.99 0-1.94.09-2.78.25-2.48.44-2.93 1.36-2.93 3.06v2.24h5.86v.75H4.02c-1.7 0-3.18 1.02-3.65 2.95-.54 2.21-.56 3.59 0 5.9.42 1.72 1.42 2.95 3.12 2.95h2.02v-2.68c0-1.93 1.67-3.63 3.65-3.63h5.85c1.63 0 2.93-1.35 2.93-3V3.31c0-1.6-1.35-2.8-2.93-3.06C14.01.08 12.91 0 11.92 0ZM8.75 1.8c.6 0 1.09.5 1.09 1.11 0 .6-.49 1.09-1.09 1.09-.61 0-1.1-.49-1.1-1.09 0-.61.49-1.11 1.1-1.11Z"/><path fill="#FFD43B" d="M12.15 24c.99 0 1.94-.09 2.78-.25 2.48-.44 2.93-1.36 2.93-3.06v-2.24H12v-.75h8.05c1.7 0 3.18-1.02 3.65-2.95.54-2.21.56-3.59 0-5.9-.42-1.72-1.42-2.95-3.12-2.95h-2.02v2.68c0 1.93-1.67 3.63-3.65 3.63H9.06c-1.63 0-2.93 1.35-2.93 3v5.48c0 1.6 1.35 2.8 2.93 3.06 1 .17 2.1.25 3.09.25Zm3.17-1.8c-.6 0-1.09-.5-1.09-1.11 0-.6.49-1.09 1.09-1.09.61 0 1.1.49 1.1 1.09 0 .61-.49 1.11-1.1 1.11Z"/></svg>
|
||||
|
After Width: | Height: | Size: 915 B |
@ -148,14 +148,14 @@ export const copyGeneratedSetupPrompt = async (event) => {
|
||||
{/* Going Further */}
|
||||
<div className="relative mb-10">
|
||||
<div className="absolute -left-[2.55rem] top-0.5 h-4 w-4 rounded-full border-2 border-[#6b5df7] bg-[#6b5df7]" />
|
||||
<SectionLink href="/guides/going-further/backend-integration">Going Further</SectionLink>
|
||||
<SectionLink href="/guides/going-further/local-vs-cloud-dashboard">Going Further</SectionLink>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
Compare development flows, integrate your backend, and use lower-level interfaces where needed.
|
||||
Compare development flows, configure your project, and use lower-level interfaces where needed.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<ChipLink href="/guides/going-further/backend-integration">Backend Integration</ChipLink>
|
||||
<ChipLink href="/guides/going-further/local-vs-cloud-dashboard">Local vs. Cloud</ChipLink>
|
||||
<ChipLink href="/guides/going-further/hexclave-config">Config File</ChipLink>
|
||||
<ChipLink href="/guides/going-further/cli">CLI</ChipLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -183,7 +183,7 @@ export const copyGeneratedSetupPrompt = async (event) => {
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<ChipLink href="/api/overview">Overview</ChipLink>
|
||||
<ChipLink href="/guides/apps/webhooks/overview">Webhooks</ChipLink>
|
||||
<ChipLink href="/guides/going-further/backend-integration">Backend Integration</ChipLink>
|
||||
<ChipLink href="/guides/getting-started/setup">Backend Setup</ChipLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -35,7 +35,6 @@ The full docs sidebar — generated from the live navigation. Fetch any of these
|
||||
- [User Fundamentals](https://docs.hexclave.com/guides/getting-started/user-fundamentals)
|
||||
- [AI Integration](https://docs.hexclave.com/guides/getting-started/ai-integration)
|
||||
- **Going Further**
|
||||
- [Backend Integration](https://docs.hexclave.com/guides/going-further/backend-integration)
|
||||
- [CLI](https://docs.hexclave.com/guides/going-further/cli)
|
||||
- [Local Vs Cloud Dashboard](https://docs.hexclave.com/guides/going-further/local-vs-cloud-dashboard)
|
||||
- [Hexclave Config](https://docs.hexclave.com/guides/going-further/hexclave-config)
|
||||
@ -132,6 +131,8 @@ To use it, you can use the sections below to set up Hexclave in the project. For
|
||||
|
||||
Follow these instructions in order to set up and get started with the Hexclave SDK in various languages.
|
||||
|
||||
Note: These instructions are for setting up the Hexclave SDK to build your own CLIs. If you're looking to use the Hexclave CLI instead, see the [CLI documentation](https://docs.hexclave.com/guides/going-further/cli).
|
||||
|
||||
Not all steps are applicable to every type of application; for example, React apps have some extra steps that are not needed with other frameworks.
|
||||
|
||||
The frameworks and languages with explicit SDK support are:
|
||||
@ -756,6 +757,262 @@ Follow these instructions to integrate Hexclave with Convex.
|
||||
<Step title="Done!" />
|
||||
</Steps>
|
||||
|
||||
## Python Backend Setup
|
||||
|
||||
Follow these instructions to authenticate requests to a Python backend with Hexclave.
|
||||
|
||||
This setup is for Python backends that do not use the JavaScript SDK. The backend flow is: your frontend sends the user's access token to your backend, and your backend verifies it before serving protected data.
|
||||
|
||||
<Steps titleSize="h3">
|
||||
<Step title="Choose a project setup">
|
||||
You can use either a development environment with the local dashboard or a Hexclave Cloud project.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Option 1: Local dashboard (recommended)" defaultOpen>
|
||||
If this project already has a `hexclave.config.ts` file for another frontend or backend, reuse that same file so the whole project shares one Hexclave config. Otherwise, create a new `hexclave.config.ts` file in your workspace:
|
||||
|
||||
```ts hexclave.config.ts
|
||||
import type { HexclaveConfig } from "@hexclave/js";
|
||||
|
||||
export const config: HexclaveConfig = "show-onboarding";
|
||||
```
|
||||
|
||||
Run your backend through the Hexclave CLI so it starts the local dashboard and injects the Hexclave environment variables:
|
||||
|
||||
```json package.json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "hexclave dev --config-file ./hexclave.config.ts -- <your-backend-dev-command>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Your backend should read `HEXCLAVE_PROJECT_ID` and `HEXCLAVE_SECRET_SERVER_KEY` from the environment.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Option 2: Hexclave Cloud project">
|
||||
Create or select a project on [app.hexclave.com](https://app.hexclave.com). Then copy the project ID and a secret server key into your backend environment:
|
||||
|
||||
```.env .env
|
||||
HEXCLAVE_PROJECT_ID=<your-project-id>
|
||||
HEXCLAVE_SECRET_SERVER_KEY=<your-secret-server-key>
|
||||
```
|
||||
|
||||
The secret server key must only be available to your backend. Never expose it to browser code, mobile clients, logs, or public repositories.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Step>
|
||||
|
||||
<Step title="Install backend dependencies">
|
||||
Install `requests` for REST API verification. If you want to use JWT verification, also install `PyJWT[crypto]`.
|
||||
|
||||
```sh
|
||||
pip install requests PyJWT[crypto]
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Send the user's access token to your backend">
|
||||
From your frontend, get the current user's access token and pass it to your backend endpoint.
|
||||
|
||||
```ts
|
||||
// this is your frontend's code!
|
||||
const { accessToken } = await user.getAuthJson();
|
||||
const response = await fetch("<your-backend-endpoint>", {
|
||||
headers: {
|
||||
"x-stack-access-token": accessToken,
|
||||
},
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Verify the token">
|
||||
Hexclave supports two backend verification approaches. JWT verification is faster and local to your backend. REST endpoint verification asks Hexclave to validate the token and return the current user object.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Verify with JWT" defaultOpen>
|
||||
JWT verification validates the token locally in your backend. It does not require a request to Hexclave on every call, but it only gives you the information contained in the token, such as the user ID.
|
||||
|
||||
```python
|
||||
import os
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
jwks_client = PyJWKClient(
|
||||
f"https://api.hexclave.com/api/v1/projects/{os.environ['HEXCLAVE_PROJECT_ID']}/.well-known/jwks.json"
|
||||
)
|
||||
|
||||
def get_current_user_id_from_jwt(request):
|
||||
access_token = request.headers.get("x-stack-access-token")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
signing_key = jwks_client.get_signing_key_from_jwt(access_token)
|
||||
payload = jwt.decode(
|
||||
access_token,
|
||||
signing_key.key,
|
||||
algorithms=["ES256"],
|
||||
audience=os.environ["HEXCLAVE_PROJECT_ID"],
|
||||
)
|
||||
return payload["sub"]
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Verify with the Hexclave REST endpoint">
|
||||
REST endpoint verification asks Hexclave to validate the token and returns the current user object. Use this when you want the complete, up-to-date user profile or do not want to implement JWT verification yourself.
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
|
||||
def get_current_hexclave_user(request):
|
||||
access_token = request.headers.get("x-stack-access-token")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
response = requests.get(
|
||||
"https://api.hexclave.com/api/v1/users/me",
|
||||
headers={
|
||||
"x-stack-access-type": "server",
|
||||
"x-stack-project-id": os.environ["HEXCLAVE_PROJECT_ID"],
|
||||
"x-stack-secret-server-key": os.environ["HEXCLAVE_SECRET_SERVER_KEY"],
|
||||
"x-stack-access-token": access_token,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
If the response is `200 OK`, the user is authenticated. If the response is not `200 OK`, treat the request as unauthenticated.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Step>
|
||||
|
||||
<Step title="Protect authenticated endpoints">
|
||||
Wrap your protected endpoints with a helper that extracts `x-stack-access-token`, verifies it with either JWT verification or REST API verification, and returns `401 Unauthorized` when verification fails.
|
||||
|
||||
<Note>
|
||||
Disable HTTP caching for authenticated responses with a header like `Cache-Control: private, no-store`.
|
||||
</Note>
|
||||
</Step>
|
||||
|
||||
<Step title="Done!" />
|
||||
</Steps>
|
||||
|
||||
## Other Backend Setup (REST API)
|
||||
|
||||
Follow these instructions to authenticate requests from any backend language using Hexclave's REST API.
|
||||
|
||||
Use this option when your backend is not JavaScript/TypeScript or Python, or when you want to call Hexclave over plain HTTP. The backend flow is: your frontend sends the user's access token to your backend, and your backend verifies it before serving protected data.
|
||||
|
||||
<Steps titleSize="h3">
|
||||
<Step title="Choose a project setup">
|
||||
You can use either a development environment with the local dashboard or a Hexclave Cloud project.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Option 1: Local dashboard (recommended)" defaultOpen>
|
||||
If this project already has a `hexclave.config.ts` file for another frontend or backend, reuse that same file so the whole project shares one Hexclave config. Otherwise, create a new `hexclave.config.ts` file in your workspace:
|
||||
|
||||
```ts hexclave.config.ts
|
||||
import type { HexclaveConfig } from "@hexclave/js";
|
||||
|
||||
export const config: HexclaveConfig = "show-onboarding";
|
||||
```
|
||||
|
||||
Run your backend through the Hexclave CLI so it starts the local dashboard and injects the Hexclave environment variables:
|
||||
|
||||
```json package.json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "hexclave dev --config-file ./hexclave.config.ts -- <your-backend-dev-command>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Your backend should read `HEXCLAVE_PROJECT_ID` and `HEXCLAVE_SECRET_SERVER_KEY` from the environment.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Option 2: Hexclave Cloud project">
|
||||
Create or select a project on [app.hexclave.com](https://app.hexclave.com). Then copy the project ID and a secret server key into your backend environment:
|
||||
|
||||
```.env .env
|
||||
HEXCLAVE_PROJECT_ID=<your-project-id>
|
||||
HEXCLAVE_SECRET_SERVER_KEY=<your-secret-server-key>
|
||||
```
|
||||
|
||||
The secret server key must only be available to your backend. Never expose it to browser code, mobile clients, logs, or public repositories.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Step>
|
||||
|
||||
|
||||
|
||||
<Step title="Send the user's access token to your backend">
|
||||
From your frontend, get the current user's access token and pass it to your backend endpoint.
|
||||
|
||||
```ts
|
||||
// this is your frontend's code!
|
||||
const { accessToken } = await user.getAuthJson();
|
||||
const response = await fetch("<your-backend-endpoint>", {
|
||||
headers: {
|
||||
"x-stack-access-token": accessToken,
|
||||
},
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Verify the token">
|
||||
Hexclave supports two backend verification approaches. JWT verification is faster and local to your backend. REST endpoint verification asks Hexclave to validate the token and return the current user object.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Verify with JWT" defaultOpen>
|
||||
JWT verification validates the token locally in your backend. It does not require a request to Hexclave on every call, but it only gives you the information contained in the token, such as the user ID.
|
||||
|
||||
```text
|
||||
1. Read the access token from the `x-stack-access-token` header.
|
||||
2. Fetch the JWKS from:
|
||||
https://api.hexclave.com/api/v1/projects/<your-project-id>/.well-known/jwks.json
|
||||
3. Verify the JWT signature with an ES256-capable JWT library.
|
||||
4. Verify the token audience is your Hexclave project ID.
|
||||
5. Use the `sub` claim as the authenticated user ID.
|
||||
6. Reject the request if any verification step fails.
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Verify with the Hexclave REST endpoint">
|
||||
REST endpoint verification asks Hexclave to validate the token and returns the current user object. Use this when you want the complete, up-to-date user profile or do not want to implement JWT verification yourself.
|
||||
|
||||
```sh
|
||||
curl https://api.hexclave.com/api/v1/users/me \
|
||||
-H "x-stack-access-type: server" \
|
||||
-H "x-stack-project-id: $HEXCLAVE_PROJECT_ID" \
|
||||
-H "x-stack-secret-server-key: $HEXCLAVE_SECRET_SERVER_KEY" \
|
||||
-H "x-stack-access-token: <access-token-from-request>"
|
||||
```
|
||||
|
||||
If the response is `200 OK`, the user is authenticated. If the response is not `200 OK`, treat the request as unauthenticated.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Step>
|
||||
|
||||
<Step title="Protect authenticated endpoints">
|
||||
Wrap your protected endpoints with a helper that extracts `x-stack-access-token`, verifies it with either JWT verification or REST API verification, and returns `401 Unauthorized` when verification fails.
|
||||
|
||||
<Note>
|
||||
Disable HTTP caching for authenticated responses with a header like `Cache-Control: private, no-store`.
|
||||
</Note>
|
||||
</Step>
|
||||
|
||||
<Step title="Done!" />
|
||||
</Steps>
|
||||
|
||||
## CLI Setup
|
||||
|
||||
Follow these instructions to authenticate users in a command line application with Hexclave.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -333,6 +333,198 @@ export const cliSetupPrompt = deindent`
|
||||
</Steps>
|
||||
`;
|
||||
|
||||
function getRestBackendSetupPrompt(kind: "python" | "rest-api") {
|
||||
const isPython = kind === "python";
|
||||
const title = isPython ? "Python Backend Setup" : "Other Backend Setup (REST API)";
|
||||
const intro = isPython
|
||||
? "Follow these instructions to authenticate requests to a Python backend with Hexclave."
|
||||
: "Follow these instructions to authenticate requests from any backend language using Hexclave's REST API.";
|
||||
const useCase = isPython
|
||||
? "This setup is for Python backends that do not use the JavaScript SDK."
|
||||
: "Use this option when your backend is not JavaScript/TypeScript or Python, or when you want to call Hexclave over plain HTTP.";
|
||||
const dependencyStep = isPython ? deindent`
|
||||
<Step title="Install backend dependencies">
|
||||
Install \`requests\` for REST API verification. If you want to use JWT verification, also install \`PyJWT[crypto]\`.
|
||||
|
||||
\`\`\`sh
|
||||
pip install requests PyJWT[crypto]
|
||||
\`\`\`
|
||||
</Step>
|
||||
` : "";
|
||||
const jwtVerification = isPython ? deindent`
|
||||
\`\`\`python
|
||||
import os
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from jwt.exceptions import InvalidTokenError
|
||||
|
||||
jwks_client = PyJWKClient(
|
||||
f"https://api.hexclave.com/api/v1/projects/{os.environ['HEXCLAVE_PROJECT_ID']}/.well-known/jwks.json"
|
||||
)
|
||||
|
||||
def get_current_user_id_from_jwt(request):
|
||||
access_token = request.headers.get("x-stack-access-token")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
signing_key = jwks_client.get_signing_key_from_jwt(access_token)
|
||||
payload = jwt.decode(
|
||||
access_token,
|
||||
signing_key.key,
|
||||
algorithms=["ES256"],
|
||||
audience=os.environ["HEXCLAVE_PROJECT_ID"],
|
||||
)
|
||||
return payload["sub"]
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
\`\`\`
|
||||
` : deindent`
|
||||
\`\`\`text
|
||||
1. Read the access token from the \`x-stack-access-token\` header.
|
||||
2. Fetch the JWKS from:
|
||||
https://api.hexclave.com/api/v1/projects/<your-project-id>/.well-known/jwks.json
|
||||
3. Verify the JWT signature with an ES256-capable JWT library.
|
||||
4. Verify the token audience is your Hexclave project ID.
|
||||
5. Use the \`sub\` claim as the authenticated user ID.
|
||||
6. Reject the request if any verification step fails.
|
||||
\`\`\`
|
||||
`;
|
||||
const restVerification = isPython ? deindent`
|
||||
\`\`\`python
|
||||
import os
|
||||
import requests
|
||||
|
||||
def get_current_hexclave_user(request):
|
||||
access_token = request.headers.get("x-stack-access-token")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
response = requests.get(
|
||||
"https://api.hexclave.com/api/v1/users/me",
|
||||
headers={
|
||||
"x-stack-access-type": "server",
|
||||
"x-stack-project-id": os.environ["HEXCLAVE_PROJECT_ID"],
|
||||
"x-stack-secret-server-key": os.environ["HEXCLAVE_SECRET_SERVER_KEY"],
|
||||
"x-stack-access-token": access_token,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
return None
|
||||
\`\`\`
|
||||
` : deindent`
|
||||
\`\`\`sh
|
||||
curl https://api.hexclave.com/api/v1/users/me \\
|
||||
-H "x-stack-access-type: server" \\
|
||||
-H "x-stack-project-id: $HEXCLAVE_PROJECT_ID" \\
|
||||
-H "x-stack-secret-server-key: $HEXCLAVE_SECRET_SERVER_KEY" \\
|
||||
-H "x-stack-access-token: <access-token-from-request>"
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
return deindent`
|
||||
## ${title}
|
||||
|
||||
${intro}
|
||||
|
||||
${useCase} The backend flow is: your frontend sends the user's access token to your backend, and your backend verifies it before serving protected data.
|
||||
|
||||
<Steps titleSize="h3">
|
||||
<Step title="Choose a project setup">
|
||||
You can use either a development environment with the local dashboard or a Hexclave Cloud project.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Option 1: Local dashboard (recommended)" defaultOpen>
|
||||
If this project already has a \`hexclave.config.ts\` file for another frontend or backend, reuse that same file so the whole project shares one Hexclave config. Otherwise, create a new \`hexclave.config.ts\` file in your workspace:
|
||||
|
||||
\`\`\`ts hexclave.config.ts
|
||||
import type { HexclaveConfig } from "@hexclave/js";
|
||||
|
||||
export const config: HexclaveConfig = "show-onboarding";
|
||||
\`\`\`
|
||||
|
||||
Run your backend through the Hexclave CLI so it starts the local dashboard and injects the Hexclave environment variables:
|
||||
|
||||
\`\`\`json package.json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "hexclave dev --config-file ./hexclave.config.ts -- <your-backend-dev-command>"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Your backend should read \`HEXCLAVE_PROJECT_ID\` and \`HEXCLAVE_SECRET_SERVER_KEY\` from the environment.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Option 2: Hexclave Cloud project">
|
||||
Create or select a project on [app.hexclave.com](https://app.hexclave.com). Then copy the project ID and a secret server key into your backend environment:
|
||||
|
||||
\`\`\`.env .env
|
||||
HEXCLAVE_PROJECT_ID=<your-project-id>
|
||||
HEXCLAVE_SECRET_SERVER_KEY=<your-secret-server-key>
|
||||
\`\`\`
|
||||
|
||||
The secret server key must only be available to your backend. Never expose it to browser code, mobile clients, logs, or public repositories.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Step>
|
||||
|
||||
${dependencyStep}
|
||||
|
||||
<Step title="Send the user's access token to your backend">
|
||||
From your frontend, get the current user's access token and pass it to your backend endpoint.
|
||||
|
||||
\`\`\`ts
|
||||
// this is your frontend's code!
|
||||
const { accessToken } = await user.getAuthJson();
|
||||
const response = await fetch("<your-backend-endpoint>", {
|
||||
headers: {
|
||||
"x-stack-access-token": accessToken,
|
||||
},
|
||||
});
|
||||
\`\`\`
|
||||
</Step>
|
||||
|
||||
<Step title="Verify the token">
|
||||
Hexclave supports two backend verification approaches. JWT verification is faster and local to your backend. REST endpoint verification asks Hexclave to validate the token and return the current user object.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Verify with JWT" defaultOpen>
|
||||
JWT verification validates the token locally in your backend. It does not require a request to Hexclave on every call, but it only gives you the information contained in the token, such as the user ID.
|
||||
|
||||
${jwtVerification}
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Verify with the Hexclave REST endpoint">
|
||||
REST endpoint verification asks Hexclave to validate the token and returns the current user object. Use this when you want the complete, up-to-date user profile or do not want to implement JWT verification yourself.
|
||||
|
||||
${restVerification}
|
||||
|
||||
If the response is \`200 OK\`, the user is authenticated. If the response is not \`200 OK\`, treat the request as unauthenticated.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
</Step>
|
||||
|
||||
<Step title="Protect authenticated endpoints">
|
||||
Wrap your protected endpoints with a helper that extracts \`x-stack-access-token\`, verifies it with either JWT verification or REST API verification, and returns \`401 Unauthorized\` when verification fails.
|
||||
|
||||
<Note>
|
||||
Disable HTTP caching for authenticated responses with a header like \`Cache-Control: private, no-store\`.
|
||||
</Note>
|
||||
</Step>
|
||||
|
||||
<Step title="Done!" />
|
||||
</Steps>
|
||||
`;
|
||||
}
|
||||
|
||||
export const pythonBackendSetupPrompt = getRestBackendSetupPrompt("python");
|
||||
export const restApiBackendSetupPrompt = getRestBackendSetupPrompt("rest-api");
|
||||
|
||||
export const aiAgentConfigPreparationPrompt = deindent`
|
||||
## AI Agent Configuration
|
||||
|
||||
@ -412,6 +604,8 @@ export function getSdkSetupPrompt(mainType: "ai-prompt" | "nextjs" | "react" | "
|
||||
|
||||
Follow these instructions in order to set up and get started with the Hexclave SDK ${typeLabel ? `for ${typeLabel} ` : "in various languages"}.
|
||||
|
||||
Note: These instructions are for setting up the Hexclave SDK to build your own CLIs. If you're looking to use the Hexclave CLI instead, see the [CLI documentation](https://docs.hexclave.com/guides/going-further/cli).
|
||||
|
||||
${isAiPrompt ? "Not all steps are applicable to every type of application; for example, React apps have some extra steps that are not needed with other frameworks." : ""}
|
||||
|
||||
${isAiPrompt ? deindent`
|
||||
@ -841,6 +1035,10 @@ export const aiSetupPrompt = deindent`
|
||||
|
||||
${supabaseSetupPrompt}
|
||||
|
||||
${pythonBackendSetupPrompt}
|
||||
|
||||
${restApiBackendSetupPrompt}
|
||||
|
||||
${cliSetupPrompt}
|
||||
|
||||
${aiAgentConfigPreparationPrompt}
|
||||
|
||||
@ -77,7 +77,6 @@ const docsJson = {
|
||||
{
|
||||
"group": "Going Further",
|
||||
"pages": [
|
||||
"guides/going-further/backend-integration",
|
||||
"guides/going-further/cli",
|
||||
"guides/going-further/local-vs-cloud-dashboard",
|
||||
"guides/going-further/hexclave-config"
|
||||
@ -308,6 +307,10 @@ const docsJson = {
|
||||
{
|
||||
"source": "/guides/going-further/stack-app",
|
||||
"destination": "/sdk/objects/stack-app"
|
||||
},
|
||||
{
|
||||
"source": "/guides/going-further/backend-integration",
|
||||
"destination": "/guides/getting-started/setup"
|
||||
}
|
||||
]
|
||||
} as const;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import path from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { aiSetupPrompt, cliSetupPrompt, convexSetupPrompt, getSdkSetupPrompt, supabaseSetupPrompt } from "../packages/stack-shared/src/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt";
|
||||
import { aiSetupPrompt, cliSetupPrompt, convexSetupPrompt, getSdkSetupPrompt, pythonBackendSetupPrompt, restApiBackendSetupPrompt, supabaseSetupPrompt } from "../packages/stack-shared/src/ai/unified-prompts/skill-site-prompt-parts/ai-setup-prompt";
|
||||
import { deindent } from "../packages/stack-shared/src/utils/strings";
|
||||
import { writeFileSyncIfChanged } from "./utils";
|
||||
import { remindersPrompt } from "../packages/stack-shared/src/ai/unified-prompts/reminders";
|
||||
@ -11,6 +11,7 @@ type SdkSetupToolCategory = "frontend" | "backend" | "database" | "other";
|
||||
type SdkSetupTool = {
|
||||
label: string,
|
||||
where: SdkSetupToolCategory[],
|
||||
filterGroups: ("js" | "python" | "other")[],
|
||||
imageUrl: string,
|
||||
monochromeLogo: boolean,
|
||||
tabs: { label: string, mdContent: string }[],
|
||||
@ -25,6 +26,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
nextjs: {
|
||||
label: "Next.js",
|
||||
where: ["frontend", "backend"],
|
||||
filterGroups: ["js"],
|
||||
imageUrl: "/images/setup-tools/nextjs.svg",
|
||||
monochromeLogo: true,
|
||||
tabs: [{
|
||||
@ -36,6 +38,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
react: {
|
||||
label: "React",
|
||||
where: ["frontend"],
|
||||
filterGroups: ["js"],
|
||||
imageUrl: "/images/setup-tools/react.svg",
|
||||
monochromeLogo: false,
|
||||
tabs: [{
|
||||
@ -47,6 +50,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
js: {
|
||||
label: "Other JS/TS",
|
||||
where: ["frontend"],
|
||||
filterGroups: ["js"],
|
||||
imageUrl: "/images/setup-tools/javascript.svg",
|
||||
monochromeLogo: false,
|
||||
tabs: [{
|
||||
@ -58,6 +62,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
"tanstack-start": {
|
||||
label: "Tanstack Start",
|
||||
where: ["frontend"],
|
||||
filterGroups: ["js"],
|
||||
imageUrl: "/images/setup-tools/tanstack.svg",
|
||||
monochromeLogo: true,
|
||||
tabs: [{
|
||||
@ -69,6 +74,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
"tanstack-query": {
|
||||
label: "Tanstack Query",
|
||||
where: ["frontend"],
|
||||
filterGroups: ["js"],
|
||||
imageUrl: "/images/setup-tools/tanstack.svg",
|
||||
monochromeLogo: true,
|
||||
tabs: [],
|
||||
@ -77,6 +83,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
nodejs: {
|
||||
label: "Node.js",
|
||||
where: ["backend"],
|
||||
filterGroups: ["js"],
|
||||
imageUrl: "/images/setup-tools/nodejs.svg",
|
||||
monochromeLogo: false,
|
||||
tabs: [{
|
||||
@ -88,6 +95,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
bun: {
|
||||
label: "Bun",
|
||||
where: ["backend"],
|
||||
filterGroups: ["js"],
|
||||
imageUrl: "/images/setup-tools/bun.svg",
|
||||
monochromeLogo: false,
|
||||
tabs: [{
|
||||
@ -96,9 +104,34 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
}],
|
||||
extraFeatures: [],
|
||||
},
|
||||
python: {
|
||||
label: "Python",
|
||||
where: ["backend"],
|
||||
filterGroups: ["python"],
|
||||
imageUrl: "/images/setup-tools/python.svg",
|
||||
monochromeLogo: false,
|
||||
tabs: [{
|
||||
label: "Python",
|
||||
mdContent: pythonBackendSetupPrompt,
|
||||
}],
|
||||
extraFeatures: [],
|
||||
},
|
||||
"rest-api": {
|
||||
label: "Other (REST API)",
|
||||
where: ["backend"],
|
||||
filterGroups: ["other"],
|
||||
imageUrl: "/images/setup-tools/cli.svg",
|
||||
monochromeLogo: true,
|
||||
tabs: [{
|
||||
label: "Other (REST API)",
|
||||
mdContent: restApiBackendSetupPrompt,
|
||||
}],
|
||||
extraFeatures: [],
|
||||
},
|
||||
convex: {
|
||||
label: "Convex",
|
||||
where: ["backend", "database"],
|
||||
filterGroups: ["js"],
|
||||
imageUrl: "/images/setup-tools/convex.svg",
|
||||
monochromeLogo: false,
|
||||
tabs: [{
|
||||
@ -110,6 +143,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
supabase: {
|
||||
label: "Supabase",
|
||||
where: ["database"],
|
||||
filterGroups: ["other"],
|
||||
imageUrl: "/images/setup-tools/supabase.svg",
|
||||
monochromeLogo: false,
|
||||
tabs: [{
|
||||
@ -121,6 +155,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
cli: {
|
||||
label: "CLI",
|
||||
where: ["other"],
|
||||
filterGroups: ["other"],
|
||||
imageUrl: "/images/setup-tools/cli.svg",
|
||||
monochromeLogo: true,
|
||||
tabs: [{
|
||||
@ -132,6 +167,7 @@ const sdkSetupTools: Record<string, SdkSetupTool> = {
|
||||
/*mcp: {
|
||||
label: "MCP",
|
||||
where: ["other"],
|
||||
filterGroups: ["other"],
|
||||
imageUrl: "/images/setup-tools/mcp.svg",
|
||||
monochromeLogo: true,
|
||||
tabs: [{
|
||||
@ -201,6 +237,7 @@ function renderToolCards(category: SdkSetupToolCategory) {
|
||||
data-tool-label="${tool.label}"
|
||||
data-tool-has-tabs="${hasTabs ? "true" : "false"}"
|
||||
data-tool-extra-features="${tool.extraFeatures.join(",")}"
|
||||
data-tool-filter-groups="${tool.filterGroups.join(",")}"
|
||||
onClick={onSetupToolClick}
|
||||
className="group flex flex-col items-center gap-1 rounded-xl px-1 py-1 text-center transition-colors duration-150 hover:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 aria-pressed:bg-white/60 aria-pressed:ring-2 aria-pressed:ring-[#6b5df7] dark:aria-pressed:bg-white/10"
|
||||
title="${tool.label}"
|
||||
@ -222,7 +259,7 @@ function renderToolCards(category: SdkSetupToolCategory) {
|
||||
|
||||
function renderToolCategory(category: SdkSetupToolCategory) {
|
||||
return deindent`
|
||||
<section className="grid gap-3 sm:grid-cols-[6rem_1fr] sm:items-start">
|
||||
<section data-setup-tool-category="true" className="grid gap-3 sm:grid-cols-[6rem_1fr] sm:items-start">
|
||||
<h3 className="pt-1 text-sm font-semibold text-[#2e446f] dark:text-[#d8e7ff]">${categoryLabels.get(category)}</h3>
|
||||
<div className="grid grid-cols-3 gap-x-2 gap-y-3 sm:grid-cols-4 sm:gap-x-3 sm:gap-y-3 lg:grid-cols-6">
|
||||
${renderToolCards(category)}
|
||||
@ -231,6 +268,21 @@ function renderToolCategory(category: SdkSetupToolCategory) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSetupFilterButton(filterId: string, label: string) {
|
||||
return deindent`
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed="true"
|
||||
data-setup-filter-button="true"
|
||||
data-filter-id="${filterId}"
|
||||
onClick={onSetupFilterClick}
|
||||
className="inline-flex items-center justify-center rounded-full px-2.5 py-1 text-xs font-medium text-[#526994] transition-colors duration-150 hover:transition-none hover:bg-white/45 hover:text-[#2a4272] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 aria-pressed:bg-white/70 aria-pressed:text-[#243d70] aria-pressed:shadow-sm dark:text-[#9eb3d8] dark:hover:bg-white/10 dark:hover:text-white dark:aria-pressed:bg-white/14 dark:aria-pressed:text-white"
|
||||
>
|
||||
${label}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMarkdownInTabPanel(mdContent: string) {
|
||||
return mdContent.replace(/<Note>\n([\s\S]*?)\n\s*<\/Note>/g, (_match, noteContent: string) => {
|
||||
const blockquoteContent = deindent(noteContent)
|
||||
@ -366,8 +418,28 @@ writeFileSyncIfChanged(
|
||||
};
|
||||
|
||||
export const updateSetupBuilder = (root, syncUrl = true) => {
|
||||
const activeFilterIds = new Set(
|
||||
Array.from(root.querySelectorAll("[data-setup-filter-button='true'][aria-pressed='true']"))
|
||||
.map((button) => button.getAttribute("data-filter-id"))
|
||||
.filter((filterId) => filterId != null)
|
||||
);
|
||||
for (const toolCard of root.querySelectorAll("[data-setup-tool-card='true']")) {
|
||||
const filterGroups = (toolCard.getAttribute("data-tool-filter-groups") ?? "")
|
||||
.split(",")
|
||||
.filter((filterGroup) => filterGroup.length > 0);
|
||||
const shouldShow = filterGroups.some((filterGroup) => activeFilterIds.has(filterGroup));
|
||||
toolCard.hidden = !shouldShow;
|
||||
toolCard.style.display = shouldShow ? "" : "none";
|
||||
}
|
||||
for (const toolCategory of root.querySelectorAll("[data-setup-tool-category='true']")) {
|
||||
const hasVisibleCard = Array.from(toolCategory.querySelectorAll("[data-setup-tool-card='true']"))
|
||||
.some((toolCard) => !toolCard.hidden);
|
||||
toolCategory.hidden = !hasVisibleCard;
|
||||
toolCategory.style.display = hasVisibleCard ? "" : "none";
|
||||
}
|
||||
const selectedToolIds = new Set(
|
||||
Array.from(root.querySelectorAll("[data-setup-tool-card='true'][aria-pressed='true']"))
|
||||
.filter((card) => !card.hidden)
|
||||
.map((card) => card.getAttribute("data-tool-id"))
|
||||
.filter((toolId) => toolId != null)
|
||||
);
|
||||
@ -436,6 +508,21 @@ writeFileSyncIfChanged(
|
||||
updateSetupBuilder(root);
|
||||
};
|
||||
|
||||
export const onSetupFilterClick = (event) => {
|
||||
const button = event.currentTarget;
|
||||
const root = button.closest("[data-setup-builder='true']");
|
||||
if (root == null) {
|
||||
return;
|
||||
}
|
||||
const nextPressed = button.getAttribute("aria-pressed") !== "true";
|
||||
const currentlyPressedFilters = root.querySelectorAll("[data-setup-filter-button='true'][aria-pressed='true']").length;
|
||||
if (!nextPressed && currentlyPressedFilters <= 1) {
|
||||
return;
|
||||
}
|
||||
button.setAttribute("aria-pressed", nextPressed ? "true" : "false");
|
||||
updateSetupBuilder(root);
|
||||
};
|
||||
|
||||
<Visibility for="agents">
|
||||
{hexclaveAgentRemindersText}
|
||||
</Visibility>
|
||||
@ -461,6 +548,14 @@ writeFileSyncIfChanged(
|
||||
</div>
|
||||
|
||||
<div className="not-prose mt-5 space-y-4 rounded-2xl border border-[#d6e4ff] bg-gradient-to-b from-[#f7faff] to-[#eaf2ff] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_10px_30px_-24px_rgba(47,79,140,0.35)] dark:border-[#1f2d45] dark:from-[#11203a] dark:to-[#070f1f] dark:shadow-[inset_0_1px_0_rgba(112,152,224,0.18),0_16px_34px_-24px_rgba(2,8,20,0.85)] sm:p-4">
|
||||
<div className="flex flex-col gap-2 border-b border-[#d6e4ff]/80 pb-3 dark:border-[#263a5f] sm:flex-row sm:items-center">
|
||||
<span className="text-sm font-medium text-[#526994] dark:text-[#9eb3d8]">Filter:</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
${renderSetupFilterButton("js", "JS")}
|
||||
${renderSetupFilterButton("python", "Python")}
|
||||
${renderSetupFilterButton("other", "Other")}
|
||||
</div>
|
||||
</div>
|
||||
${renderToolCategory("frontend")}
|
||||
${renderToolCategory("backend")}
|
||||
${renderToolCategory("database")}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user