Compare commits

..

27 Commits

Author SHA1 Message Date
Vikhyath Mondreti
bff1852a85 v0.3.56: i18n, sharepoint operations & logs search improvements 2025-09-18 14:21:14 -07:00
Adam Gough
7327b448e5 Improvement(sharepoint): added ability to create list items, different from create list (#1379)
* added add list items

(cherry picked from commit df6ea35d5bb975c03c7ec0c787bd915f34890ac0)

* bun run lint

* minor changes

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Adam Gough <adamgough@Adams-MacBook-Pro.local>
2025-09-18 14:18:58 -07:00
Waleed
eb1e90bb7f improvement(search): added more granular logs search, added logs export, improved overall search experience (#1378)
* improvement(search): added more granular logs search, added logs export, improved overall search experience

* updated tests
2025-09-18 13:58:44 -07:00
Vikhyath Mondreti
3905d1cb81 fix(selectors): gdrive and slack selectors inf loops (#1376)
* fix(selectors): gdrive and slack selectors inf loops

* remove comment
2025-09-18 11:40:36 -07:00
Waleed
cd084e8236 fix(actions): updated i18n gh action to use PAT instead of default token (#1377) 2025-09-18 11:29:02 -07:00
Waleed
5d96484501 fix(variables): remove quote stripping from short & long inputs (#1375)
* fix(variables): remove quote stripping from short & long inputs

* restore env

* remove quote stripping everywhere

* remove unused file
2025-09-18 11:04:22 -07:00
Waleed
6747a497fc fix(migrations): upgrade drizzle-kit in migrations container (#1374)
* fix(migrations): upgrade drizzle-kit in migrations container

* fix comments

* rm unused file
2025-09-18 11:04:06 -07:00
Waleed
2df65527d3 v0.3.55: landing page / OTP improvements, DB package separation, Webhooks fixes, Sharepoint Improvement 2025-09-17 23:48:35 -07:00
Adam Gough
d0b69455e2 Improvement(sharepoint): added more operations in sharepoint (#1369)
* added list tools

* not working yet

* improved read and create

* added scopes

* updated sharepoint tools

* added greptile comments

---------

Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
2025-09-17 19:16:12 -07:00
Vikhyath Mondreti
6028b1f5c0 fix(dockerfile): needs dummy db url (#1368) 2025-09-17 18:56:54 -07:00
Waleed
658cf11299 improvement(idempotency): added atomic claims to prevent duplicate processing for long-running workflows (#1366)
* improvement(idempotency): added atomic claims to prevent duplicate processing for long-running workflows

* ack PR comments
2025-09-17 17:17:55 -07:00
Waleed
6312df3a07 feat(signup): added back to login functionality to OTP page (#1365)
* update infra and remove railway

* feat(signup): added back to login functionalityfrom OTP page

* remove placeholders from docker commands, simplified login flow

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.
2025-09-17 17:17:37 -07:00
Vikhyath Mondreti
9de7a00373 improvement(code-structure): move db into separate package (#1364)
* improvement(code-structure): move db into separate package

* make db separate package

* remake bun lock

* update imports to not maintain two separate ones

* fix CI for tests by adding dummy url

* vercel build fix attempt

* update bun lock

* regenerate bun lock

* fix mocks

* remove db commands from apps/sim package json
2025-09-17 15:41:13 -07:00
Waleed
325a666a8b improvement(landing): insert prompt into copilot panel from landing, open panel on entry (#1363)
* update infra and remove railway

* improvement(landing): insert prompt into copilot panel from landing, open panel on entry

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.

* fixes

* remove debug logs

* go back to old env
2025-09-17 12:28:22 -07:00
Vikhyath Mondreti
2149f5e36d v0.3.54: google forms block, new landing page, generic mail tool, billing fixes 2025-09-16 22:13:12 -07:00
Emir Karabeg
009e1da5f1 improvement(platform): ui/ux (#1357)
* improvement: branding; auth; chat-deploy

* improvement: docs favicon

* fix: rolled back verification logic
2025-09-16 21:51:23 -07:00
Vikhyath Mondreti
6101493f12 fix(next-js): pin version (#1358)
* fix(next-js): pin version

* fix
2025-09-16 21:50:52 -07:00
Adam Gough
4b5c2b43e9 feat(google-forms): added google forms block (#1343)
* fix(sidebar): draggable cursor on sidebar when switching workflows (#1276)

* added google form tool to read forms

* added trigger mode and block docs

* updated docs

* removed file

* reverted diff

* greptile comments

* Reverted bun file

* remove outdated code for old webhook modal

* restore ui changes to webhooks

* removed provider specific logic

* fix lint

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
2025-09-16 21:20:59 -07:00
Vikhyath Mondreti
bd402cdda5 fix(layout): fix layout semantics on invite page (#1356) 2025-09-16 21:17:51 -07:00
Emir Karabeg
0c30646a2d improvement: branding; auth; chat-deploy (#1351)
* improvement: branding; auth; chat-deploy

* improvement: docs favicon
2025-09-16 21:16:55 -07:00
Vikhyath Mondreti
53792b9a1d fix bun lock (#1354) 2025-09-16 21:02:11 -07:00
Vikhyath Mondreti
48f86e66f4 fix(better-auth): revert back to version 1.2.9 (#1352) 2025-09-16 20:53:01 -07:00
Waleed
fd422b5d0d feat(sms): add generic sms sending block/tool (#1349)
* update infra and remove railway

* feat(sms): add generic sms sending block/tool

* added docs

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.

* updated colors

* remove fallbacks, stronger typing
2025-09-16 17:47:15 -07:00
Waleed
17cf72834d improvement(gh): fix i18n github action to run on merge to staging (#1350)
* update infra and remove railway

* improvement(ci): fix i18n github action

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.
2025-09-16 17:34:53 -07:00
Waleed
3122b506fd feat(tools): add generic mail sending block/tools, updated docs script (#1348)
* update infra and remove railway

* feat(tools): add generic mail sending block/tools, updated docs script

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.

* remove message id

* updated type
2025-09-16 16:06:31 -07:00
Waleed
a31305b7ee fix(bun): pin bun version for db migrations (#1347)
* fix(migrations): downgrade nextjs

* fix(bun): pin bun version in db migrations

* Revert "fix(migrations): downgrade nextjs"

This reverts commit 27b544f22d.
2025-09-16 14:31:37 -07:00
Emir Karabeg
4f26a7aa73 feat(landing): new landing page (#1219)
* update infra and remove railway

* feat(landing): background; font; metadata; nav

* finished navbar ui

* completed hero UI

* hero heading UI/UX

* updated icon descriptions

* canvas improvements

* canvas improvements

* updated canvas; adjusted background

* removed gsap; adjusted canvas height

* added templates outline

* feat(landing, landing-2): Update background, hero components, nav, integrations, pricing, templates, testimonials, tailwind config

* feat(landing, landing-2): Update background, footer, hero, index components, integrations, landing-pricing, landing templates, footer in sections, icons, middleware

* improvement(landing): optimized html

* feat(landing): update background, footer, hero, integrations, landing-enterprise, landing-pricing, landing-templates, nav, add github-stars route

* feat(landing): added onclicks

* feat(landing): commented out templates

* fix: reset environment

* fixed build

* feat(landing): updated background, footer, index, integrations, landing-pricing, nav, testimonials, landing page, fonts, environment

* feat(landing): swapped integrations and pricing

* navigation for new landing

* login/signup/terms/privacy preliminary changes, as well as navigation setup

* feat(landing,nav,hero,integrations,footer,testimonials,background,structured-data): updates and additions across components

* feat(landing): updated terms and privacy

* feat(auth): adjusted background

* feat(auth): signup and login complete

* feat(auth): completed all flows ui/ux

* fix: testing and build

* feat(landing, auth): update nav and login tests

* fix(ui): update auth navigation component (149 chars)

* restore scripts dir

* revert back to old globals.css brand primary color, updated invite page

* Revert "update infra and remove railway"

This reverts commit abfa2f8d51.

* remove logos

* add gh stars action for reuse on landing + cht

---------

Co-authored-by: waleedlatif1 <walif6@gmail.com>
2025-09-15 21:40:35 -07:00
660 changed files with 11928 additions and 9713 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 users information. Requires bot API key.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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`

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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`

View 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`

View File

@@ -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.

View File

@@ -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.

View 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"
]
}

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View 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`

View File

@@ -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.

View File

@@ -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.

View File

@@ -76,7 +76,7 @@ Whether youre 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 B

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 897 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View 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>
)
}

View 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>
)
}

View File

@@ -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 }
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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')
})
})
})
})

View File

@@ -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>
</>
)
}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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() {

View File

@@ -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')
})
})
})

View File

@@ -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>
</>
)
}

View File

@@ -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} />
}

View File

@@ -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)

View File

@@ -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} />

View File

@@ -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
}
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

Some files were not shown because too many files have changed in this diff Show More