Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/scripts/http-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ case "$group" in
export INTERNAL_API_SECRET=scim-http-ci-local-secret-at-least-32-characters
export DB_TX_TRIPWIRE=throw
export NEXT_PUBLIC_CHAT_DISABLED=true
export SIM_LINKS=true
start_app scim 3017 SCIM record-http-status
SCIM_E2E_BASE_URL="$local_app_url" \
SCIM_E2E_DATABASE_URL="$DATABASE_URL" \
Expand All @@ -155,6 +156,11 @@ case "$group" in
VERSION_COMPARE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
VERSION_COMPARE_E2E_REPORT_PATH="$report_dir/version-compare-http-report.json" \
bun run test:workflow-version-compare:e2e
SIM_LINKS_E2E_BASE_URL="$local_app_url" \
SIM_LINKS_E2E_DATABASE_URL="$DATABASE_URL" \
SIM_LINKS_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
SIM_LINKS_E2E_REPORT_PATH="$report_dir/sim-links-e2e-report.json" \
bun run test:sim-links:e2e
;;

# The search suites serve their own fixtures in-process and need no app. They share this group
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/platform/enterprise/audit-logs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ Events use identifiers such as `resource.action`. These categories are represent
| API keys | `api_key.created`, `api_key.revoked` |
| Credentials | `credential.created`, `credential.deleted`, `oauth.disconnected` |
| Organization | `organization.updated`, `org_member.added`, `org_member.role_changed`, `organization.sso_provider.updated` |
| Sim links | `sim_link.created`, `sim_link.updated`, `sim_link.deleted` |
| Exports and drains | `audit_logs.exported`, `data_drain.created`, `data_drain.updated`, `data_drain.ran`, `data_drain.tested` |

Authentication failures and password-reset requests use an anonymous actor and record the target account separately. Impersonation and administrative session revocations identify the operator and target organization. Table-row mutations record summaries and counts without copying row contents.
Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/search/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"quickstart",
"find-content",
"ask-questions",
"sim-links",
"troubleshooting",
"---Connect sources---",
"connect-your-account",
Expand Down
65 changes: 65 additions & 0 deletions apps/docs/content/docs/search/sim-links.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
title: Sim Links
description: Short sim/ links that take everyone in your organization to the right page
---

Sim Links are short, memorable names for the pages your team opens often, like `sim/handbook` for the employee handbook or `sim/okta` for your sign-in portal. Anyone in the organization can open them, with or without a Search seat. They work like go links in other tools.

Open **Sim Links** in the organization sidebar to see every link in your organization, with who created it, how often it's opened, and when it last changed. Search by name, destination, or description, and select **Most used** to see the 50 links your team opens most.

## Open a link

Go to `https://sim.ai/sim/<name>`, for example `https://sim.ai/sim/handbook`. On a self-hosted deployment, use your Sim address instead of `sim.ai`. Sim sends you to the link's destination. If you aren't signed in, Sim asks you to sign in first, then continues to the link.

Names ignore case and punctuation, so `sim/Team-Handbook`, `sim/team_handbook`, and `sim/teamhandbook` all open the same link. The destination still applies its own permissions.

If nobody has created the link yet, Sim offers to create it with that name.

## Open links from the address bar

Add Sim as a site search shortcut in Chrome so you can type `sim handbook` instead of the full address:

1. Open Chrome **Settings → Search engine → Manage search engines and site search**.
2. Next to **Site search**, select **Add**.
3. Enter `Sim` as the name, `sim` as the shortcut, and `https://sim.ai/sim/%s` as the URL.

Type `sim`, press **Space** or **Tab**, then type a link name such as `handbook` or `jira/123` and press **Enter**.

Chrome administrators can add this shortcut for everyone with the `SiteSearchSettings` policy, using the same shortcut and URL.

## Create a link

1. Open **Sim Links** and select **New link**.
2. Enter the **Destination URL**, a full `https://` address.
3. Enter a **Name**. Use letters, numbers, and `. : @ & ~ - _`.
4. Optionally add a **Description** so teammates know what the link is for.
5. Select **Create**.

Select **Copy link** from a link's menu to share it.

## Add values to a link

Anything after the name is added to the destination. If `sim/docs` opens `https://docs.example.com`, then `sim/docs/billing/invoices` opens `https://docs.example.com/billing/invoices`.

To place a value somewhere else, put `{*}` in the destination URL:

| Destination URL | You open | Sim opens |
| --- | --- | --- |
| `https://jira.example.com/browse/PROD-{*}` | `sim/jira/123` | `https://jira.example.com/browse/PROD-123` |
| `https://wiki.example.com/search?q={*}` | `sim/wiki/expense policy` | `https://wiki.example.com/search?q=expense%20policy` |
| `https://github.com/acme/{*}/issues/{*}` | `sim/issues/api/42` | `https://github.com/acme/api/issues/42` |

Each value fills the next `{*}` in order, and the last `{*}` takes everything that remains. A link with `{*}` can also have a **Default destination** on the same site, which opens when you use the link without a value. `{*}` can't replace the domain, and values can't be `.` or `..`.

## Control access

Under **Access**, the creator of a link can choose:

- **Unlist**: hides the link from the Sim Links page for everyone except the creator and organization admins. Anyone who knows the name can still open it.
- **Restrict editing**: only the creator and organization admins can edit or delete the link. Everyone can still open it.

Without restrictions, any member can edit or delete a link. Only the creator and organization admins can change its access settings. Creating, editing, and deleting links is recorded in your organization's [audit logs](/platform/enterprise/audit-logs).

## Related docs

- [Find content](/search/find-content)
34 changes: 34 additions & 0 deletions apps/sim/app/api/organizations/[id]/sim-links/[linkId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { deleteSimLinkContract, updateSimLinkContract } from '@/lib/api/contracts/sim-links'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { presentSimLink } from '@/lib/api/server/sim-link-presenters'
import { deleteSimLink, updateSimLink } from '@/lib/sim-links/application/use-cases'

const policy = {
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({ bucketName: 'sim-links' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
}

export const PUT = defineInternalJsonRoute({
...policy,
contract: updateSimLinkContract,
operation: updateSimLink.operation,
useCase: updateSimLink,
mapInput: ({ params, body }) => ({ organizationId: params.id, linkId: params.linkId, ...body }),
present: (link) => ({ link: presentSimLink(link) }),
})

export const DELETE = defineInternalJsonRoute({
...policy,
contract: deleteSimLinkContract,
operation: deleteSimLink.operation,
useCase: deleteSimLink,
mapInput: ({ params }) => ({ organizationId: params.id, linkId: params.linkId }),
present: () => ({ success: true as const }),
})
34 changes: 34 additions & 0 deletions apps/sim/app/api/organizations/[id]/sim-links/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createSimLinkContract, listSimLinksContract } from '@/lib/api/contracts/sim-links'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { presentSimLink } from '@/lib/api/server/sim-link-presenters'
import { createSimLink, listSimLinks } from '@/lib/sim-links/application/use-cases'

const policy = {
auth: internalSessionAuth,
rateLimit: internalRateLimits.user({ bucketName: 'sim-links' }),
errorPolicy: internalOrchestrationErrorPolicy,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
}

export const GET = defineInternalJsonRoute({
...policy,
contract: listSimLinksContract,
operation: listSimLinks.operation,
useCase: listSimLinks,
mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }),
present: ({ links, nextCursor }) => ({ links: links.map(presentSimLink), nextCursor }),
})

export const POST = defineInternalJsonRoute({
...policy,
contract: createSimLinkContract,
operation: createSimLink.operation,
useCase: createSimLink,
mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
present: (link) => ({ link: presentSimLink(link) }),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* The home surface's reading column, so every organization page shares its width. Kept out of
* the client component module so server-rendered loading views receive the class string itself.
*/
export const PAGE_COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6'
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page/constants'
export {
OrganizationPage,
type OrganizationPageTab,
PAGE_COLUMN_CLASS,
} from '@/app/o/[organizationId]/components/organization-page/organization-page'
export { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,13 @@ import {
} from '@sim/emcn'
import { Search, X } from '@sim/emcn/icons'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page/constants'
import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
import {
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'

/** The home surface's reading column, so every organization page shares its width. */
export const PAGE_COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6'

export interface OrganizationPageTab {
id: string
label: string
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ChartColumn, Home, Integration, Search } from '@sim/emcn/icons'
import { ChartColumn, Home, Integration, Link, Search } from '@sim/emcn/icons'
import { organizationRoutes } from '@/lib/navigation/paths'
import type { SidebarNavItemData } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'

type OrganizationNavRoute = 'home' | 'search' | 'integrations' | 'benchmark'
type OrganizationNavRoute = 'home' | 'search' | 'integrations' | 'simLinks' | 'benchmark'

interface OrganizationNavEntry {
id: string
Expand All @@ -19,21 +19,38 @@ const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [
{ id: 'home', label: 'Home', icon: Home, route: 'home' },
{ id: 'search', label: 'Search', icon: Search, route: 'search' },
{ id: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' },
{ id: 'sim-links', label: 'Sim Links', icon: Link, route: 'simLinks' },
{ id: 'benchmark', label: 'Benchmark', icon: ChartColumn, route: 'benchmark' },
]

interface OrganizationNavAvailability {
searchAvailable: boolean
mothershipAvailable: boolean
benchmarkEnabled?: boolean
simLinksEnabled?: boolean
}

export function buildOrganizationNavItems(
organizationId: string,
searchAvailable: boolean,
mothershipAvailable: boolean,
benchmarkEnabled = false
{
searchAvailable,
mothershipAvailable,
benchmarkEnabled = false,
simLinksEnabled = false,
}: OrganizationNavAvailability
): SidebarNavItemData[] {
const routes = organizationRoutes(organizationId)
return ORGANIZATION_NAV_ENTRIES.filter(({ route }) => {
if (route === 'benchmark') return benchmarkEnabled
return route === 'home' ? mothershipAvailable : searchAvailable
}).map(({ route, ...entry }) => ({
...entry,
href: routes[route],
}))
const available: Record<OrganizationNavRoute, boolean> = {
home: mothershipAvailable,
search: searchAvailable,
integrations: searchAvailable,
simLinks: simLinksEnabled,
benchmark: benchmarkEnabled,
}
return ORGANIZATION_NAV_ENTRIES.filter(({ route }) => available[route]).map(
({ route, ...entry }) => ({
...entry,
href: routes[route],
})
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ interface OrganizationChatsProps

interface OrganizationSidebarProps {
benchmarkEnabled?: boolean
simLinksEnabled?: boolean
}

function OrganizationChats({ organizationId, ...props }: OrganizationChatsProps) {
Expand All @@ -75,6 +76,7 @@ function OrganizationChats({ organizationId, ...props }: OrganizationChatsProps)
*/
export const OrganizationSidebar = memo(function OrganizationSidebar({
benchmarkEnabled = false,
simLinksEnabled = false,
}: OrganizationSidebarProps) {
const { isCollapsed: railCollapsed, isPeeking, onToggle: toggleCollapsed } = useSidebarChrome()
/** The peek card always renders the expanded layout, whatever the rail's state. */
Expand All @@ -96,12 +98,12 @@ export const OrganizationSidebar = memo(function OrganizationSidebar({

const isMac = isMacPlatform()
const canUseHome = mothershipAvailable && canBuild
const navItems = buildOrganizationNavItems(
organization.id,
searchAccess.memberScoped,
canUseHome,
benchmarkEnabled
)
const navItems = buildOrganizationNavItems(organization.id, {
searchAvailable: searchAccess.memberScoped,
mothershipAvailable: canUseHome,
benchmarkEnabled,
simLinksEnabled,
})
const settingsPath = organizationRoutes(organization.id).settings
const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`)

Expand Down
10 changes: 9 additions & 1 deletion apps/sim/app/o/[organizationId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { isMothershipModelSelectorEnabled } from '@/lib/mothership/feature-flags
import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths'
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
import { isWorkflowTestsEnabled } from '@/lib/workflow-tests/feature-flag'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
Expand Down Expand Up @@ -75,6 +76,7 @@ export default async function OrganizationLayout({
workflowTestsEnabled,
changelogEnabled,
projectsEnabled,
simLinksEnabled,
] = await Promise.all([
prefetchOrganizationSidebar(
queryClient,
Expand All @@ -92,6 +94,7 @@ export default async function OrganizationLayout({
isWorkflowTestsEnabled(organizationId),
isChangelogEnabled(organizationId),
isProjectsEnabled(),
isSimLinksEnabled(organizationId),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

Expand All @@ -116,7 +119,12 @@ export default async function OrganizationLayout({
<SessionExpired />
<SettingsNavigationProvider>
<WorkspaceChrome
sidebar={<OrganizationSidebar benchmarkEnabled={benchmarkEnabled} />}
sidebar={
<OrganizationSidebar
benchmarkEnabled={benchmarkEnabled}
simLinksEnabled={simLinksEnabled}
/>
}
initialSidebarCollapsed={initialSidebarCollapsed}
>
{children}
Expand Down
19 changes: 19 additions & 0 deletions apps/sim/app/o/[organizationId]/links/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { cn } from '@sim/emcn'
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
import { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page'

export default function SimLinksLoading() {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className={PAGE_HEADER_BAR}>
<div className={HEADER_ACTION_CLUSTER} />
</div>
<div className={cn(PAGE_COLUMN_CLASS, 'pt-8')}>
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
<h1 className='text-[var(--text-primary)] text-lg'>Sim Links</h1>
<p role='status' className='mt-4 text-[var(--text-muted)] text-small'>
Loading Sim links
</p>
</div>
</div>
)
}
27 changes: 27 additions & 0 deletions apps/sim/app/o/[organizationId]/links/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { organizationRoutes } from '@/lib/navigation/paths'
import { isSimLinksEnabled } from '@/lib/sim-links/feature-flag'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { SimLinks } from '@/app/o/[organizationId]/links/sim-links'

export const metadata: Metadata = { title: 'Sim Links' }

interface SimLinksPageProps {
params: Promise<{ organizationId: string }>
}

export default async function SimLinksPage({ params }: SimLinksPageProps) {
const { organizationId } = await params
const session = await getSession()
if (!session?.user)
redirect(
buildAuthCrossLink('/login', {
callbackUrl: organizationRoutes(organizationId).simLinks,
isInviteFlow: false,
})
)
if (!(await isSimLinksEnabled(organizationId))) notFound()
return <SimLinks organizationId={organizationId} />
}
6 changes: 6 additions & 0 deletions apps/sim/app/o/[organizationId]/links/search-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createSerializer, parseAsString } from 'nuqs/server'

/** `create` opens the new-link editor, prefilled with its value as the name. */
export const simLinkViewParsers = { create: parseAsString } as const

export const serializeSimLinkView = createSerializer(simLinkViewParsers)
Loading
Loading