Skip to content

Commit 00e25a4

Browse files
authored
feat(sim-links): organization Sim links (go links) with sim/<name> redirects (#9484)
Org-scoped Sim links: sim/<name> redirects with case- and punctuation-insensitive names, {*} variable links with same-site defaults, unlisted and restricted links, visit counts with a most-used view, an illustrated empty state, audit entries, and the org-scoped sim-links feature flag. Destinations keep a fixed http(s) origin; dot-segment values and control characters are refused.
1 parent e9ac764 commit 00e25a4

49 files changed

Lines changed: 34067 additions & 39 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/scripts/http-e2e.sh‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ case "$group" in
144144
export INTERNAL_API_SECRET=scim-http-ci-local-secret-at-least-32-characters
145145
export DB_TX_TRIPWIRE=throw
146146
export NEXT_PUBLIC_CHAT_DISABLED=true
147+
export SIM_LINKS=true
147148
start_app scim 3017 SCIM record-http-status
148149
SCIM_E2E_BASE_URL="$local_app_url" \
149150
SCIM_E2E_DATABASE_URL="$DATABASE_URL" \
@@ -155,6 +156,11 @@ case "$group" in
155156
VERSION_COMPARE_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
156157
VERSION_COMPARE_E2E_REPORT_PATH="$report_dir/version-compare-http-report.json" \
157158
bun run test:workflow-version-compare:e2e
159+
SIM_LINKS_E2E_BASE_URL="$local_app_url" \
160+
SIM_LINKS_E2E_DATABASE_URL="$DATABASE_URL" \
161+
SIM_LINKS_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \
162+
SIM_LINKS_E2E_REPORT_PATH="$report_dir/sim-links-e2e-report.json" \
163+
bun run test:sim-links:e2e
158164
;;
159165

160166
# The search suites serve their own fixtures in-process and need no app. They share this group

‎apps/docs/content/docs/platform/enterprise/audit-logs.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Events use identifiers such as `resource.action`. These categories are represent
101101
| API keys | `api_key.created`, `api_key.revoked` |
102102
| Credentials | `credential.created`, `credential.deleted`, `oauth.disconnected` |
103103
| Organization | `organization.updated`, `org_member.added`, `org_member.role_changed`, `organization.sso_provider.updated` |
104+
| Sim links | `sim_link.created`, `sim_link.updated`, `sim_link.deleted` |
104105
| Exports and drains | `audit_logs.exported`, `data_drain.created`, `data_drain.updated`, `data_drain.ran`, `data_drain.tested` |
105106

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

‎apps/docs/content/docs/search/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"quickstart",
66
"find-content",
77
"ask-questions",
8+
"sim-links",
89
"troubleshooting",
910
"---Connect sources---",
1011
"connect-your-account",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
title: Sim Links
3+
description: Short sim/ links that take everyone in your organization to the right page
4+
---
5+
6+
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.
7+
8+
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.
9+
10+
## Open a link
11+
12+
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.
13+
14+
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.
15+
16+
If nobody has created the link yet, Sim offers to create it with that name.
17+
18+
## Open links from the address bar
19+
20+
Add Sim as a site search shortcut in Chrome so you can type `sim handbook` instead of the full address:
21+
22+
1. Open Chrome **Settings → Search engine → Manage search engines and site search**.
23+
2. Next to **Site search**, select **Add**.
24+
3. Enter `Sim` as the name, `sim` as the shortcut, and `https://sim.ai/sim/%s` as the URL.
25+
26+
Type `sim`, press **Space** or **Tab**, then type a link name such as `handbook` or `jira/123` and press **Enter**.
27+
28+
Chrome administrators can add this shortcut for everyone with the `SiteSearchSettings` policy, using the same shortcut and URL.
29+
30+
## Create a link
31+
32+
1. Open **Sim Links** and select **New link**.
33+
2. Enter the **Destination URL**, a full `https://` address.
34+
3. Enter a **Name**. Use letters, numbers, and `. : @ & ~ - _`.
35+
4. Optionally add a **Description** so teammates know what the link is for.
36+
5. Select **Create**.
37+
38+
Select **Copy link** from a link's menu to share it.
39+
40+
## Add values to a link
41+
42+
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`.
43+
44+
To place a value somewhere else, put `{*}` in the destination URL:
45+
46+
| Destination URL | You open | Sim opens |
47+
| --- | --- | --- |
48+
| `https://jira.example.com/browse/PROD-{*}` | `sim/jira/123` | `https://jira.example.com/browse/PROD-123` |
49+
| `https://wiki.example.com/search?q={*}` | `sim/wiki/expense policy` | `https://wiki.example.com/search?q=expense%20policy` |
50+
| `https://github.com/acme/{*}/issues/{*}` | `sim/issues/api/42` | `https://github.com/acme/api/issues/42` |
51+
52+
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 `..`.
53+
54+
## Control access
55+
56+
Under **Access**, the creator of a link can choose:
57+
58+
- **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.
59+
- **Restrict editing**: only the creator and organization admins can edit or delete the link. Everyone can still open it.
60+
61+
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).
62+
63+
## Related docs
64+
65+
- [Find content](/search/find-content)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { deleteSimLinkContract, updateSimLinkContract } from '@/lib/api/contracts/sim-links'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { presentSimLink } from '@/lib/api/server/sim-link-presenters'
9+
import { deleteSimLink, updateSimLink } from '@/lib/sim-links/application/use-cases'
10+
11+
const policy = {
12+
auth: internalSessionAuth,
13+
rateLimit: internalRateLimits.user({ bucketName: 'sim-links' }),
14+
errorPolicy: internalOrchestrationErrorPolicy,
15+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
16+
}
17+
18+
export const PUT = defineInternalJsonRoute({
19+
...policy,
20+
contract: updateSimLinkContract,
21+
operation: updateSimLink.operation,
22+
useCase: updateSimLink,
23+
mapInput: ({ params, body }) => ({ organizationId: params.id, linkId: params.linkId, ...body }),
24+
present: (link) => ({ link: presentSimLink(link) }),
25+
})
26+
27+
export const DELETE = defineInternalJsonRoute({
28+
...policy,
29+
contract: deleteSimLinkContract,
30+
operation: deleteSimLink.operation,
31+
useCase: deleteSimLink,
32+
mapInput: ({ params }) => ({ organizationId: params.id, linkId: params.linkId }),
33+
present: () => ({ success: true as const }),
34+
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createSimLinkContract, listSimLinksContract } from '@/lib/api/contracts/sim-links'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { presentSimLink } from '@/lib/api/server/sim-link-presenters'
9+
import { createSimLink, listSimLinks } from '@/lib/sim-links/application/use-cases'
10+
11+
const policy = {
12+
auth: internalSessionAuth,
13+
rateLimit: internalRateLimits.user({ bucketName: 'sim-links' }),
14+
errorPolicy: internalOrchestrationErrorPolicy,
15+
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
16+
}
17+
18+
export const GET = defineInternalJsonRoute({
19+
...policy,
20+
contract: listSimLinksContract,
21+
operation: listSimLinks.operation,
22+
useCase: listSimLinks,
23+
mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }),
24+
present: ({ links, nextCursor }) => ({ links: links.map(presentSimLink), nextCursor }),
25+
})
26+
27+
export const POST = defineInternalJsonRoute({
28+
...policy,
29+
contract: createSimLinkContract,
30+
operation: createSimLink.operation,
31+
useCase: createSimLink,
32+
mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
33+
present: (link) => ({ link: presentSimLink(link) }),
34+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* The home surface's reading column, so every organization page shares its width. Kept out of
3+
* the client component module so server-rendered loading views receive the class string itself.
4+
*/
5+
export const PAGE_COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6'
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
export { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page/constants'
12
export {
23
OrganizationPage,
34
type OrganizationPageTab,
4-
PAGE_COLUMN_CLASS,
55
} from '@/app/o/[organizationId]/components/organization-page/organization-page'
66
export { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'

‎apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@ import {
1313
} from '@sim/emcn'
1414
import { Search, X } from '@sim/emcn/icons'
1515
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
16+
import { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page/constants'
1617
import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters'
1718
import {
1819
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
1920
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
2021
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
2122

22-
/** The home surface's reading column, so every organization page shares its width. */
23-
export const PAGE_COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6'
24-
2523
export interface OrganizationPageTab {
2624
id: string
2725
label: string
Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { ChartColumn, Home, Integration, Search } from '@sim/emcn/icons'
1+
import { ChartColumn, Home, Integration, Link, Search } from '@sim/emcn/icons'
22
import { organizationRoutes } from '@/lib/navigation/paths'
33
import type { SidebarNavItemData } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
44

5-
type OrganizationNavRoute = 'home' | 'search' | 'integrations' | 'benchmark'
5+
type OrganizationNavRoute = 'home' | 'search' | 'integrations' | 'simLinks' | 'benchmark'
66

77
interface OrganizationNavEntry {
88
id: string
@@ -19,21 +19,38 @@ const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [
1919
{ id: 'home', label: 'Home', icon: Home, route: 'home' },
2020
{ id: 'search', label: 'Search', icon: Search, route: 'search' },
2121
{ id: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' },
22+
{ id: 'sim-links', label: 'Sim Links', icon: Link, route: 'simLinks' },
2223
{ id: 'benchmark', label: 'Benchmark', icon: ChartColumn, route: 'benchmark' },
2324
]
2425

26+
interface OrganizationNavAvailability {
27+
searchAvailable: boolean
28+
mothershipAvailable: boolean
29+
benchmarkEnabled?: boolean
30+
simLinksEnabled?: boolean
31+
}
32+
2533
export function buildOrganizationNavItems(
2634
organizationId: string,
27-
searchAvailable: boolean,
28-
mothershipAvailable: boolean,
29-
benchmarkEnabled = false
35+
{
36+
searchAvailable,
37+
mothershipAvailable,
38+
benchmarkEnabled = false,
39+
simLinksEnabled = false,
40+
}: OrganizationNavAvailability
3041
): SidebarNavItemData[] {
3142
const routes = organizationRoutes(organizationId)
32-
return ORGANIZATION_NAV_ENTRIES.filter(({ route }) => {
33-
if (route === 'benchmark') return benchmarkEnabled
34-
return route === 'home' ? mothershipAvailable : searchAvailable
35-
}).map(({ route, ...entry }) => ({
36-
...entry,
37-
href: routes[route],
38-
}))
43+
const available: Record<OrganizationNavRoute, boolean> = {
44+
home: mothershipAvailable,
45+
search: searchAvailable,
46+
integrations: searchAvailable,
47+
simLinks: simLinksEnabled,
48+
benchmark: benchmarkEnabled,
49+
}
50+
return ORGANIZATION_NAV_ENTRIES.filter(({ route }) => available[route]).map(
51+
({ route, ...entry }) => ({
52+
...entry,
53+
href: routes[route],
54+
})
55+
)
3956
}

0 commit comments

Comments
 (0)