Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bff1852a85 | ||
|
|
7327b448e5 | ||
|
|
eb1e90bb7f | ||
|
|
3905d1cb81 | ||
|
|
cd084e8236 | ||
|
|
5d96484501 | ||
|
|
6747a497fc | ||
|
|
2df65527d3 | ||
|
|
d0b69455e2 | ||
|
|
6028b1f5c0 | ||
|
|
658cf11299 | ||
|
|
6312df3a07 | ||
|
|
9de7a00373 | ||
|
|
325a666a8b | ||
|
|
2149f5e36d | ||
|
|
009e1da5f1 | ||
|
|
6101493f12 | ||
|
|
4b5c2b43e9 | ||
|
|
bd402cdda5 | ||
|
|
0c30646a2d | ||
|
|
53792b9a1d | ||
|
|
48f86e66f4 | ||
|
|
fd422b5d0d | ||
|
|
17cf72834d | ||
|
|
3122b506fd | ||
|
|
a31305b7ee | ||
|
|
4f26a7aa73 |
6
.github/workflows/ci.yml
vendored
@@ -32,6 +32,7 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: '--no-warnings'
|
||||
NEXT_PUBLIC_APP_URL: 'https://www.sim.ai'
|
||||
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio'
|
||||
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
|
||||
run: bun run test
|
||||
|
||||
@@ -39,6 +40,7 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: '--no-warnings'
|
||||
NEXT_PUBLIC_APP_URL: 'https://www.sim.ai'
|
||||
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio'
|
||||
STRIPE_SECRET_KEY: 'dummy_key_for_ci_only'
|
||||
STRIPE_WEBHOOK_SECRET: 'dummy_secret_for_ci_only'
|
||||
RESEND_API_KEY: 'dummy_key_for_ci_only'
|
||||
@@ -71,7 +73,7 @@ jobs:
|
||||
run: bun install
|
||||
|
||||
- name: Apply migrations
|
||||
working-directory: ./apps/sim
|
||||
working-directory: ./packages/db
|
||||
env:
|
||||
DATABASE_URL: ${{ github.ref == 'refs/heads/main' && secrets.DATABASE_URL || secrets.STAGING_DATABASE_URL }}
|
||||
run: bunx drizzle-kit migrate
|
||||
run: bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
|
||||
101
.github/workflows/i18n.yml
vendored
@@ -2,17 +2,16 @@ name: 'Auto-translate Documentation'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'apps/docs/content/docs/en/**'
|
||||
- 'apps/docs/i18n.json'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ staging ]
|
||||
paths:
|
||||
- 'apps/docs/content/docs/en/**'
|
||||
- 'apps/docs/i18n.json'
|
||||
workflow_dispatch: # Allow manual triggers
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -22,14 +21,14 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
|
||||
- name: Run Lingo.dev translations
|
||||
env:
|
||||
LINGODOTDEV_API_KEY: ${{ secrets.LINGODOTDEV_API_KEY }}
|
||||
@@ -50,38 +49,56 @@ jobs:
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Commit and push translation updates
|
||||
- name: Create Pull Request with translations
|
||||
if: steps.changes.outputs.changes == 'true'
|
||||
run: |
|
||||
cd apps/docs
|
||||
git add content/docs/es/ content/docs/fr/ content/docs/zh/ i18n.lock
|
||||
git commit -m "feat: update translations"
|
||||
git push origin ${{ github.ref_name }}
|
||||
|
||||
- name: Create Pull Request (for feature branches)
|
||||
if: steps.changes.outputs.changes == 'true' && github.event_name == 'pull_request'
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "feat: update translations"
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
commit-message: "feat(i18n): update translations"
|
||||
title: "🌐 Auto-update translations"
|
||||
body: |
|
||||
## Summary
|
||||
Automated translation updates for documentation.
|
||||
Automated translation updates triggered by changes to documentation.
|
||||
|
||||
- Updated translations for modified English content
|
||||
- Generated using Lingo.dev AI translation
|
||||
- Maintains consistency with source documentation
|
||||
This PR was automatically created after content changes were made, updating translations for all supported languages using Lingo.dev AI translation engine.
|
||||
|
||||
## Test Plan
|
||||
- [ ] Verify translated content accuracy
|
||||
- [ ] Check that all links and references work correctly
|
||||
- [ ] Ensure formatting and structure are preserved
|
||||
branch: auto-translations
|
||||
base: ${{ github.base_ref }}
|
||||
**Original trigger**: ${{ github.event.head_commit.message }}
|
||||
**Commit**: ${{ github.sha }}
|
||||
**Workflow**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [x] Documentation
|
||||
- [ ] Other: ___________
|
||||
|
||||
## Testing
|
||||
This PR includes automated translations for modified English documentation content:
|
||||
- 🇪🇸 Spanish (es) translations
|
||||
- 🇫🇷 French (fr) translations
|
||||
- 🇨🇳 Chinese (zh) translations
|
||||
|
||||
**What reviewers should focus on:**
|
||||
- Verify translated content accuracy and context
|
||||
- Check that all links and references work correctly in translated versions
|
||||
- Ensure formatting, code blocks, and structure are preserved
|
||||
- Validate that technical terms are appropriately translated
|
||||
|
||||
## Checklist
|
||||
- [x] Code follows project style guidelines (automated translation)
|
||||
- [x] Self-reviewed my changes (automated process)
|
||||
- [ ] Tests added/updated and passing
|
||||
- [x] No new warnings introduced
|
||||
- [x] I confirm that I have read and agree to the terms outlined in the [Contributor License Agreement (CLA)](./CONTRIBUTING.md#contributor-license-agreement-cla)
|
||||
|
||||
## Screenshots/Videos
|
||||
<!-- Translation changes are text-based - no visual changes expected -->
|
||||
<!-- Reviewers should check the documentation site renders correctly for all languages -->
|
||||
branch: auto-translate/staging-merge-${{ github.run_id }}
|
||||
base: staging
|
||||
labels: |
|
||||
i18n
|
||||
auto-generated
|
||||
|
||||
verify-translations:
|
||||
needs: translate
|
||||
@@ -91,26 +108,30 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
with:
|
||||
ref: staging
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd apps/docs
|
||||
bun install
|
||||
|
||||
|
||||
- name: Build documentation to verify translations
|
||||
run: |
|
||||
cd apps/docs
|
||||
bun run build
|
||||
|
||||
|
||||
- name: Report translation status
|
||||
run: |
|
||||
cd apps/docs
|
||||
echo "## Translation Status Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Triggered by merge to staging branch**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
en_count=$(find content/docs/en -name "*.mdx" | wc -l)
|
||||
es_count=$(find content/docs/es -name "*.mdx" 2>/dev/null | wc -l || echo 0)
|
||||
@@ -121,6 +142,10 @@ jobs:
|
||||
fr_percentage=$((fr_count * 100 / en_count))
|
||||
zh_percentage=$((zh_count * 100 / en_count))
|
||||
|
||||
echo "### Coverage Statistics" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **🇬🇧 English**: $en_count files (source)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **🇪🇸 Spanish**: $es_count/$en_count files ($es_percentage%)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **🇫🇷 French**: $fr_count/$en_count files ($fr_percentage%)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **🇨🇳 Chinese**: $zh_count/$en_count files ($zh_percentage%)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **🇨🇳 Chinese**: $zh_count/$en_count files ($zh_percentage%)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🔄 **Auto-translation PR**: Check for new pull request with updated translations" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -125,10 +125,11 @@ Update your `.env` file with the database URL:
|
||||
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
```
|
||||
|
||||
4. Set up the database:
|
||||
4. Set up the database (from packages/db):
|
||||
|
||||
```bash
|
||||
bunx drizzle-kit migrate
|
||||
cd packages/db
|
||||
bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
```
|
||||
|
||||
5. Start the development servers:
|
||||
|
||||
@@ -57,7 +57,7 @@ In Sim, the Airtable integration enables your agents to interact with your Airta
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Airtable functionality to manage table records. List, get, create,
|
||||
Integrates Airtable into the workflow. Can create, get, list, or update Airtable records. Requires OAuth. Can be used in trigger mode to trigger a workflow when an update is made to an Airtable table.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ In Sim, the ArXiv integration enables your agents to programmatically search, re
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Search for academic papers, retrieve metadata, download papers, and access the vast collection of scientific research on ArXiv.
|
||||
Integrates ArXiv into the workflow. Can search for papers, get paper details, and get author papers. Does not require OAuth or an API key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ In Sim, the BrowserUse integration allows your agents to interact with the web a
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Execute browser automation tasks with BrowserUse to navigate the web, scrape data, and perform actions as if a real user was interacting with the browser. The task runs asynchronously and the block will poll for completion before returning results.
|
||||
Integrate Browser Use into the workflow. Can navigate the web and perform actions as if a real user was interacting with the browser. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ In Sim, the Clay integration allows your agents to push structured data into Cla
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Populate Clay workbook with data using a JSON or plain text. Enables direct communication and notifications with channel confirmation.
|
||||
Integrate Clay into the workflow. Can populate a table with data. Requires an API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ In Sim, the Confluence integration enables your agents to access and leverage yo
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to Confluence workspaces to retrieve and search documentation. Access page content, metadata, and integrate Confluence documentation into your workflows.
|
||||
Integrate Confluence into the workflow. Can read and update a page. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
<g>
|
||||
<path
|
||||
d='M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z'
|
||||
fill='#5865F2'
|
||||
fill='currentColor'
|
||||
fillRule='nonzero'
|
||||
/>
|
||||
</g>
|
||||
@@ -57,7 +57,7 @@ Discord components in Sim use efficient lazy loading, only fetching data when ne
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to Discord to send messages, manage channels, and interact with servers. Automate notifications, community management, and integrate Discord into your workflows.
|
||||
Integrate Discord into the workflow. Can send and get messages, get server information, and get a user’s information. Requires bot API key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ In Sim, the ElevenLabs integration enables your agents to convert text to lifeli
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Generate realistic speech from text using ElevenLabs voices.
|
||||
Integrate ElevenLabs into the workflow. Can convert text to speech. Requires API key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ In Sim, the Exa integration allows your agents to search the web for information
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Search the web, retrieve content, find similar links, and answer questions using Exa's powerful AI search capabilities.
|
||||
Integrate Exa into the workflow. Can search, get contents, find similar links, answer a question, and perform research. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ The File Parser tool is particularly useful for scenarios where your agents need
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Upload and extract contents from structured file formats including PDFs, CSV spreadsheets, and Word documents (DOCX). You can either provide a URL to a file or upload files directly. Specialized parsers extract text and metadata from each format. You can upload multiple files at once and access them individually or as a combined document.
|
||||
Integrate File into the workflow. Can upload a file manually or insert a file url.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ This allows your agents to gather information from websites, extract structured
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Extract content from any website with advanced web scraping or search the web for information. Retrieve clean, structured data from web pages with options to focus on main content, or intelligently search for information across the web.
|
||||
Integrate Firecrawl into the workflow. Can search, scrape, or crawl websites. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Webhook
|
||||
description: Receive webhooks from any service
|
||||
description: Receive webhooks from any service by configuring a custom webhook.
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
@@ -35,7 +35,7 @@ In Sim, the GitHub integration enables your agents to interact directly with Git
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Access GitHub repositories, pull requests, and comments through the GitHub API. Automate code reviews, PR management, and repository interactions within your workflow. Trigger workflows from GitHub events like push, pull requests, and issues.
|
||||
Integrate Github into the workflow. Can get get PR details, create PR comment, get repository info, and get latest commit. Requires github token API Key. Can be used in trigger mode to trigger a workflow when a PR is created, commented on, or a commit is pushed.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ In Sim, the Gmail integration enables your agents to send, read, and search emai
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Comprehensive Gmail integration with OAuth authentication. Send email messages, read email content, and trigger workflows from Gmail events like new emails and label changes.
|
||||
Integrate Gmail into the workflow. Can send, read, and search emails. Requires OAuth. Can be used in trigger mode to trigger a workflow when a new email is received.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ In Sim, the Google Calendar integration enables your agents to programmatically
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Calendar functionality to create, read, update, and list calendar events within your workflow. Automate scheduling, check availability, and manage events using OAuth authentication. Email invitations are sent asynchronously and delivery depends on recipients' Google Calendar settings.
|
||||
Integrate Google Calendar into the workflow. Can create, read, update, and list calendar events. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ In Sim, the Google Docs integration enables your agents to interact directly wit
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Docs functionality to manage documents. Read content from existing documents, write to documents, and create new documents using OAuth authentication. Supports text content manipulation for document creation and editing.
|
||||
Integrate Google Docs into the workflow. Can read, write, and create documents. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ In Sim, the Google Drive integration enables your agents to interact directly wi
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Drive functionality to manage files and folders. Upload new files, get content from existing files, create new folders, and list contents of folders using OAuth authentication. Supports file operations with custom MIME types and folder organization.
|
||||
Integrate Google Drive into the workflow. Can create, upload, and list files. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
86
apps/docs/content/docs/en/tools/google_forms.mdx
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Google Forms
|
||||
description: Read responses from a Google Form
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="google_forms"
|
||||
color="#E0E0E0"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 65' fill='none'>
|
||||
<path
|
||||
d='M29.583 0H4.438C1.997 0 0 1.997 0 4.438v56.208C0 63.086 1.997 65.083 4.438 65.083h38.458c2.44 0 4.437-1.997 4.437-4.437V17.75L36.979 10.354 29.583 0Z'
|
||||
fill='#673AB7'
|
||||
/>
|
||||
<path
|
||||
d='M29.583 0v10.354c0 2.45 1.986 4.438 4.438 4.438h13.312L36.979 10.354 29.583 0Z'
|
||||
fill='#B39DDB'
|
||||
/>
|
||||
<path
|
||||
d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z'
|
||||
fill='#F1F1F1'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='gf-gradient'
|
||||
x1='30.881'
|
||||
y1='16.452'
|
||||
x2='47.333'
|
||||
y2='32.9'
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#9575CD' />
|
||||
<stop offset='1' stopColor='#7E57C2' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>`}
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[Google Forms](https://forms.google.com) is Google's online survey and form tool that allows users to create forms, collect responses, and analyze results. As part of Google's productivity suite, Google Forms makes it easy to gather information, feedback, and data from users.
|
||||
|
||||
Learn how to integrate the Google Forms tool in Sim to automatically read and process form responses in your workflows. This tutorial walks you through connecting Google Forms, retrieving responses, and using collected data to power automation. Perfect for syncing survey results, registrations, or feedback with your agents in real-time.
|
||||
|
||||
With Google Forms, you can:
|
||||
|
||||
- **Create surveys and forms**: Design custom forms for feedback, registration, quizzes, and more
|
||||
- **Collect responses automatically**: Gather data from users in real-time
|
||||
- **Analyze results**: View responses in Google Forms or export to Google Sheets for further analysis
|
||||
- **Collaborate easily**: Share forms and work with others to build and review questions
|
||||
- **Integrate with other Google services**: Connect with Google Sheets, Drive, and more
|
||||
|
||||
In Sim, the Google Forms integration enables your agents to programmatically access form responses. This allows for powerful automation scenarios such as processing survey data, triggering workflows based on new submissions, and syncing form results with other tools. Your agents can fetch all responses for a form, retrieve a specific response, and use the data to drive intelligent automation. By connecting Sim with Google Forms, you can automate data collection, streamline feedback processing, and incorporate form responses into your agent's capabilities.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Forms into your workflow. Provide a Form ID to list responses, or specify a Response ID to fetch a single response. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `google_forms_get_responses`
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| formId | string | Yes | The ID of the Google Form |
|
||||
| responseId | string | No | If provided, returns this specific response |
|
||||
| pageSize | number | No | Max responses to return (service may return fewer). Defaults to 5000 |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `data` | json | Response or list of responses |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `google_forms`
|
||||
@@ -58,7 +58,7 @@ In Sim, the Google Search integration enables your agents to search the web prog
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Searches the web using the Google Custom Search API, which provides high-quality search results from the entire internet or a specific site defined by a custom search engine ID.
|
||||
Integrate Google Search into the workflow. Can search the web. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ In Sim, the Google Sheets integration enables your agents to interact directly w
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Google Sheets functionality to manage spreadsheet data. Read data from specific ranges, write new data, update existing cells, and append data to the end of sheets using OAuth authentication. Supports various input and output formats for flexible data handling.
|
||||
Integrate Google Sheets into the workflow. Can read, write, append, and update data. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ In Sim, the HuggingFace integration enables your agents to programmatically gene
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Generate completions using Hugging Face Inference API with access to various open-source models. Leverage cutting-edge AI models for chat completions, content generation, and AI-powered conversations with customizable parameters.
|
||||
Integrate Hugging Face into the workflow. Can generate completions using the Hugging Face Inference API. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ In Sim, the Hunter.io integration enables your agents to programmatically search
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Search for email addresses, verify their deliverability, discover companies, and enrich contact data using Hunter.io's powerful email finding capabilities.
|
||||
Integrate Hunter into the workflow. Can search domains, find email addresses, verify email addresses, discover companies, find companies, and count email addresses. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ In Sim, the DALL-E integration enables your agents to generate images programmat
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Create high-quality images using OpenAI's image generation models. Configure resolution, quality, style, and other parameters to get exactly the image you need.
|
||||
Integrate Image Generator into the workflow. Can generate images using DALL-E 3 or GPT Image. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ This integration is particularly valuable for building agents that need to gathe
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Transform web content into clean, readable text using Jina AI's advanced extraction capabilities. Extract meaningful content from websites while preserving important information and optionally gathering links.
|
||||
Integrate Jina into the workflow. Extracts content from websites. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ In Sim, the Jira integration allows your agents to seamlessly interact with your
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to Jira workspaces to read, write, and update issues. Access content, metadata, and integrate Jira documentation into your workflows.
|
||||
Integrate Jira into the workflow. Can read, write, and update issues. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ In Sim, the Knowledge Base block enables your agents to perform intelligent sema
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Perform semantic vector search across knowledge bases, upload individual chunks to existing documents, or create new documents from text content. Uses advanced AI embeddings to understand meaning and context for search operations.
|
||||
Integrate Knowledge into the workflow. Can search, upload chunks, and create documents.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,11 +10,8 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
color="#5E6AD2"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon"
|
||||
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
fill='currentColor'
|
||||
|
||||
|
||||
viewBox='0 0 100 100'
|
||||
>
|
||||
<path
|
||||
@@ -42,7 +39,7 @@ In Sim, the Linear integration allows your agents to seamlessly interact with yo
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate with Linear to fetch, filter, and create issues directly from your workflow.
|
||||
Integrate Linear into the workflow. Can read and create issues. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ To implement Linkup in your agent, simply add the tool to your agent's configura
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Linkup Search allows you to search and retrieve up-to-date information from the web with source attribution.
|
||||
Integrate Linkup into the workflow. Can search the web. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
70
apps/docs/content/docs/en/tools/mail.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Mail
|
||||
description: Send emails using the internal mail service
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mail"
|
||||
color="#181C1E"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon"
|
||||
|
||||
|
||||
|
||||
viewBox='0 0 30 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M2.35742 5.83288L11.7674 12.1071C13.0656 12.9712 13.7141 13.404 14.4151 13.5725C15.0352 13.7208 15.681 13.7208 16.2998 13.5725C17.0008 13.404 17.6492 12.9712 18.9475 12.1071L28.3574 5.83288M8.82844 21.7219H21.8864C24.1513 21.7219 25.2837 21.7219 26.1492 21.2811C26.9097 20.8931 27.5278 20.2744 27.9152 19.5137C28.3574 18.6482 28.3574 17.5158 28.3574 15.2509V7.97102C28.3574 5.70616 28.3574 4.57373 27.9166 3.70823C27.5288 2.94727 26.9102 2.32858 26.1492 1.94084C25.2837 1.5 24.1513 1.5 21.8864 1.5H8.82844C6.56358 1.5 5.43115 1.5 4.56566 1.94084C3.80519 2.32881 3.187 2.94747 2.79961 3.70823C2.35742 4.57373 2.35742 5.70616 2.35742 7.97102V15.2509C2.35742 17.5158 2.35742 18.6482 2.79826 19.5137C3.186 20.2747 3.80469 20.8933 4.56566 21.2811C5.43115 21.7219 6.56358 21.7219 8.82844 21.7219Z'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2.5'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>`}
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The Mail block allows you to send emails directly from your workflows using Sim's own mail sending infrastructure powered by [Resend](https://resend.com/). This integration enables you to programmatically deliver notifications, alerts, and other important information to users' email addresses without requiring any external configuration or OAuth.
|
||||
|
||||
Our internal mail service is designed for reliability and ease of use, making it ideal for automating communications and ensuring your messages reach recipients efficiently.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Send emails directly using the internal mail service. Uses MAIL_BLOCK_FROM_ADDRESS if configured, otherwise falls back to FROM_EMAIL_ADDRESS. No external configuration or OAuth required. Perfect for sending notifications, alerts, or general purpose emails from your workflows. Supports HTML formatting.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `mail_send`
|
||||
|
||||
Send an email using the internal mail service without requiring OAuth or external configuration
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `to` | string | Yes | Recipient email address |
|
||||
| `subject` | string | Yes | Email subject |
|
||||
| `body` | string | Yes | Email body content |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the email was sent successfully |
|
||||
| `to` | string | Recipient email address |
|
||||
| `subject` | string | Email subject |
|
||||
| `body` | string | Email body content |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `mail`
|
||||
40
apps/docs/content/docs/en/tools/mcp.mdx
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: MCP Tool
|
||||
description: Execute tools from Model Context Protocol (MCP) servers
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="mcp"
|
||||
color="#181C1E"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon"
|
||||
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<rect x='2' y='2' rx='2' ry='2' />
|
||||
<rect x='2' y='14' rx='2' ry='2' />
|
||||
<line x1='6' x2='6.01' y1='6' y2='6' />
|
||||
<line x1='6' x2='6.01' y1='18' y2='18' />
|
||||
</svg>`}
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate MCP into the workflow. Can execute tools from MCP servers. Requires MCP servers in workspace settings.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `mcp`
|
||||
@@ -44,7 +44,7 @@ In Sim, the Mem0 integration enables your agents to maintain persistent memory a
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Add, search, retrieve, and delete memories using Mem0. Store conversation history, user preferences, and context across workflow executions for enhanced AI agent capabilities.
|
||||
Integrate Mem0 into the workflow. Can add, search, and retrieve memories. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Create persistent storage for data that needs to be accessed across multiple workflow steps. Store and retrieve information throughout your workflow execution to maintain context and state.
|
||||
Integrate Memory into the workflow. Can add, get a memory, get all memories, and delete memories.
|
||||
|
||||
|
||||
|
||||
|
||||
74
apps/docs/content/docs/en/tools/meta.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"items": [
|
||||
"index",
|
||||
"airtable",
|
||||
"arxiv",
|
||||
"browser_use",
|
||||
"clay",
|
||||
"confluence",
|
||||
"discord",
|
||||
"elevenlabs",
|
||||
"exa",
|
||||
"file",
|
||||
"firecrawl",
|
||||
"generic_webhook",
|
||||
"github",
|
||||
"gmail",
|
||||
"google_calendar",
|
||||
"google_docs",
|
||||
"google_drive",
|
||||
"google_forms",
|
||||
"google_search",
|
||||
"google_sheets",
|
||||
"huggingface",
|
||||
"hunter",
|
||||
"image_generator",
|
||||
"jina",
|
||||
"jira",
|
||||
"knowledge",
|
||||
"linear",
|
||||
"linkup",
|
||||
"mail",
|
||||
"mcp",
|
||||
"mem0",
|
||||
"memory",
|
||||
"microsoft_excel",
|
||||
"microsoft_planner",
|
||||
"microsoft_teams",
|
||||
"mistral_parse",
|
||||
"mongodb",
|
||||
"mysql",
|
||||
"notion",
|
||||
"onedrive",
|
||||
"openai",
|
||||
"outlook",
|
||||
"parallel_ai",
|
||||
"perplexity",
|
||||
"pinecone",
|
||||
"postgresql",
|
||||
"qdrant",
|
||||
"reddit",
|
||||
"s3",
|
||||
"schedule",
|
||||
"serper",
|
||||
"sharepoint",
|
||||
"slack",
|
||||
"sms",
|
||||
"stagehand",
|
||||
"stagehand_agent",
|
||||
"supabase",
|
||||
"tavily",
|
||||
"telegram",
|
||||
"thinking",
|
||||
"translate",
|
||||
"twilio_sms",
|
||||
"typeform",
|
||||
"vision",
|
||||
"wealthbox",
|
||||
"webhook",
|
||||
"whatsapp",
|
||||
"wikipedia",
|
||||
"x",
|
||||
"youtube"
|
||||
]
|
||||
}
|
||||
@@ -94,7 +94,7 @@ In Sim, the Microsoft Excel integration provides seamless access to spreadsheet
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Microsoft Excel functionality to manage spreadsheet data. Read data from specific ranges, write new data, update existing cells, and manipulate table data using OAuth authentication. Supports various input and output formats for flexible data handling.
|
||||
Integrate Microsoft Excel into the workflow. Can read, write, update, and add to table. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ In Sim, the Microsoft Planner integration allows your agents to programmatically
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Microsoft Planner functionality to manage tasks. Read all user tasks, tasks from specific plans, individual tasks, or create new tasks with various properties like title, description, due date, and assignees using OAuth authentication.
|
||||
Integrate Microsoft Planner into the workflow. Can read and create tasks. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ In Sim, the Microsoft Teams integration enables your agents to interact directly
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Microsoft Teams functionality to manage messages. Read content from existing messages and write to messages using OAuth authentication. Supports text content manipulation for message creation and editing.
|
||||
Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Requires OAuth. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ The Mistral Parse tool is particularly useful for scenarios where your agents ne
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Extract text and structure from PDF documents using Mistral's OCR API. Either enter a URL to a PDF document or upload a PDF file directly. Configure processing options and get the content in your preferred format. For URLs, they must be publicly accessible and point to a valid PDF file. Note: Google Drive, Dropbox, and other cloud storage links are not supported; use a direct download URL from a web server instead.
|
||||
Integrate Mistral Parse into the workflow. Can extract text from uploaded PDF documents, or from a URL. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to any MongoDB database to execute queries, manage data, and perform database operations. Supports find, insert, update, delete, and aggregation operations with secure connection handling.
|
||||
Integrate MongoDB into the workflow. Can find, insert, update, delete, and aggregate data.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ The MySQL tool is ideal for scenarios where your agents need to interact with st
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to any MySQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling.
|
||||
Integrate MySQL into the workflow. Can query, insert, update, delete, and execute raw SQL.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ This integration bridges the gap between your AI workflows and your knowledge ba
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate with Notion to read content from pages, write new content, and create new pages.
|
||||
Integrate with Notion into the workflow. Can read page, read database, create page, create database, append content, query database, and search workspace. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ In Sim, the OneDrive integration enables your agents to directly interact with y
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate OneDrive functionality to manage files and folders. Upload new files, create new folders, and list contents of folders using OAuth authentication. Supports file operations with custom MIME types and folder organization.
|
||||
Integrate OneDrive into the workflow. Can create, upload, and list files. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ In Sim, the OpenAI integration enables your agents to leverage these powerful AI
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Convert text into numerical vector representations using OpenAI's embedding models. Transform text data into embeddings for semantic search, clustering, and other vector-based operations.
|
||||
Integrate Embeddings into the workflow. Can generate embeddings from text. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ In Sim, the Microsoft Outlook integration enables your agents to interact direct
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Outlook functionality to read, draft, andsend email messages within your workflow. Automate email communications and process email content using OAuth authentication.
|
||||
Integrate Outlook into the workflow. Can read, draft, and send email messages. Requires OAuth. Can be used in trigger mode to trigger a workflow when a new email is received.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ In Sim, the Parallel AI integration empowers your agents to perform web searches
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Search the web using Parallel AI's advanced search capabilities. Get comprehensive results with intelligent processing and content extraction.
|
||||
Integrate Parallel AI into the workflow. Can search the web. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ In Sim, the Perplexity integration enables your agents to leverage these powerfu
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Generate completions using Perplexity AI models with real-time knowledge and search capabilities. Create responses, answer questions, and generate content with customizable parameters.
|
||||
Integrate Perplexity into the workflow. Can generate completions using Perplexity AI chat models. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ In Sim, the Pinecone integration enables your agents to leverage vector search c
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Store, search, and retrieve vector embeddings using Pinecone's specialized vector database. Generate embeddings from text and perform semantic similarity searches with customizable filtering options.
|
||||
Integrate Pinecone into the workflow. Can generate embeddings, upsert text, search with text, fetch vectors, and search with vectors. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ The PostgreSQL tool is ideal for scenarios where your agents need to interact wi
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect to any PostgreSQL database to execute queries, manage data, and perform database operations. Supports SELECT, INSERT, UPDATE, DELETE operations with secure connection handling.
|
||||
Integrate PostgreSQL into the workflow. Can query, insert, update, delete, and execute raw SQL.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ This integration allows your agents to leverage powerful vector search and manag
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Store, search, and retrieve vector embeddings using Qdrant. Perform semantic similarity searches and manage your vector collections.
|
||||
Integrate Qdrant into the workflow. Can upsert, search, and fetch points. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ These operations let your agents access and analyze Reddit content as part of yo
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Access Reddit data to retrieve posts and comments from any subreddit. Get post titles, content, authors, scores, comments and more.
|
||||
Integrate Reddit into the workflow. Can get posts and comments from a subreddit. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ In Sim, the S3 integration enables your agents to retrieve and access files stor
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Retrieve and view files from Amazon S3 buckets using presigned URLs.
|
||||
Integrate S3 into the workflow. Can get presigned URLs for S3 objects. Requires access key and secret access key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Configure automated workflow execution with flexible timing options. Set up recurring workflows that run at specific intervals or times.
|
||||
Integrate Schedule into the workflow. Can trigger a workflow on a schedule configuration.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ In Sim, the Serper integration enables your agents to leverage the power of web
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Access real-time web search results with Serper's Google Search API integration. Retrieve structured search data including web pages, news, images, and places with customizable language and region settings.
|
||||
Integrate Serper into the workflow. Can search the web. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Sharepoint
|
||||
description: Read and create pages
|
||||
description: Work with pages and lists
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
@@ -61,7 +61,7 @@ In Sim, the SharePoint integration empowers your agents to create and access Sha
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Sharepoint functionality to manage pages. Read and create pages, and list sites using OAuth authentication. Supports page operations with custom MIME types and folder organization.
|
||||
Integrate SharePoint into the workflow. Read/create pages, list sites, and work with lists (read, create, update items). Requires OAuth.
|
||||
|
||||
|
||||
|
||||
@@ -124,6 +124,84 @@ List details of all SharePoint sites
|
||||
| --------- | ---- | ----------- |
|
||||
| `site` | object | Information about the current SharePoint site |
|
||||
|
||||
### `sharepoint_create_list`
|
||||
|
||||
Create a new list in a SharePoint site
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteId` | string | No | The ID of the SharePoint site \(internal use\) |
|
||||
| `siteSelector` | string | No | Select the SharePoint site |
|
||||
| `listDisplayName` | string | Yes | Display name of the list to create |
|
||||
| `listDescription` | string | No | Description of the list |
|
||||
| `listTemplate` | string | No | List template name \(e.g., 'genericList'\) |
|
||||
| `pageContent` | string | No | Optional JSON of columns. Either a top-level array of column definitions or an object with \{ columns: \[...\] \}. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `list` | object | Created SharePoint list information |
|
||||
|
||||
### `sharepoint_get_list`
|
||||
|
||||
Get metadata (and optionally columns/items) for a SharePoint list
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteSelector` | string | No | Select the SharePoint site |
|
||||
| `siteId` | string | No | The ID of the SharePoint site \(internal use\) |
|
||||
| `listId` | string | No | The ID of the list to retrieve |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `list` | object | Information about the SharePoint list |
|
||||
|
||||
### `sharepoint_update_list`
|
||||
|
||||
Update the properties (fields) on a SharePoint list item
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteSelector` | string | No | Select the SharePoint site |
|
||||
| `siteId` | string | No | The ID of the SharePoint site \(internal use\) |
|
||||
| `listId` | string | No | The ID of the list containing the item |
|
||||
| `itemId` | string | Yes | The ID of the list item to update |
|
||||
| `listItemFields` | object | Yes | Field values to update on the list item |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `item` | object | Updated SharePoint list item |
|
||||
|
||||
### `sharepoint_add_list_items`
|
||||
|
||||
Add a new item to a SharePoint list
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `siteSelector` | string | No | Select the SharePoint site |
|
||||
| `siteId` | string | No | The ID of the SharePoint site \(internal use\) |
|
||||
| `listId` | string | Yes | The ID of the list to add the item to |
|
||||
| `listItemFields` | object | Yes | Field values for the new list item |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `item` | object | Created SharePoint list item |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -64,7 +64,7 @@ This allows for powerful automation scenarios such as sending notifications, ale
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Comprehensive Slack integration with OAuth authentication. Send formatted messages using Slack's mrkdwn syntax or trigger workflows from Slack events like mentions and messages.
|
||||
Integrate Slack into the workflow. Can send messages, create canvases, and read messages. Requires OAuth. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.
|
||||
|
||||
|
||||
|
||||
|
||||
53
apps/docs/content/docs/en/tools/sms.mdx
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: SMS
|
||||
description: Send SMS messages using the internal SMS service
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="sms"
|
||||
color="#E0E0E0"
|
||||
icon={true}
|
||||
iconSvg={`<svg className="block-icon" fill="#000000" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"><path d="M 2 5 L 2 25 L 7 25 L 7 30.09375 L 8.625 28.78125 L 13.34375 25 L 30 25 L 30 5 Z M 4 7 L 28 7 L 28 23 L 12.65625 23 L 12.375 23.21875 L 9 25.90625 L 9 23 L 4 23 Z M 8 12 L 8 14 L 24 14 L 24 12 Z M 8 16 L 8 18 L 20 18 L 20 16 Z"/></svg>`}
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The SMS block allows you to send text messages directly from your workflows using Sim's own SMS sending infrastructure powered by Twilio. This integration enables you to programmatically deliver notifications, alerts, and other important information to users' mobile devices without requiring any external configuration or OAuth.
|
||||
|
||||
Our internal SMS service is designed for reliability and ease of use, making it ideal for automating communications and ensuring your messages reach recipients efficiently.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Send SMS messages directly using the internal SMS service powered by Twilio. No external configuration or OAuth required. Perfect for sending notifications, alerts, or general purpose text messages from your workflows. Requires valid phone numbers with country codes.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `sms_send`
|
||||
|
||||
Send an SMS message using the internal SMS service powered by Twilio
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `to` | string | Yes | Recipient phone number \(include country code, e.g., +1234567890\) |
|
||||
| `body` | string | Yes | SMS message content |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `success` | boolean | Whether the SMS was sent successfully |
|
||||
| `to` | string | Recipient phone number |
|
||||
| `body` | string | SMS message content |
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
- Category: `tools`
|
||||
- Type: `sms`
|
||||
@@ -191,7 +191,7 @@ In Sim, the Stagehand integration enables your agents to extract structured data
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Use Stagehand to extract structured data from webpages using Browserbase and OpenAI.
|
||||
Integrate Stagehand into the workflow. Can extract structured data from webpages. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ In Sim, the Stagehand integration enables your agents to seamlessly interact wit
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Use Stagehand to create an autonomous web browsing agent that can navigate across websites, perform tasks, and return structured data.
|
||||
Integrate Stagehand Agent into the workflow. Can navigate the web and perform tasks. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ Whether you’re building internal tools, automating business processes, or powe
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate with Supabase to manage your database, authentication, storage, and more. Query data, manage users, and interact with Supabase services directly.
|
||||
Integrate Supabase into the workflow. Can get many rows, get, create, update, delete, and upsert a row.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ In Sim, the Tavily integration enables your agents to search the web and extract
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Access Tavily's AI-powered search engine to find relevant information from across the web. Extract and process content from specific URLs with customizable depth options.
|
||||
Integrate Tavily into the workflow. Can search the web and extract content from specific URLs. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ In Sim, the Telegram integration enables your agents to leverage these powerful
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Send messages to any Telegram channel using your Bot API key or trigger workflows from Telegram bot messages. Integrate automated notifications and alerts into your workflow to keep your team informed.
|
||||
Integrate Telegram into the workflow. Can send messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ With Translate, you can:
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Convert text between languages while preserving meaning, nuance, and formatting. Utilize powerful language models to produce natural, fluent translations with appropriate cultural adaptations.
|
||||
Integrate Translate into the workflow. Can translate text to any language.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ In Sim, the Twilio SMS integration enables your agents to leverage these powerfu
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Send text messages to single or multiple recipients using the Twilio API.
|
||||
Integrate Twilio into the workflow. Can send SMS messages.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ In Sim, the Typeform integration enables your agents to programmatically interac
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Access and retrieve responses from your Typeform forms. Integrate form submissions data into your workflow for analysis, storage, or processing.
|
||||
Integrate Typeform into the workflow. Can retrieve responses, download files, and get form insights. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ In Sim, the Vision integration enables your agents to analyze images with vision
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Process visual content with customizable prompts to extract insights and information from images.
|
||||
Integrate Vision into the workflow. Can analyze images with vision models. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ In Sim, the Wealthbox integration enables your agents to seamlessly interact wit
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Wealthbox functionality to manage notes, contacts, and tasks. Read content from existing notes, contacts, and tasks and write to them using OAuth authentication. Supports text content manipulation for note creation and editing.
|
||||
Integrate Wealthbox into the workflow. Can read and write notes, read and write contacts, and read and write tasks. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ In Sim, the WhatsApp integration enables your agents to leverage these messaging
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Send messages to WhatsApp users using the WhatsApp Business API. Requires WhatsApp Business API configuration.
|
||||
Integrate WhatsApp into the workflow. Can send messages.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ In Sim, the Wikipedia integration enables your agents to programmatically access
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Access Wikipedia articles, search for pages, get summaries, retrieve full content, and discover random articles from the world's largest encyclopedia.
|
||||
Integrate Wikipedia into the workflow. Can get page summary, search pages, get page content, and get random page.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
<path
|
||||
d='M 5.9199219 6 L 20.582031 27.375 L 6.2304688 44 L 9.4101562 44 L 21.986328 29.421875 L 31.986328 44 L 44 44 L 28.681641 21.669922 L 42.199219 6 L 39.029297 6 L 27.275391 19.617188 L 17.933594 6 L 5.9199219 6 z M 9.7167969 8 L 16.880859 8 L 40.203125 42 L 33.039062 42 L 9.7167969 8 z'
|
||||
fill='currentColor'
|
||||
stroke='currentColor'
|
||||
strokeWidth='0.5'
|
||||
/>
|
||||
</svg>`}
|
||||
/>
|
||||
@@ -36,7 +38,7 @@ In Sim, the X integration enables sophisticated social media automation scenario
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Connect with X to post tweets, read content, search for information, and access user profiles. Integrate social media capabilities into your workflow with comprehensive X platform access.
|
||||
Integrate X into the workflow. Can post a new tweet, get tweet details, search tweets, and get user profile. Requires OAuth.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ In Sim, the YouTube integration enables your agents to programmatically search a
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Find relevant videos on YouTube using the YouTube Data API. Search for content with customizable result limits and retrieve structured video metadata for integration into your workflow.
|
||||
Integrate YouTube into the workflow. Can search for videos. Requires API Key.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"fumadocs-mdx": "^11.5.6",
|
||||
"fumadocs-ui": "^15.7.5",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next": "^15.3.2",
|
||||
"next": "15.4.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 443 B After Width: | Height: | Size: 521 B |
|
Before Width: | Height: | Size: 897 B After Width: | Height: | Size: 1.0 KiB |
BIN
apps/docs/public/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
3
apps/docs/public/favicon/favicon.svg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1,16 +1,18 @@
|
||||
{
|
||||
"name": "Sim",
|
||||
"short_name": "Sim",
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon/android-chrome-192x192.png",
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-chrome-512x512.png",
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
|
||||
BIN
apps/docs/public/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
apps/docs/public/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
44
apps/sim/app/(auth)/components/auth-background-svg.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
export default function AuthBackgroundSVG() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='pointer-events-none fixed inset-0 h-full w-full'
|
||||
style={{ zIndex: 5 }}
|
||||
viewBox='0 0 1880 960'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMid slice'
|
||||
>
|
||||
{/* Right side paths - extended to connect */}
|
||||
<path
|
||||
d='M1393.53 42.8889C1545.99 173.087 1688.28 339.75 1878.44 817.6'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path d='M1624.21 960L1625.78 0' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1832.67 715.81L1880 716.031' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1393.4 40V0' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1393.03' cy='40.0186' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1625.28' cy='303.147' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1837.37' cy='715.81' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Left side paths - extended to connect */}
|
||||
<path
|
||||
d='M160 157.764C319.811 136.451 417.278 102.619 552.39 0'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path d='M310.22 803.025V0' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path
|
||||
d='M160 530.184C256.142 655.353 308.338 749.141 348.382 960'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='2'
|
||||
/>
|
||||
<path d='M160 157.764V960' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M-50 157.764L160 157.764' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='160' cy='157.764' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='310.22' cy='803.025' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='160' cy='530.184' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
16
apps/sim/app/(auth)/components/auth-background.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import AuthBackgroundSVG from './auth-background-svg'
|
||||
|
||||
type AuthBackgroundProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function AuthBackground({ className, children }: AuthBackgroundProps) {
|
||||
return (
|
||||
<div className={cn('relative min-h-screen w-full overflow-hidden', className)}>
|
||||
<AuthBackgroundSVG />
|
||||
<div className='relative z-20'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,19 +4,9 @@ import { env } from '@/lib/env'
|
||||
import { isProd } from '@/lib/environment'
|
||||
|
||||
export async function getOAuthProviderStatus() {
|
||||
const githubAvailable = !!(
|
||||
env.GITHUB_CLIENT_ID &&
|
||||
env.GITHUB_CLIENT_SECRET &&
|
||||
env.GITHUB_CLIENT_ID !== 'placeholder' &&
|
||||
env.GITHUB_CLIENT_SECRET !== 'placeholder'
|
||||
)
|
||||
const githubAvailable = !!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET)
|
||||
|
||||
const googleAvailable = !!(
|
||||
env.GOOGLE_CLIENT_ID &&
|
||||
env.GOOGLE_CLIENT_SECRET &&
|
||||
env.GOOGLE_CLIENT_ID !== 'placeholder' &&
|
||||
env.GOOGLE_CLIENT_SECRET !== 'placeholder'
|
||||
)
|
||||
const googleAvailable = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET)
|
||||
|
||||
return { githubAvailable, googleAvailable, isProduction: isProd }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { GithubIcon, GoogleIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
|
||||
interface SocialLoginButtonsProps {
|
||||
githubAvailable: boolean
|
||||
@@ -36,12 +37,6 @@ export function SocialLoginButtons({
|
||||
setIsGithubLoading(true)
|
||||
try {
|
||||
await client.signIn.social({ provider: 'github', callbackURL })
|
||||
|
||||
// Mark that the user has previously logged in
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('has_logged_in_before', 'true')
|
||||
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
|
||||
}
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign in with GitHub'
|
||||
|
||||
@@ -65,13 +60,6 @@ export function SocialLoginButtons({
|
||||
setIsGoogleLoading(true)
|
||||
try {
|
||||
await client.signIn.social({ provider: 'google', callbackURL })
|
||||
|
||||
// Mark that the user has previously logged in
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('has_logged_in_before', 'true')
|
||||
// Also set a cookie to enable middleware to check login status
|
||||
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
|
||||
}
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to sign in with Google'
|
||||
|
||||
@@ -92,24 +80,24 @@ export function SocialLoginButtons({
|
||||
const githubButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-neutral-700 bg-neutral-900 text-white hover:bg-neutral-800 hover:text-white'
|
||||
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
|
||||
disabled={!githubAvailable || isGithubLoading}
|
||||
onClick={signInWithGithub}
|
||||
>
|
||||
<GithubIcon className='mr-2 h-4 w-4' />
|
||||
{isGithubLoading ? 'Connecting...' : 'Continue with GitHub'}
|
||||
<GithubIcon className='!h-[18px] !w-[18px] mr-1' />
|
||||
{isGithubLoading ? 'Connecting...' : 'GitHub'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
const googleButton = (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-neutral-700 bg-neutral-900 text-white hover:bg-neutral-800 hover:text-white'
|
||||
className='w-full rounded-[10px] shadow-sm hover:bg-gray-50'
|
||||
disabled={!googleAvailable || isGoogleLoading}
|
||||
onClick={signInWithGoogle}
|
||||
>
|
||||
<GoogleIcon className='mr-2 h-4 w-4' />
|
||||
{isGoogleLoading ? 'Connecting...' : 'Continue with Google'}
|
||||
<GoogleIcon className='!h-[18px] !w-[18px] mr-1' />
|
||||
{isGoogleLoading ? 'Connecting...' : 'Google'}
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -120,9 +108,9 @@ export function SocialLoginButtons({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-3'>
|
||||
{githubAvailable && githubButton}
|
||||
<div className={`${inter.className} grid gap-3 font-light`}>
|
||||
{googleAvailable && googleButton}
|
||||
{githubAvailable && githubButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
|
||||
import { useEffect } from 'react'
|
||||
import Nav from '@/app/(landing)/components/nav/nav'
|
||||
import AuthBackground from './components/auth-background'
|
||||
|
||||
// Helper to detect if a color is dark
|
||||
function isColorDark(hexColor: string): boolean {
|
||||
const hex = hexColor.replace('#', '')
|
||||
const r = Number.parseInt(hex.substr(0, 2), 16)
|
||||
const g = Number.parseInt(hex.substr(2, 2), 16)
|
||||
const b = Number.parseInt(hex.substr(4, 2), 16)
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return luminance < 0.5
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
const brand = useBrandConfig()
|
||||
useEffect(() => {
|
||||
// Check if brand background is dark and add class accordingly
|
||||
const rootStyle = getComputedStyle(document.documentElement)
|
||||
const brandBackground = rootStyle.getPropertyValue('--brand-background-hex').trim()
|
||||
|
||||
if (brandBackground && isColorDark(brandBackground)) {
|
||||
document.body.classList.add('auth-dark-bg')
|
||||
} else {
|
||||
document.body.classList.remove('auth-dark-bg')
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<main className='relative flex min-h-screen flex-col bg-[var(--brand-background-hex)] font-geist-sans text-white'>
|
||||
{/* Background pattern */}
|
||||
<GridPattern
|
||||
x={-5}
|
||||
y={-5}
|
||||
className='absolute inset-0 z-0 stroke-[#ababab]/5'
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<AuthBackground>
|
||||
<main className='relative flex min-h-screen flex-col font-geist-sans text-foreground'>
|
||||
{/* Header - Nav handles all conditional logic */}
|
||||
<Nav hideAuthButtons={true} variant='auth' />
|
||||
|
||||
{/* Header */}
|
||||
<div className='relative z-10 px-6 pt-9'>
|
||||
<div className='mx-auto max-w-7xl'>
|
||||
<Link href='/' className='inline-flex'>
|
||||
{brand.logoUrl ? (
|
||||
<img
|
||||
src={brand.logoUrl}
|
||||
alt={`${brand.name} Logo`}
|
||||
width={56}
|
||||
height={56}
|
||||
className='h-[56px] w-[56px] object-contain'
|
||||
/>
|
||||
) : (
|
||||
<Image src='/sim.svg' alt={`${brand.name} Logo`} width={56} height={56} />
|
||||
)}
|
||||
</Link>
|
||||
{/* Content */}
|
||||
<div className='relative z-30 flex flex-1 items-center justify-center px-4 pb-24'>
|
||||
<div className='w-full max-w-lg px-4'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
|
||||
<div className='w-full max-w-md'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
</AuthBackground>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { client } from '@/lib/auth-client'
|
||||
import LoginPage from '@/app/(auth)/login/login-form'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
client: {
|
||||
signIn: {
|
||||
email: vi.fn(),
|
||||
},
|
||||
emailOtp: {
|
||||
sendVerificationOtp: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
|
||||
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
|
||||
}))
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}
|
||||
|
||||
const mockSearchParams = {
|
||||
get: vi.fn(),
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(useRouter as any).mockReturnValue(mockRouter)
|
||||
;(useSearchParams as any).mockReturnValue(mockSearchParams)
|
||||
mockSearchParams.get.mockReturnValue(null)
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
githubAvailable: true,
|
||||
googleAvailable: true,
|
||||
isProduction: false,
|
||||
}
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render login form with all required elements', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/forgot password/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/sign up/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render social login buttons', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Password Visibility Toggle', () => {
|
||||
it('should toggle password visibility when button is clicked', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const toggleButton = screen.getByLabelText(/show password/i)
|
||||
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'text')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interaction', () => {
|
||||
it('should allow users to type in form fields', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
|
||||
expect(emailInput).toHaveValue('user@company.com')
|
||||
expect(passwordInput).toHaveValue('password123')
|
||||
})
|
||||
|
||||
it('should show loading state during form submission', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
mockSignIn.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve({ data: { user: { id: '1' } }, error: null }), 100)
|
||||
)
|
||||
)
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
fireEvent.click(submitButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Signing in...')).toBeInTheDocument()
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call signIn with correct credentials', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
mockSignIn.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignIn).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'user@company.com',
|
||||
password: 'password123',
|
||||
callbackURL: '/workspace',
|
||||
},
|
||||
expect.objectContaining({
|
||||
onError: expect.any(Function),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle authentication errors', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
|
||||
mockSignIn.mockImplementation((credentials, options) => {
|
||||
if (options?.onError) {
|
||||
options.onError({
|
||||
error: {
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
message: 'Invalid credentials',
|
||||
} as any,
|
||||
response: {} as any,
|
||||
request: {} as any,
|
||||
} as any)
|
||||
}
|
||||
return Promise.resolve({ data: null, error: 'Invalid credentials' })
|
||||
})
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid email or password')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Forgot Password', () => {
|
||||
it('should open forgot password dialog', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const forgotPasswordButton = screen.getByText(/forgot password/i)
|
||||
fireEvent.click(forgotPasswordButton)
|
||||
|
||||
expect(screen.getByText('Reset Password')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Parameters', () => {
|
||||
it('should handle invite flow parameter in signup link', () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'invite_flow') return 'true'
|
||||
if (param === 'callbackUrl') return '/invite/123'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const signupLink = screen.getByText(/sign up/i)
|
||||
expect(signupLink).toHaveAttribute('href', '/signup?invite_flow=true&callbackUrl=/invite/123')
|
||||
})
|
||||
|
||||
it('should default to regular signup link when no invite flow', () => {
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const signupLink = screen.getByText(/sign up/i)
|
||||
expect(signupLink).toHaveAttribute('href', '/signup')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Email Verification Flow', () => {
|
||||
it('should redirect to verification page when email not verified', async () => {
|
||||
const mockSignIn = vi.mocked(client.signIn.email)
|
||||
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
|
||||
|
||||
mockSignIn.mockRejectedValue({
|
||||
message: 'Email not verified',
|
||||
code: 'EMAIL_NOT_VERIFIED',
|
||||
})
|
||||
|
||||
mockSendOtp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<LoginPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendOtp).toHaveBeenCalledWith({
|
||||
email: 'user@company.com',
|
||||
type: 'email-verification',
|
||||
})
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/verify')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,8 @@ import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
const logger = createLogger('LoginForm')
|
||||
|
||||
@@ -72,12 +74,12 @@ const validatePassword = (passwordValue: string): string[] => {
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.required.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.required.message)
|
||||
return errors // Return early for required field
|
||||
return errors
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.notEmpty.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.notEmpty.message)
|
||||
return errors // Return early for empty field
|
||||
return errors
|
||||
}
|
||||
|
||||
return errors
|
||||
@@ -100,12 +102,11 @@ export default function LoginPage({
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
// Initialize state for URL parameters
|
||||
const [callbackUrl, setCallbackUrl] = useState('/workspace')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
|
||||
// Forgot password states
|
||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
||||
@@ -114,31 +115,51 @@ export default function LoginPage({
|
||||
message: string
|
||||
}>({ type: null, message: '' })
|
||||
|
||||
// Email validation state
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
|
||||
// Extract URL parameters after component mounts to avoid SSR issues
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
// Only access search params on the client side
|
||||
if (searchParams) {
|
||||
const callback = searchParams.get('callbackUrl')
|
||||
if (callback) {
|
||||
// Validate the callbackUrl before setting it
|
||||
if (validateCallbackUrl(callback)) {
|
||||
setCallbackUrl(callback)
|
||||
} else {
|
||||
logger.warn('Invalid callback URL detected and blocked:', { url: callback })
|
||||
// Keep the default safe value ('/workspace')
|
||||
}
|
||||
}
|
||||
|
||||
const inviteFlow = searchParams.get('invite_flow') === 'true'
|
||||
setIsInviteFlow(inviteFlow)
|
||||
}
|
||||
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,7 +179,6 @@ export default function LoginPage({
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validateEmailField(newEmail)
|
||||
setEmailErrors(errors)
|
||||
setShowEmailValidationError(false)
|
||||
@@ -168,7 +188,6 @@ export default function LoginPage({
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validatePassword(newPassword)
|
||||
setPasswordErrors(errors)
|
||||
setShowValidationError(false)
|
||||
@@ -179,26 +198,23 @@ export default function LoginPage({
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const email = formData.get('email') as string
|
||||
const emailRaw = formData.get('email') as string
|
||||
const email = emailRaw.trim().toLowerCase()
|
||||
|
||||
// Validate email on submit
|
||||
const emailValidationErrors = validateEmailField(email)
|
||||
setEmailErrors(emailValidationErrors)
|
||||
setShowEmailValidationError(emailValidationErrors.length > 0)
|
||||
|
||||
// Validate password on submit
|
||||
const passwordValidationErrors = validatePassword(password)
|
||||
setPasswordErrors(passwordValidationErrors)
|
||||
setShowValidationError(passwordValidationErrors.length > 0)
|
||||
|
||||
// If there are validation errors, stop submission
|
||||
if (emailValidationErrors.length > 0 || passwordValidationErrors.length > 0) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Final validation before submission
|
||||
const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace'
|
||||
|
||||
const result = await client.signIn.email(
|
||||
@@ -260,33 +276,13 @@ export default function LoginPage({
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark that the user has previously logged in
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('has_logged_in_before', 'true')
|
||||
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax' // 1 year expiry
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Handle only the special verification case that requires a redirect
|
||||
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
try {
|
||||
await client.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
})
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', email)
|
||||
}
|
||||
|
||||
router.push('/verify')
|
||||
return
|
||||
} catch (_verifyErr) {
|
||||
setPasswordErrors(['Failed to send verification code. Please try again later.'])
|
||||
setShowValidationError(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', email)
|
||||
}
|
||||
router.push('/verify')
|
||||
return
|
||||
}
|
||||
|
||||
console.error('Uncaught login error:', err)
|
||||
@@ -370,167 +366,172 @@ export default function LoginPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<h1 className='font-semibold text-[32px] text-white tracking-tight'>Sign In</h1>
|
||||
<p className='text-neutral-400 text-sm'>
|
||||
Enter your email below to sign in to your account
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Sign in
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter your details
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-6'>
|
||||
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
|
||||
<SocialLoginButtons
|
||||
googleAvailable={googleAvailable}
|
||||
githubAvailable={githubAvailable}
|
||||
isProduction={isProduction}
|
||||
callbackURL={callbackUrl}
|
||||
/>
|
||||
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className='relative mt-2 py-4'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-neutral-700/50 border-t' />
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className='space-y-5'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email' className='text-neutral-300'>
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
|
||||
showEmailValidationError &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{emailErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password' className='text-neutral-300'>
|
||||
Password
|
||||
</Label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setForgotPasswordOpen(true)}
|
||||
className='font-medium text-neutral-400 text-xs transition hover:text-white'
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
required
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='current-password'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your password'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-neutral-400 transition hover:text-white'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{showValidationError && passwordErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{passwordErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showEmailValidationError &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{emailErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setForgotPasswordOpen(true)}
|
||||
className='font-medium text-muted-foreground text-xs transition hover:text-foreground'
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='flex h-11 w-full items-center justify-center gap-2 bg-brand-primary font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-brand-primary-hover'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
required
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='current-password'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your password'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{showValidationError && passwordErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{passwordErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-center text-sm'>
|
||||
<span className='text-neutral-400'>Don't have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className='text-center text-neutral-500/80 text-xs leading-relaxed'>
|
||||
By signing in, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className={`${inter.className} relative my-6 font-light`}>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='auth-divider w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SocialLoginButtons
|
||||
googleAvailable={googleAvailable}
|
||||
githubAvailable={githubAvailable}
|
||||
isProduction={isProduction}
|
||||
callbackURL={callbackUrl}
|
||||
/>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<span className='font-normal'>Don't have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
|
||||
>
|
||||
By signing in, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Dialog open={forgotPasswordOpen} onOpenChange={setForgotPasswordOpen}>
|
||||
<DialogContent className='border border-neutral-700/50 bg-neutral-800/90 text-white backdrop-blur-sm'>
|
||||
<DialogContent className='auth-card auth-card-shadow max-w-[540px] rounded-[10px] border backdrop-blur-sm'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className='font-semibold text-white text-xl tracking-tight'>
|
||||
<DialogTitle className='auth-text-primary font-semibold text-xl tracking-tight'>
|
||||
Reset Password
|
||||
</DialogTitle>
|
||||
<DialogDescription className='text-neutral-300 text-sm'>
|
||||
<DialogDescription className='auth-text-secondary text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='reset-email' className='text-neutral-300'>
|
||||
Email
|
||||
</Label>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
</div>
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={forgotPasswordEmail}
|
||||
@@ -539,8 +540,9 @@ export default function LoginPage({
|
||||
required
|
||||
type='email'
|
||||
className={cn(
|
||||
'border-neutral-700/80 bg-neutral-900 text-white placeholder:text-white/60 focus:border-[var(--brand-primary-hover-hex)]/70 focus:ring-[var(--brand-primary-hover-hex)]/20',
|
||||
resetStatus.type === 'error' && 'border-red-500 focus-visible:ring-red-500'
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
resetStatus.type === 'error' &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{resetStatus.type === 'error' && (
|
||||
@@ -557,7 +559,7 @@ export default function LoginPage({
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleForgotPassword}
|
||||
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
className={`${buttonClass} w-full rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isSubmittingReset}
|
||||
>
|
||||
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
|
||||
@@ -565,6 +567,6 @@ export default function LoginPage({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
const logger = createLogger('ResetPasswordPage')
|
||||
|
||||
@@ -30,7 +24,6 @@ function ResetPasswordContent() {
|
||||
text: '',
|
||||
})
|
||||
|
||||
// Validate token presence
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatusMessage({
|
||||
@@ -66,7 +59,6 @@ function ResetPasswordContent() {
|
||||
text: 'Password reset successful! Redirecting to login...',
|
||||
})
|
||||
|
||||
// Redirect to login page after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login?resetSuccess=true')
|
||||
}, 1500)
|
||||
@@ -82,40 +74,42 @@ function ResetPasswordContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='flex min-h-screen flex-col items-center justify-center bg-gray-50'>
|
||||
<div className='sm:mx-auto sm:w-full sm:max-w-md'>
|
||||
<h1 className='mb-8 text-center font-bold text-2xl'>Sim</h1>
|
||||
<Card className='w-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>Reset your password</CardTitle>
|
||||
<CardDescription>Enter a new password for your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SetNewPasswordForm
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={statusMessage.type}
|
||||
statusMessage={statusMessage.text}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='w-full text-center text-gray-500 text-sm'>
|
||||
<Link href='/login' className='text-muted-foreground hover:underline'>
|
||||
Back to login
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Enter a new password for your account
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className={`${inter.className} mt-8`}>
|
||||
<SetNewPasswordForm
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={statusMessage.type}
|
||||
statusMessage={statusMessage.text}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<Link
|
||||
href='/login'
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<div className='flex min-h-screen items-center justify-center'>Loading...</div>}
|
||||
fallback={<div className='flex h-screen items-center justify-center'>Loading...</div>}
|
||||
>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
|
||||
interface RequestResetFormProps {
|
||||
email: string
|
||||
@@ -26,25 +27,56 @@ export function RequestResetForm({
|
||||
statusMessage,
|
||||
className,
|
||||
}: RequestResetFormProps) {
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit(email)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
<div className='grid gap-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='reset-email'>Email</Label>
|
||||
</div>
|
||||
<Input
|
||||
id='reset-email'
|
||||
value={email}
|
||||
onChange={(e) => onEmailChange(e.target.value)}
|
||||
placeholder='your@email.com'
|
||||
placeholder='Enter your email'
|
||||
type='email'
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
className='placeholder:text-white/60'
|
||||
className='rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
We'll send a password reset link to this email address.
|
||||
@@ -52,30 +84,22 @@ export function RequestResetForm({
|
||||
</div>
|
||||
|
||||
{/* Status message display */}
|
||||
{statusType && (
|
||||
{statusType && statusMessage && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-sm',
|
||||
statusType === 'success'
|
||||
? 'border-green-200 bg-green-50 text-green-700'
|
||||
: 'border-red-200 bg-red-50 text-red-700'
|
||||
)}
|
||||
className={cn('text-xs', statusType === 'success' ? 'text-[#4CAF50]' : 'text-red-400')}
|
||||
>
|
||||
{statusMessage}
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type='submit' disabled={isSubmitting} className='w-full'>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
'Send Reset Link'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting}
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
>
|
||||
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -100,11 +124,40 @@ export function SetNewPasswordForm({
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [validationMessage, setValidationMessage] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Simple validation
|
||||
if (password.length < 8) {
|
||||
setValidationMessage('Password must be at least 8 characters long')
|
||||
return
|
||||
@@ -120,71 +173,98 @@ export function SetNewPasswordForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
<div className='grid gap-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='password'>New Password</Label>
|
||||
<Input
|
||||
id='password'
|
||||
type='password'
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Enter new password'
|
||||
className='placeholder:text-white/60'
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className={cn(`${inter.className} space-y-8`, className)}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password'>New Password</Label>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Enter new password'
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
validationMessage &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='confirmPassword'>Confirm Password</Label>
|
||||
<Input
|
||||
id='confirmPassword'
|
||||
type='password'
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Confirm new password'
|
||||
className='placeholder:text-white/60'
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='confirmPassword'>Confirm Password</Label>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='confirmPassword'
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Confirm new password'
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
validationMessage &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationMessage && (
|
||||
<div className='rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-sm'>
|
||||
{validationMessage}
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
<p>{validationMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusType && (
|
||||
{statusType && statusMessage && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border p-3 text-sm',
|
||||
statusType === 'success'
|
||||
? 'border-green-200 bg-green-50 text-green-700'
|
||||
: 'border-red-200 bg-red-50 text-red-700'
|
||||
'mt-1 space-y-1 text-xs',
|
||||
statusType === 'success' ? 'text-[#4CAF50]' : 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{statusMessage}
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button disabled={isSubmitting || !token} type='submit' className='w-full'>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
'Reset Password'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting || !token}
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
>
|
||||
{isSubmitting ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { env, isTruthy } from '@/lib/env'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import SignupForm from '@/app/(auth)/signup/signup-form'
|
||||
|
||||
// Force dynamic rendering to avoid prerender errors with search params
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SignupPage() {
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { client, useSession } from '@/lib/auth-client'
|
||||
import SignupPage from '@/app/(auth)/signup/signup-form'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
client: {
|
||||
signUp: {
|
||||
email: vi.fn(),
|
||||
},
|
||||
emailOtp: {
|
||||
sendVerificationOtp: vi.fn(),
|
||||
},
|
||||
},
|
||||
useSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(auth)/components/social-login-buttons', () => ({
|
||||
SocialLoginButtons: () => <div data-testid='social-login-buttons'>Social Login Buttons</div>,
|
||||
}))
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}
|
||||
|
||||
const mockSearchParams = {
|
||||
get: vi.fn(),
|
||||
}
|
||||
|
||||
describe('SignupPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(useRouter as any).mockReturnValue(mockRouter)
|
||||
;(useSearchParams as any).mockReturnValue(mockSearchParams)
|
||||
;(useSession as any).mockReturnValue({
|
||||
refetch: vi.fn().mockResolvedValue({}),
|
||||
})
|
||||
mockSearchParams.get.mockReturnValue(null)
|
||||
})
|
||||
|
||||
const defaultProps = {
|
||||
githubAvailable: true,
|
||||
googleAvailable: true,
|
||||
isProduction: false,
|
||||
}
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render signup form with all required elements', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByPlaceholderText(/enter your name/i)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/enter your email/i)).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText(/enter your password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/sign in/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render social login buttons', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('social-login-buttons')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Password Visibility Toggle', () => {
|
||||
it('should toggle password visibility when button is clicked', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const toggleButton = screen.getByLabelText(/show password/i)
|
||||
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'text')
|
||||
|
||||
fireEvent.click(toggleButton)
|
||||
expect(passwordInput).toHaveAttribute('type', 'password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Interaction', () => {
|
||||
it('should allow users to type in form fields', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
|
||||
expect(nameInput).toHaveValue('John Doe')
|
||||
expect(emailInput).toHaveValue('user@company.com')
|
||||
expect(passwordInput).toHaveValue('Password123!')
|
||||
})
|
||||
|
||||
it('should show loading state during form submission', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockImplementation(
|
||||
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
|
||||
)
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
expect(screen.getByText('Creating account...')).toBeInTheDocument()
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should call signUp with correct credentials and trimmed name', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
|
||||
|
||||
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
mockSendOtp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
// Use valid input that passes all validation rules
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'user@company.com',
|
||||
password: 'Password123!',
|
||||
name: 'John Doe',
|
||||
},
|
||||
expect.objectContaining({
|
||||
onError: expect.any(Function),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should automatically trim spaces from name input', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: ' John Doe ' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'John Doe',
|
||||
email: 'user@company.com',
|
||||
password: 'Password123!',
|
||||
}),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect to verification page after successful signup', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
const mockSendOtp = vi.mocked(client.emailOtp.sendVerificationOtp)
|
||||
|
||||
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
mockSendOtp.mockResolvedValue({ data: null, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// With sendVerificationOnSignUp: true, OTP is sent automatically by Better Auth
|
||||
// No manual OTP sending in the component anymore
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle signup errors', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
|
||||
mockSignUp.mockImplementation((credentials, options) => {
|
||||
if (options?.onError) {
|
||||
options.onError({
|
||||
error: {
|
||||
code: 'USER_ALREADY_EXISTS',
|
||||
message: 'User already exists',
|
||||
} as any,
|
||||
response: {} as any,
|
||||
request: {} as any,
|
||||
} as any)
|
||||
}
|
||||
return Promise.resolve({ data: null, error: 'User already exists' })
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'existing@example.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show warning for names that would be truncated (over 100 characters)', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
const longName = 'a'.repeat(101) // 101 characters
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: longName } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/name will be truncated to 100 characters/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Ensure signUp was not called
|
||||
expect(mockSignUp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle names exactly at 100 characters without warning', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockImplementation(
|
||||
() => new Promise((resolve) => resolve({ data: { user: { id: '1' } }, error: null }))
|
||||
)
|
||||
|
||||
const exactLengthName = 'a'.repeat(100) // Exactly 100 characters
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: exactLengthName } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'ValidPass123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
// Should not show truncation warning
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/name will be truncated/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Should proceed with form submission
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
{
|
||||
email: 'user@company.com',
|
||||
password: 'ValidPass123!',
|
||||
name: exactLengthName,
|
||||
},
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle names exactly at validation errors', async () => {
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
|
||||
mockSignUp.mockImplementation((credentials, options) => {
|
||||
if (options?.onError) {
|
||||
options.onError({
|
||||
error: {
|
||||
code: 'NAME_VALIDATION_ERROR',
|
||||
message: 'Name validation error',
|
||||
} as any,
|
||||
response: {} as any,
|
||||
request: {} as any,
|
||||
} as any)
|
||||
}
|
||||
return Promise.resolve({ data: null, error: 'Name validation error' })
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to create account')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL Parameters', () => {
|
||||
it('should prefill email from URL parameter', () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'email') return 'prefilled@example.com'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
expect(emailInput).toHaveValue('prefilled@example.com')
|
||||
})
|
||||
|
||||
it('should handle invite flow redirect', async () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'redirect') return '/invite/123'
|
||||
if (param === 'invite_flow') return 'true'
|
||||
return null
|
||||
})
|
||||
|
||||
const mockSignUp = vi.mocked(client.signUp.email)
|
||||
mockSignUp.mockResolvedValue({ data: { user: { id: '1' } }, error: null })
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/enter your name/i)
|
||||
const emailInput = screen.getByPlaceholderText(/enter your email/i)
|
||||
const passwordInput = screen.getByPlaceholderText(/enter your password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'John Doe' } })
|
||||
fireEvent.change(emailInput, { target: { value: 'user@company.com' } })
|
||||
fireEvent.change(passwordInput, { target: { value: 'Password123!' } })
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/verify?fromSignup=true')
|
||||
})
|
||||
})
|
||||
|
||||
it('should link to login with invite flow parameters', () => {
|
||||
mockSearchParams.get.mockImplementation((param) => {
|
||||
if (param === 'invite_flow') return 'true'
|
||||
if (param === 'redirect') return '/invite/123'
|
||||
return null
|
||||
})
|
||||
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const loginLink = screen.getByText(/sign in/i)
|
||||
expect(loginLink).toHaveAttribute('href', '/login?invite_flow=true&callbackUrl=/invite/123')
|
||||
})
|
||||
|
||||
it('should default to regular login link when no invite flow', () => {
|
||||
render(<SignupPage {...defaultProps} />)
|
||||
|
||||
const loginLink = screen.getByText(/sign in/i)
|
||||
expect(loginLink).toHaveAttribute('href', '/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,8 @@ import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
const logger = createLogger('SignupForm')
|
||||
|
||||
@@ -91,8 +93,8 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [redirectUrl, setRedirectUrl] = useState('')
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
// Name validation state
|
||||
const [name, setName] = useState('')
|
||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||
const [showNameValidationError, setShowNameValidationError] = useState(false)
|
||||
@@ -104,25 +106,46 @@ function SignupFormContent({
|
||||
setEmail(emailParam)
|
||||
}
|
||||
|
||||
// Handle redirection for invitation flow
|
||||
const redirectParam = searchParams.get('redirect')
|
||||
if (redirectParam) {
|
||||
setRedirectUrl(redirectParam)
|
||||
|
||||
// Check if this is part of an invitation flow
|
||||
if (redirectParam.startsWith('/invite/')) {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicitly check for invite_flow parameter
|
||||
const inviteFlowParam = searchParams.get('invite_flow')
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Validate password and return array of error messages
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
@@ -149,18 +172,17 @@ function SignupFormContent({
|
||||
return errors
|
||||
}
|
||||
|
||||
// Validate name and return array of error messages
|
||||
const validateName = (nameValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!NAME_VALIDATIONS.required.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.required.message)
|
||||
return errors // Return early for required field
|
||||
return errors
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.notEmpty.message)
|
||||
return errors // Return early for empty field
|
||||
return errors
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) {
|
||||
@@ -178,7 +200,6 @@ function SignupFormContent({
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
|
||||
// Silently validate but don't show errors
|
||||
const errors = validatePassword(newPassword)
|
||||
setPasswordErrors(errors)
|
||||
setShowValidationError(false)
|
||||
@@ -197,12 +218,10 @@ function SignupFormContent({
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
|
||||
// Silently validate but don't show errors until submit
|
||||
const errors = validateEmailField(newEmail)
|
||||
setEmailErrors(errors)
|
||||
setShowEmailValidationError(false)
|
||||
|
||||
// Clear any previous server-side email errors when the user starts typing
|
||||
if (emailError) {
|
||||
setEmailError('')
|
||||
}
|
||||
@@ -213,7 +232,8 @@ function SignupFormContent({
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const emailValue = formData.get('email') as string
|
||||
const emailValueRaw = formData.get('email') as string
|
||||
const emailValue = emailValueRaw.trim().toLowerCase()
|
||||
const passwordValue = formData.get('password') as string
|
||||
const nameValue = formData.get('name') as string
|
||||
|
||||
@@ -317,7 +337,6 @@ function SignupFormContent({
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh session to get the new user data immediately after signup
|
||||
try {
|
||||
await refetchSession()
|
||||
logger.info('Session refreshed after successful signup')
|
||||
@@ -325,34 +344,23 @@ function SignupFormContent({
|
||||
logger.error('Failed to refresh session after signup:', sessionError)
|
||||
}
|
||||
|
||||
// For new signups, always require verification
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', emailValue)
|
||||
localStorage.setItem('has_logged_in_before', 'true')
|
||||
|
||||
// Set cookie flag for middleware check
|
||||
document.cookie = 'requiresEmailVerification=true; path=/; max-age=900; SameSite=Lax' // 15 min expiry
|
||||
document.cookie = 'has_logged_in_before=true; path=/; max-age=31536000; SameSite=Lax'
|
||||
|
||||
// Store invitation flow state if applicable
|
||||
if (isInviteFlow && redirectUrl) {
|
||||
sessionStorage.setItem('inviteRedirectUrl', redirectUrl)
|
||||
sessionStorage.setItem('isInviteFlow', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
// Send verification OTP manually
|
||||
try {
|
||||
await client.emailOtp.sendVerificationOtp({
|
||||
email: emailValue,
|
||||
type: 'email-verification',
|
||||
type: 'sign-in',
|
||||
})
|
||||
} catch (otpError) {
|
||||
logger.error('Failed to send OTP:', otpError)
|
||||
// Continue anyway - user can use resend button
|
||||
} catch (otpErr) {
|
||||
logger.warn('Failed to send sign-in OTP after signup; user can press Resend', otpErr)
|
||||
}
|
||||
|
||||
// Always redirect to verification for new signups
|
||||
router.push('/verify?fromSignup=true')
|
||||
} catch (error) {
|
||||
logger.error('Signup error:', error)
|
||||
@@ -361,166 +369,180 @@ function SignupFormContent({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<h1 className='font-semibold text-[32px] text-white tracking-tight'>Create Account</h1>
|
||||
<p className='text-neutral-400 text-sm'>Enter your details to create a new account</p>
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
Create an account
|
||||
</h1>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
Create an account or log in
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-6'>
|
||||
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
|
||||
<SocialLoginButtons
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
callbackURL={redirectUrl || '/workspace'}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className='relative mt-2 py-4'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-neutral-700/50 border-t' />
|
||||
</div>
|
||||
<form onSubmit={onSubmit} className={`${inter.className} mt-8 space-y-8`}>
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='name'>Full name</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className='space-y-5'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='name' className='text-neutral-300'>
|
||||
Full Name
|
||||
</Label>
|
||||
<Input
|
||||
id='name'
|
||||
name='name'
|
||||
placeholder='Enter your name'
|
||||
type='text'
|
||||
autoCapitalize='words'
|
||||
autoComplete='name'
|
||||
title='Name can only contain letters, spaces, hyphens, and apostrophes'
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
|
||||
showNameValidationError &&
|
||||
nameErrors.length > 0 &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showNameValidationError && nameErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{nameErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email' className='text-neutral-300'>
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'border-neutral-700 bg-neutral-900 text-white placeholder:text-white/60',
|
||||
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
|
||||
'border-red-500 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{emailErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{emailError && !showEmailValidationError && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
<p>{emailError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='password' className='text-neutral-300'>
|
||||
Password
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
placeholder='Enter your password'
|
||||
autoCorrect='off'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className='border-neutral-700 bg-neutral-900 pr-10 text-white placeholder:text-white/60'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-neutral-400 transition hover:text-white'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{showValidationError && passwordErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{passwordErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
id='name'
|
||||
name='name'
|
||||
placeholder='Enter your name'
|
||||
type='text'
|
||||
autoCapitalize='words'
|
||||
autoComplete='name'
|
||||
title='Name can only contain letters, spaces, hyphens, and apostrophes'
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showNameValidationError &&
|
||||
nameErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showNameValidationError && nameErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{nameErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='flex h-11 w-full items-center justify-center gap-2 bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
'rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
(emailError || (showEmailValidationError && emailErrors.length > 0)) &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{emailErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{emailError && !showEmailValidationError && (
|
||||
<div className='mt-1 text-red-400 text-xs'>
|
||||
<p>{emailError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
placeholder='Enter your password'
|
||||
autoCorrect='off'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{showValidationError && passwordErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{passwordErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-center text-sm'>
|
||||
<span className='text-neutral-400'>Already have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
type='submit'
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className='text-center text-neutral-500/80 text-xs leading-relaxed'>
|
||||
By creating an account, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
className='text-neutral-400 underline-offset-4 transition hover:text-neutral-300 hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{(githubAvailable || googleAvailable) && (
|
||||
<div className={`${inter.className} relative my-6 font-light`}>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='auth-divider w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='bg-white px-4 font-[340] text-muted-foreground'>Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SocialLoginButtons
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
callbackURL={redirectUrl || '/workspace'}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
|
||||
<div className={`${inter.className} pt-6 text-center font-light text-[14px]`}>
|
||||
<span className='font-normal'>Already have an account? </span>
|
||||
<Link
|
||||
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${inter.className} auth-text-muted absolute right-0 bottom-0 left-0 px-8 pb-8 text-center font-[340] text-[13px] leading-relaxed sm:px-8 md:px-[44px]`}
|
||||
>
|
||||
By creating an account, you agree to our{' '}
|
||||
<Link
|
||||
href='/terms'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='auth-link underline-offset-4 transition hover:underline'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { isProd } from '@/lib/environment'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
|
||||
|
||||
// Force dynamic rendering to avoid prerender errors with search params
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function VerifyPage() {
|
||||
const baseUrl = getBaseUrl()
|
||||
const hasResendKey = Boolean(env.RESEND_API_KEY && env.RESEND_API_KEY !== 'placeholder')
|
||||
const hasResendKey = Boolean(env.RESEND_API_KEY)
|
||||
|
||||
return <VerifyContent hasResendKey={hasResendKey} baseUrl={baseUrl} isProduction={isProd} />
|
||||
return <VerifyContent hasResendKey={hasResendKey} isProduction={isProd} />
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { client, useSession } from '@/lib/auth-client'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('useVerification')
|
||||
@@ -47,61 +46,39 @@ export function useVerification({
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Get stored email
|
||||
const storedEmail = sessionStorage.getItem('verificationEmail')
|
||||
if (storedEmail) {
|
||||
setEmail(storedEmail)
|
||||
}
|
||||
|
||||
// Check for redirect information
|
||||
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
|
||||
if (storedRedirectUrl) {
|
||||
setRedirectUrl(storedRedirectUrl)
|
||||
}
|
||||
|
||||
// Check if this is an invite flow
|
||||
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
|
||||
if (storedIsInviteFlow === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Also check URL parameters for redirect information
|
||||
const redirectParam = searchParams.get('redirectAfter')
|
||||
if (redirectParam) {
|
||||
setRedirectUrl(redirectParam)
|
||||
}
|
||||
|
||||
// Check for invite_flow parameter
|
||||
const inviteFlowParam = searchParams.get('invite_flow')
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Send initial OTP code if this is the first load
|
||||
useEffect(() => {
|
||||
if (email && !isSendingInitialOtp && hasResendKey) {
|
||||
setIsSendingInitialOtp(true)
|
||||
|
||||
// Only send verification OTP if we're coming from login page
|
||||
// Skip this if coming from signup since the OTP is already sent
|
||||
if (!searchParams.get('fromSignup')) {
|
||||
client.emailOtp
|
||||
.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
})
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to send initial verification code:', error)
|
||||
setErrorMessage('Failed to send verification code. Please use the resend button.')
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [email, isSendingInitialOtp, searchParams, hasResendKey])
|
||||
}, [email, isSendingInitialOtp, hasResendKey])
|
||||
|
||||
// Enable the verify button when all 6 digits are entered
|
||||
const isOtpComplete = otp.length === 6
|
||||
|
||||
async function verifyCode() {
|
||||
@@ -112,25 +89,24 @@ export function useVerification({
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
// Call the verification API with the OTP code
|
||||
const response = await client.emailOtp.verifyEmail({
|
||||
email,
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
const response = await client.signIn.emailOtp({
|
||||
email: normalizedEmail,
|
||||
otp,
|
||||
})
|
||||
|
||||
// Check if verification was successful
|
||||
if (response && !response.error) {
|
||||
setIsVerified(true)
|
||||
|
||||
// Clear verification requirements and session storage
|
||||
try {
|
||||
await refetchSession()
|
||||
} catch (e) {
|
||||
logger.warn('Failed to refetch session after verification', e)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
|
||||
// Clear the verification requirement flag
|
||||
document.cookie =
|
||||
'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
|
||||
// Also clear invite-related items
|
||||
if (isInviteFlow) {
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
sessionStorage.removeItem('isInviteFlow')
|
||||
@@ -139,24 +115,20 @@ export function useVerification({
|
||||
|
||||
setTimeout(() => {
|
||||
if (isInviteFlow && redirectUrl) {
|
||||
// For invitation flow, redirect to the invitation page
|
||||
window.location.href = redirectUrl
|
||||
} else {
|
||||
// Default redirect to dashboard
|
||||
window.location.href = '/workspace'
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
logger.info('Setting invalid OTP state - API error response')
|
||||
const message = 'Invalid verification code. Please check and try again.'
|
||||
// Set both state variables to ensure the error shows
|
||||
setIsInvalidOtp(true)
|
||||
setErrorMessage(message)
|
||||
logger.info('Error state after API error:', {
|
||||
isInvalidOtp: true,
|
||||
errorMessage: message,
|
||||
})
|
||||
// Clear the OTP input on invalid code
|
||||
setOtp('')
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -171,7 +143,6 @@ export function useVerification({
|
||||
message = 'Too many failed attempts. Please request a new code.'
|
||||
}
|
||||
|
||||
// Set both state variables to ensure the error shows
|
||||
setIsInvalidOtp(true)
|
||||
setErrorMessage(message)
|
||||
logger.info('Error state after caught error:', {
|
||||
@@ -179,7 +150,6 @@ export function useVerification({
|
||||
errorMessage: message,
|
||||
})
|
||||
|
||||
// Clear the OTP input on error
|
||||
setOtp('')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -192,10 +162,11 @@ export function useVerification({
|
||||
setIsLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
const normalizedEmail = email.trim().toLowerCase()
|
||||
client.emailOtp
|
||||
.sendVerificationOtp({
|
||||
email,
|
||||
type: 'email-verification',
|
||||
email: normalizedEmail,
|
||||
type: 'sign-in',
|
||||
})
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
@@ -207,7 +178,6 @@ export function useVerification({
|
||||
}
|
||||
|
||||
function handleOtpChange(value: string) {
|
||||
// Only clear error when user is actively typing a new code
|
||||
if (value.length === 6) {
|
||||
setIsInvalidOtp(false)
|
||||
setErrorMessage('')
|
||||
@@ -215,12 +185,11 @@ export function useVerification({
|
||||
setOtp(value)
|
||||
}
|
||||
|
||||
// Auto-submit when OTP is complete
|
||||
useEffect(() => {
|
||||
if (otp.length === 6 && email && !isLoading && !isVerified) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
verifyCode()
|
||||
}, 300) // Small delay to ensure UI is ready
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
@@ -229,17 +198,8 @@ export function useVerification({
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!isProduction || !hasResendKey) {
|
||||
const storedEmail = sessionStorage.getItem('verificationEmail')
|
||||
}
|
||||
|
||||
const isDevOrDocker = !isProduction || isTruthy(env.DOCKER_BUILD)
|
||||
|
||||
if (isDevOrDocker || !hasResendKey) {
|
||||
setIsVerified(true)
|
||||
|
||||
document.cookie =
|
||||
'requiresEmailVerification=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
window.location.href = '/workspace'
|
||||
}, 1000)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
||||
import { inter } from '@/app/fonts/inter'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
interface VerifyContentProps {
|
||||
hasResendKey: boolean
|
||||
baseUrl: string
|
||||
isProduction: boolean
|
||||
}
|
||||
|
||||
@@ -45,19 +47,50 @@ function VerificationForm({
|
||||
}
|
||||
}, [countdown, isResendDisabled])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleResend = () => {
|
||||
resendCode()
|
||||
setIsResendDisabled(true)
|
||||
setCountdown(30)
|
||||
}
|
||||
|
||||
const [buttonClass, setButtonClass] = useState('auth-button-gradient')
|
||||
|
||||
useEffect(() => {
|
||||
const checkCustomBrand = () => {
|
||||
const computedStyle = getComputedStyle(document.documentElement)
|
||||
const brandAccent = computedStyle.getPropertyValue('--brand-accent-hex').trim()
|
||||
|
||||
if (brandAccent && brandAccent !== '#6f3dfa') {
|
||||
setButtonClass('auth-button-custom')
|
||||
} else {
|
||||
setButtonClass('auth-button-gradient')
|
||||
}
|
||||
}
|
||||
|
||||
checkCustomBrand()
|
||||
|
||||
window.addEventListener('resize', checkCustomBrand)
|
||||
const observer = new MutationObserver(checkCustomBrand)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style', 'class'],
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkCustomBrand)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<h1 className='font-semibold text-[32px] text-white tracking-tight'>
|
||||
<>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className={`${soehne.className} font-medium text-[32px] text-black tracking-tight`}>
|
||||
{isVerified ? 'Email Verified!' : 'Verify Your Email'}
|
||||
</h1>
|
||||
<p className='text-neutral-400 text-sm'>
|
||||
<p className={`${inter.className} font-[380] text-[16px] text-muted-foreground`}>
|
||||
{isVerified
|
||||
? 'Your email has been verified. Redirecting to dashboard...'
|
||||
: hasResendKey
|
||||
@@ -69,47 +102,75 @@ function VerificationForm({
|
||||
</div>
|
||||
|
||||
{!isVerified && (
|
||||
<div className='flex flex-col gap-6'>
|
||||
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
|
||||
<p className='mb-4 text-neutral-400 text-sm'>
|
||||
<div className={`${inter.className} mt-8 space-y-8`}>
|
||||
<div className='space-y-6'>
|
||||
<p className='text-center text-muted-foreground text-sm'>
|
||||
Enter the 6-digit code to verify your account.
|
||||
{hasResendKey ? " If you don't see it in your inbox, check your spam folder." : ''}
|
||||
</p>
|
||||
|
||||
<div className='flex justify-center py-4'>
|
||||
<div className='flex justify-center'>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChange={handleOtpChange}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
isInvalidOtp ? 'border-red-500 focus-visible:ring-red-500' : 'border-neutral-700'
|
||||
)}
|
||||
className={cn('gap-2', isInvalidOtp && 'otp-error')}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPGroup className='[&>div]:!rounded-[10px] gap-2'>
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
className='border-neutral-700 bg-neutral-900 text-white'
|
||||
className={cn(
|
||||
'!rounded-[10px] h-12 w-12 border bg-white text-center font-medium text-lg shadow-sm transition-all duration-200',
|
||||
'border-gray-300 hover:border-gray-400',
|
||||
'focus:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-100',
|
||||
isInvalidOtp && 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
)}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
@@ -117,59 +178,74 @@ function VerificationForm({
|
||||
|
||||
{/* Error message */}
|
||||
{errorMessage && (
|
||||
<div className='mt-2 mb-4 rounded-md border border-red-900/20 bg-red-900/10 py-2 text-center'>
|
||||
<p className='font-medium text-red-400 text-sm'>{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={verifyCode}
|
||||
className='h-11 w-full bg-[var(--brand-primary-hex)] font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
disabled={!isOtpComplete || isLoading}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Verify Email'}
|
||||
</Button>
|
||||
|
||||
{hasResendKey && (
|
||||
<div className='mt-4 text-center'>
|
||||
<p className='text-neutral-400 text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in <span className='font-medium text-neutral-300'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
onClick={handleResend}
|
||||
disabled={isLoading || isResendDisabled}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-1 space-y-1 text-center text-red-400 text-xs'>
|
||||
<p>{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={verifyCode}
|
||||
className={`${buttonClass} flex w-full items-center justify-center gap-2 rounded-[10px] border font-medium text-[15px] text-white transition-all duration-200`}
|
||||
disabled={!isOtpComplete || isLoading}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Verify Email'}
|
||||
</Button>
|
||||
|
||||
{hasResendKey && (
|
||||
<div className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in <span className='font-medium text-foreground'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
onClick={handleResend}
|
||||
disabled={isLoading || isResendDisabled}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='text-center font-light text-[14px]'>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
sessionStorage.removeItem('isInviteFlow')
|
||||
}
|
||||
router.push('/signup')
|
||||
}}
|
||||
className='font-medium text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Back to signup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback component while the verification form is loading
|
||||
function VerificationFormFallback() {
|
||||
return (
|
||||
<div className='p-8 text-center'>
|
||||
<div className='text-center'>
|
||||
<div className='animate-pulse'>
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-neutral-800' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-neutral-800' />
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-gray-200' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-gray-200' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VerifyContent({ hasResendKey, baseUrl, isProduction }: VerifyContentProps) {
|
||||
export function VerifyContent({ hasResendKey, isProduction }: VerifyContentProps) {
|
||||
return (
|
||||
<Suspense fallback={<VerificationFormFallback />}>
|
||||
<VerificationForm hasResendKey={hasResendKey} isProduction={isProduction} />
|
||||
|
||||
@@ -1,204 +1,26 @@
|
||||
'use server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
import { env } from '@/lib/env'
|
||||
const DEFAULT_STARS = '15k'
|
||||
|
||||
/**
|
||||
* Format a number to a human-readable format (e.g., 1000 -> 1k, 1100 -> 1.1k)
|
||||
*/
|
||||
function formatNumber(num: number): string {
|
||||
if (num < 1000) {
|
||||
return num.toString()
|
||||
}
|
||||
const logger = createLogger('GitHubStars')
|
||||
|
||||
const formatted = (Math.round(num / 100) / 10).toFixed(1)
|
||||
|
||||
return formatted.endsWith('.0') ? `${formatted.slice(0, -2)}k` : `${formatted}k`
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to fetch GitHub stars
|
||||
*/
|
||||
export async function getFormattedGitHubStars(): Promise<string> {
|
||||
try {
|
||||
const token = env.GITHUB_TOKEN
|
||||
|
||||
const response = await fetch('https://api.github.com/repos/simstudioai/sim', {
|
||||
const response = await fetch('/api/github-stars', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
'Cache-Control': 'max-age=3600', // Cache for 1 hour
|
||||
},
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`GitHub API error: ${response.status} ${response.statusText}`)
|
||||
return formatNumber(3867)
|
||||
logger.warn('Failed to fetch GitHub stars from API')
|
||||
return DEFAULT_STARS
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return formatNumber(data.stargazers_count || 3867)
|
||||
return data.stars || DEFAULT_STARS
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitHub stars:', error)
|
||||
return formatNumber(3867)
|
||||
}
|
||||
}
|
||||
|
||||
interface Contributor {
|
||||
login: string
|
||||
avatar_url: string
|
||||
contributions: number
|
||||
html_url: string
|
||||
}
|
||||
|
||||
interface CommitData {
|
||||
sha: string
|
||||
commit: {
|
||||
author: {
|
||||
name: string
|
||||
email: string
|
||||
date: string
|
||||
}
|
||||
message: string
|
||||
}
|
||||
html_url: string
|
||||
}
|
||||
|
||||
interface RepoStats {
|
||||
stars: number
|
||||
forks: number
|
||||
watchers: number
|
||||
openIssues: number
|
||||
openPRs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to fetch repository statistics
|
||||
*/
|
||||
export async function getRepositoryStats(): Promise<RepoStats> {
|
||||
try {
|
||||
const token = env.GITHUB_TOKEN
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
}
|
||||
|
||||
const repoResponse = await fetch('https://api.github.com/repos/simstudioai/sim', {
|
||||
headers,
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
|
||||
const prsResponse = await fetch(
|
||||
'https://api.github.com/repos/simstudioai/sim/pulls?state=open',
|
||||
{
|
||||
headers,
|
||||
next: { revalidate: 3600 },
|
||||
}
|
||||
)
|
||||
|
||||
if (!repoResponse.ok || !prsResponse.ok) {
|
||||
console.error('GitHub API error fetching repo stats')
|
||||
return {
|
||||
stars: 3867,
|
||||
forks: 581,
|
||||
watchers: 26,
|
||||
openIssues: 23,
|
||||
openPRs: 3,
|
||||
}
|
||||
}
|
||||
|
||||
const repoData = await repoResponse.json()
|
||||
const prsData = await prsResponse.json()
|
||||
|
||||
return {
|
||||
stars: repoData.stargazers_count || 3867,
|
||||
forks: repoData.forks_count || 581,
|
||||
watchers: repoData.subscribers_count || 26,
|
||||
openIssues: (repoData.open_issues_count || 26) - prsData.length,
|
||||
openPRs: prsData.length || 3,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching repository stats:', error)
|
||||
return {
|
||||
stars: 3867,
|
||||
forks: 581,
|
||||
watchers: 26,
|
||||
openIssues: 23,
|
||||
openPRs: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to fetch contributors
|
||||
*/
|
||||
export async function getContributors(): Promise<Contributor[]> {
|
||||
try {
|
||||
const token = env.GITHUB_TOKEN
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/simstudioai/sim/contributors?per_page=100',
|
||||
{
|
||||
headers,
|
||||
next: { revalidate: 3600 },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('GitHub API error fetching contributors')
|
||||
return []
|
||||
}
|
||||
|
||||
const contributors = await response.json()
|
||||
return contributors || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching contributors:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server action to fetch recent commits for timeline data
|
||||
*/
|
||||
export async function getCommitsData(): Promise<CommitData[]> {
|
||||
try {
|
||||
const token = env.GITHUB_TOKEN
|
||||
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'SimStudio/1.0',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/simstudioai/sim/commits?per_page=100',
|
||||
{
|
||||
headers,
|
||||
next: { revalidate: 3600 },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('GitHub API error fetching commits')
|
||||
return []
|
||||
}
|
||||
|
||||
const commits = await response.json()
|
||||
return commits || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching commits:', error)
|
||||
return []
|
||||
logger.warn('Error fetching GitHub stars:', error)
|
||||
return DEFAULT_STARS
|
||||
}
|
||||
}
|
||||
|
||||
136
apps/sim/app/(landing)/components/background/background-svg.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
export default function BackgroundSVG() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
focusable='false'
|
||||
className='-translate-x-1/2 pointer-events-none absolute top-0 left-1/2 z-10 hidden h-full min-h-full w-[1308px] sm:block'
|
||||
width='1308'
|
||||
height='4942'
|
||||
viewBox='0 18 1308 4066'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
preserveAspectRatio='xMidYMin slice'
|
||||
>
|
||||
{/* Pricing section (original height ~380 units) */}
|
||||
<path d='M6.71704 1236.22H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='1236.48' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='1236.48' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 1245.42V1613.91' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 1245.96V1613.91' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Integrations section (original height ~412 units) */}
|
||||
<path d='M6.71704 1614.89H1291.05' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='1615.15' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='1615.15' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 1624.61V2026.93' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 1624.61V2026.93' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Testimonials section (original short height ~149 units) */}
|
||||
<path d='M6.71704 2026.71H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='2026.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='2026.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 2036.43V2177.43' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 2036.43V2177.43' stroke='#E7E4EF' strokeWidth='2' />
|
||||
|
||||
{/* Footer section line */}
|
||||
<path d='M6.71704 2177.71H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='2177.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='2177.97' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M10.7967 2187.43V4090.25' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 2187.43V4090.25' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path
|
||||
d='M959.828 116.604C1064.72 187.189 1162.61 277.541 1293.45 536.597'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path d='M1118.77 612.174V88' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<path d='M1261.95 481.414L1289.13 481.533' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<path d='M960 109.049V88' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<circle
|
||||
cx='960.214'
|
||||
cy='115.214'
|
||||
r='6.25942'
|
||||
transform='rotate(90 960.214 115.214)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<circle
|
||||
cx='1119.21'
|
||||
cy='258.214'
|
||||
r='6.25942'
|
||||
transform='rotate(90 1119.21 258.214)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<circle
|
||||
cx='1265.19'
|
||||
cy='481.414'
|
||||
r='6.25942'
|
||||
transform='rotate(90 1265.19 481.414)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path
|
||||
d='M77 179C225.501 165.887 294.438 145.674 390 85'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path d='M214.855 521.491L215 75' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<path
|
||||
d='M76.6567 381.124C177.305 448.638 213.216 499.483 240.767 613.253'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path d='M76.5203 175.703V613.253' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<path d='M1.07967 179.225L76.6567 179.225' stroke='#E7E4EF' strokeWidth='1.90903' />
|
||||
<circle
|
||||
cx='76.3128'
|
||||
cy='178.882'
|
||||
r='6.25942'
|
||||
transform='rotate(90 76.3128 178.882)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<circle
|
||||
cx='214.511'
|
||||
cy='528.695'
|
||||
r='6.25942'
|
||||
transform='rotate(90 214.511 528.695)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<circle
|
||||
cx='76.3129'
|
||||
cy='380.78'
|
||||
r='6.25942'
|
||||
transform='rotate(90 76.3129 380.78)'
|
||||
fill='white'
|
||||
stroke='#E7E4EF'
|
||||
strokeWidth='1.90903'
|
||||
/>
|
||||
<path d='M10.7967 18V1226.51' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M1297.76 18V1227.59' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M6.71704 78.533H1300.76' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='10.7967' cy='78.792' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='214.976' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='396.976' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='78.792' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1118.98' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='959.976' cy='78.9761' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<path d='M16.4341 620.811H1292.13' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='11.0557' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='76.3758' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='244.805' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='10.7967' cy='178.405' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1119.23' cy='621.07' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='481.253' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
<circle cx='1298.02' cy='541.714' r='8.07846' fill='white' stroke='#E7E4EF' strokeWidth='2' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
22
apps/sim/app/(landing)/components/background/background.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Lazy load the SVG to reduce initial bundle size
|
||||
const BackgroundSVG = dynamic(() => import('./background-svg'), {
|
||||
ssr: true, // Enable SSR for SEO
|
||||
loading: () => null, // Don't show loading state
|
||||
})
|
||||
|
||||
type BackgroundProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function Background({ className, children }: BackgroundProps) {
|
||||
return (
|
||||
<div className={cn('relative min-h-screen w-full', className)}>
|
||||
<BackgroundSVG />
|
||||
<div className='relative z-0 mx-auto w-full max-w-[1308px]'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
type BlogCardProps = {
|
||||
href: string
|
||||
title: string
|
||||
description?: string
|
||||
date?: Date
|
||||
avatar?: string
|
||||
author: string
|
||||
authorRole?: string
|
||||
type: string
|
||||
readTime?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
const blogConfig = {
|
||||
agents: '#802efc',
|
||||
functions: '#FC2E31',
|
||||
workflows: '#2E8CFC',
|
||||
// ADD MORE
|
||||
}
|
||||
|
||||
export const BlogCard = ({
|
||||
href,
|
||||
image,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
avatar,
|
||||
author,
|
||||
authorRole,
|
||||
type,
|
||||
readTime,
|
||||
}: BlogCardProps) => {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<div className='flex flex-col rounded-3xl border border-[#606060]/40 bg-[#101010] p-8 transition-all duration-500 hover:bg-[var(--surface-elevated)]'>
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt='Image'
|
||||
width={2000}
|
||||
height={2000}
|
||||
className='aspect-video h-max w-full rounded-xl'
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{date ? (
|
||||
<p className='pb-5 font-light text-[#BBBBBB]/70 text-base tracking-tight'>
|
||||
{date.toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className='flex flex-col gap-6'>
|
||||
<p className='max-w-96 font-medium text-2xl text-white/80 leading-[1.2] tracking-normal lg:text-3xl'>
|
||||
{title}
|
||||
</p>
|
||||
<p className='font-light text-lg text-white/60 leading-[1.5]'>{description}</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-6 pt-16'>
|
||||
<div className='flex items-center gap-4'>
|
||||
{avatar ? (
|
||||
<Image
|
||||
src={avatar}
|
||||
alt='Avatar'
|
||||
width={64}
|
||||
height={64}
|
||||
className='h-16 w-16 rounded-full'
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-0'>
|
||||
<p className='font-medium text-white/90 text-xl'>{author}</p>
|
||||
<p className='font-normal text-base text-white/60'>{authorRole}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-5'>
|
||||
<div
|
||||
className='rounded-lg px-2 py-1'
|
||||
style={{
|
||||
background: blogConfig[type.toLowerCase() as keyof typeof blogConfig] ?? '#333',
|
||||
}}
|
||||
>
|
||||
<p className='font-light text-base text-white'>{type}</p>
|
||||
</div>
|
||||
<p className='font-light text-base text-white/60'>{readTime} min-read</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||