mirror of
https://github.com/penpot/penpot.git
synced 2026-09-19 02:16:14 +00:00
✨ Route the app by query string with screen key
Move SPA routing out of the URL fragment into the normal query string. The screen travels in a reserved `screen` key holding the route name (`?screen=workspace&team-id=…`); every other param keeps its name. `rt/nav` and `rt/resolve` keep their signatures. This deletes the fragment-mirroring URL surgery, simplifies link-preview (the server sees everything) and nginx (single path, no SPA fallback rules needed), and migrates OIDC redirects, email links, e2e helpers and plugin test utils to the new format. Legacy `#/…` URLs translate client-side for one Penpot version (`legacy-routes`, marked TODO(next-version)); non-SPA paths are untouched. AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
parent
a91d81c695
commit
b660ea9d53
@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
## Router, app shell, and errors
|
## Router, app shell, and errors
|
||||||
|
|
||||||
- Routing uses browser-history hash tokens, but `on-navigate` rejects navigation if the current origin/path does not match `cf/public-uri`.
|
- Routing uses browser-history query tokens (`?screen=<route-name>¶ms`, single `/` path), but `on-navigate` rejects navigation if the current origin/path does not match `cf/public-uri`.
|
||||||
- Route params are split into `:path` and `:query`; duplicate query params can become vectors, so use `rt/get-query-param` when a scalar is required.
|
- Route params live entirely in the query map under the reserved `screen` key; duplicate query params can become vectors, so use `rt/get-query-param` when a scalar is required.
|
||||||
|
- Legacy `#/…` hash URLs translate client-side to the query format (one-version compat; see `legacy-routes` in `app.main.ui.routes`, TODO(next-version) to delete).
|
||||||
- Unknown/empty routes trigger an extra `get-profile`/`get-teams` check before redirecting. This avoids invitation and root-route race conditions.
|
- Unknown/empty routes trigger an extra `get-profile`/`get-teams` check before redirecting. This avoids invitation and root-route race conditions.
|
||||||
- The root app renders an exception page from `:exception` state before the normal error boundary. `rt/navigated` clears `:exception`.
|
- The root app renders an exception page from `:exception` state before the normal error boundary. `rt/navigated` clears `:exception`.
|
||||||
- Frontend error handling treats stale cross-build JS chunk failures specially: messages containing `$cljs$cst$` or `$cljs$core$I` plus undefined/null/not-a-function signatures trigger throttled reload.
|
- Frontend error handling treats stale cross-build JS chunk failures specially: messages containing `$cljs$cst$` or `$cljs$core$I` plus undefined/null/not-a-function signatures trigger throttled reload.
|
||||||
|
|||||||
@ -205,7 +205,7 @@
|
|||||||
<td align="center" bgcolor="#31EFB8" role="presentation"
|
<td align="center" bgcolor="#31EFB8" role="presentation"
|
||||||
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#31EFB8;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
|
<a href="{{ public-uri }}/?screen=auth-verify-token&token={{token}}"
|
||||||
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
style="display:inline-block;background:#31EFB8;color:#1F1F1F;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;"
|
||||||
target="_blank"> Confirm email change </a>
|
target="_blank"> Confirm email change </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ We received a request to change your current email to {{ pending-email }}.
|
|||||||
|
|
||||||
Click the link below to confirm the change.
|
Click the link below to confirm the change.
|
||||||
|
|
||||||
{{ public-uri }}/#/auth/verify-token?token={{token}}
|
{{ public-uri }}/?screen=auth-verify-token&token={{token}}
|
||||||
|
|
||||||
If you did not request this change, consider changing your password for security reasons.
|
If you did not request this change, consider changing your password for security reasons.
|
||||||
|
|
||||||
|
|||||||
@ -243,7 +243,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
|
<a href="{{ public-uri }}/?screen=auth-verify-token&token={{token}}"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> ACCEPT INVITE </a>
|
target="_blank"> ACCEPT INVITE </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -11,7 +11,7 @@ If you can't get in, your account probably isn't in the directory yet. To get ac
|
|||||||
|
|
||||||
Accept invitation using this link:
|
Accept invitation using this link:
|
||||||
|
|
||||||
{{ public-uri }}/#/auth/verify-token?token={{token}}
|
{{ public-uri }}/?screen=auth-verify-token&token={{token}}
|
||||||
|
|
||||||
Enjoy!
|
Enjoy!
|
||||||
The Penpot team.
|
The Penpot team.
|
||||||
|
|||||||
@ -220,7 +220,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
|
<a href="{{ public-uri }}/?screen=auth-verify-token&token={{token}}"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> ACCEPT INVITE </a>
|
target="_blank"> ACCEPT INVITE </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -11,7 +11,7 @@ If you can't get in, your account probably isn't in the directory yet. To get ac
|
|||||||
|
|
||||||
Accept invitation using this link:
|
Accept invitation using this link:
|
||||||
|
|
||||||
{{ public-uri }}/#/auth/verify-token?token={{token}}
|
{{ public-uri }}/?screen=auth-verify-token&token={{token}}
|
||||||
|
|
||||||
Enjoy!
|
Enjoy!
|
||||||
The Penpot team.
|
The Penpot team.
|
||||||
|
|||||||
@ -199,7 +199,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/dashboard/team/{{team-id}}/projects"
|
<a href="{{ public-uri }}/?screen=dashboard-members&team-id={{team-id}}"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> GO TO THE TEAM </a>
|
target="_blank"> GO TO THE TEAM </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ As you requested, {{invited-by|abbreviate:25}} has added you to the team “{{ t
|
|||||||
|
|
||||||
Go to the team with this link:
|
Go to the team with this link:
|
||||||
|
|
||||||
{{ public-uri }}/#/dashboard/team/{{team-id}}
|
{{ public-uri }}/?screen=dashboard-members&team-id={{team-id}}
|
||||||
|
|
||||||
Enjoy!
|
Enjoy!
|
||||||
The Penpot team.
|
The Penpot team.
|
||||||
|
|||||||
@ -199,7 +199,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/auth/recovery?token={{token}}"
|
<a href="{{ public-uri }}/?screen=auth-recovery&token={{token}}"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> RESET PASSWORD </a>
|
target="_blank"> RESET PASSWORD </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ Hello {{name|abbreviate:25}}!
|
|||||||
We received a request to reset your password. Click the link below to choose a
|
We received a request to reset your password. Click the link below to choose a
|
||||||
new one:
|
new one:
|
||||||
|
|
||||||
{{ public-uri }}/#/auth/recovery?token={{token}}
|
{{ public-uri }}/?screen=auth-recovery&token={{token}}
|
||||||
|
|
||||||
If you received this email by mistake, you can safely ignore it. Your password
|
If you received this email by mistake, you can safely ignore it. Your password
|
||||||
won't be changed.
|
won't be changed.
|
||||||
|
|||||||
@ -205,7 +205,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/auth/verify-token?token={{token}}"
|
<a href="{{ public-uri }}/?screen=auth-verify-token&token={{token}}"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> VERIFY EMAIL </a>
|
target="_blank"> VERIFY EMAIL </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ Welcome to Penpot!
|
|||||||
|
|
||||||
Please verify your email to get started with your first design and collaboration.
|
Please verify your email to get started with your first design and collaboration.
|
||||||
|
|
||||||
{{ public-uri }}/#/auth/verify-token?token={{token}}
|
{{ public-uri }}/?screen=auth-verify-token&token={{token}}
|
||||||
|
|
||||||
Enjoy!
|
Enjoy!
|
||||||
|
|
||||||
|
|||||||
@ -207,7 +207,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true"
|
<a href="{{ public-uri }}/?screen=viewer&file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> SEND A VIEW-ONLY LINK </a>
|
target="_blank"> SEND A VIEW-ONLY LINK </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ Since this file is in your Personal Projects, you can provide access by sending
|
|||||||
|
|
||||||
To proceed, please click the link below to generate and send the view-only link:
|
To proceed, please click the link below to generate and send the view-only link:
|
||||||
|
|
||||||
{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true
|
{{ public-uri }}/?screen=viewer&file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -230,7 +230,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true"
|
<a href="{{ public-uri }}/?screen=viewer&file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> SEND A VIEW-ONLY LINK </a>
|
target="_blank"> SEND A VIEW-ONLY LINK </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -19,7 +19,7 @@ Alternatively, you can create and share a view-only link to the file. This will
|
|||||||
|
|
||||||
Click the link below to generate and send the link:
|
Click the link below to generate and send the link:
|
||||||
|
|
||||||
{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true
|
{{ public-uri }}/?screen=viewer&file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -214,7 +214,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/dashboard/members?team-id={{team-id}}&invite-email={{requested-by-email|urlescape }}"
|
<a href="{{ public-uri }}/?screen=dashboard-members&team-id={{team-id}}&invite-email={{requested-by-email|urlescape }}"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> GIVE ACCESS TO “{{team-name|abbreviate:25}}” TEAM </a>
|
target="_blank"> GIVE ACCESS TO “{{team-name|abbreviate:25}}” TEAM </a>
|
||||||
</td>
|
</td>
|
||||||
@ -247,7 +247,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true"
|
<a href="{{ public-uri }}/?screen=viewer&file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> SEND A VIEW-ONLY LINK </a>
|
target="_blank"> SEND A VIEW-ONLY LINK </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ This will automatically include {{requested-by|abbreviate:25}} in the team, so t
|
|||||||
|
|
||||||
Click the link below to provide team access:
|
Click the link below to provide team access:
|
||||||
|
|
||||||
{{ public-uri }}/#/dashboard/members?team-id={{team-id}}&invite-email={{requested-by-email|urlescape}}
|
{{ public-uri }}/?screen=dashboard-members&team-id={{team-id}}&invite-email={{requested-by-email|urlescape}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ Alternatively, you can create and share a view-only link to the file. This will
|
|||||||
|
|
||||||
Click the link below to generate and send the link:
|
Click the link below to generate and send the link:
|
||||||
|
|
||||||
{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true
|
{{ public-uri }}/?screen=viewer&file-id={{file-id}}&page-id={{page-id}}§ion=interactions&index=0&share=true
|
||||||
|
|
||||||
|
|
||||||
If you do not wish to grant access at this time, you can simply disregard this email.
|
If you do not wish to grant access at this time, you can simply disregard this email.
|
||||||
|
|||||||
@ -205,7 +205,7 @@
|
|||||||
<td align="center" bgcolor="#6911d4" role="presentation"
|
<td align="center" bgcolor="#6911d4" role="presentation"
|
||||||
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
valign="middle">
|
valign="middle">
|
||||||
<a href="{{ public-uri }}/#/dashboard/members?team-id={{team-id}}&invite-email={{requested-by-email|urlescape}}"
|
<a href="{{ public-uri }}/?screen=dashboard-members&team-id={{team-id}}&invite-email={{requested-by-email|urlescape}}"
|
||||||
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
target="_blank"> GIVE ACCESS TO “{{team-name|abbreviate:25}}” TEAM </a>
|
target="_blank"> GIVE ACCESS TO “{{team-name|abbreviate:25}}” TEAM </a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ Hello!
|
|||||||
|
|
||||||
To provide access, please click the link below:
|
To provide access, please click the link below:
|
||||||
|
|
||||||
{{ public-uri }}/#/dashboard/members?team-id={{team-id}}&invite-email={{requested-by-email|urlescape}}
|
{{ public-uri }}/?screen=dashboard-members&team-id={{team-id}}&invite-email={{requested-by-email|urlescape}}
|
||||||
|
|
||||||
|
|
||||||
If you do not wish to grant access at this time, you can simply disregard this email.
|
If you do not wish to grant access at this time, you can simply disregard this email.
|
||||||
|
|||||||
@ -246,9 +246,9 @@ Graph Console
|
|||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
|
|
||||||
// Make the loaded-session file name a link into the Penpot workspace.
|
// Make the loaded-session file name a link into the Penpot workspace.
|
||||||
// The legacy /#/workspace/<project-id>/<file-id> route resolves the team
|
// Query-string routing needs the team id up front; team, project and
|
||||||
// itself, and project-id is already in the files-tree payload; same
|
// file ids are all in the files-tree payload; same origin as this
|
||||||
// origin as this page, so no base URL to configure.
|
// page, so no base URL to configure.
|
||||||
function linkLoadedFile(teams) {
|
function linkLoadedFile(teams) {
|
||||||
if (!penpotLink || !penpotLink.dataset.fileId) return;
|
if (!penpotLink || !penpotLink.dataset.fileId) return;
|
||||||
const fileId = penpotLink.dataset.fileId;
|
const fileId = penpotLink.dataset.fileId;
|
||||||
@ -256,7 +256,7 @@ Graph Console
|
|||||||
(team.projects || []).forEach(function (project) {
|
(team.projects || []).forEach(function (project) {
|
||||||
(project.files || []).forEach(function (file) {
|
(project.files || []).forEach(function (file) {
|
||||||
if (file.id === fileId) {
|
if (file.id === fileId) {
|
||||||
penpotLink.href = "/#/workspace/" + project.id + "/" + file.id;
|
penpotLink.href = "/?screen=workspace&team-id=" + team.id + "&file-id=" + file.id;
|
||||||
penpotLink.title = "Open in Penpot";
|
penpotLink.title = "Open in Penpot";
|
||||||
const crumbs = document.getElementById("graph-file-crumbs");
|
const crumbs = document.getElementById("graph-file-crumbs");
|
||||||
if (crumbs) crumbs.textContent = team.name + " › " + project.name + " › ";
|
if (crumbs) crumbs.textContent = team.name + " › " + project.name + " › ";
|
||||||
|
|||||||
@ -17,6 +17,6 @@
|
|||||||
<meta name="twitter:image" content="{{image}}" />
|
<meta name="twitter:image" content="{{image}}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>location.replace((location.pathname.replace(/link-preview\/?$/, "") || "/") + location.search + location.hash);</script>
|
<script>location.replace((location.pathname.replace(/link-preview\/?$/, "") || "/") + location.search);</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -685,10 +685,8 @@
|
|||||||
(defn- redirect-with-error
|
(defn- redirect-with-error
|
||||||
([error] (redirect-with-error error nil))
|
([error] (redirect-with-error error nil))
|
||||||
([error hint]
|
([error hint]
|
||||||
(let [params {:error error :hint hint}
|
(let [params {:screen "auth-login" :error error :hint hint}
|
||||||
params (d/without-nils params)
|
|
||||||
uri (-> (u/uri (cf/get :public-uri))
|
uri (-> (u/uri (cf/get :public-uri))
|
||||||
(assoc :path "/#/auth/login")
|
|
||||||
(assoc :query (u/map->query-string params)))]
|
(assoc :query (u/map->query-string params)))]
|
||||||
(redirect-response uri))))
|
(redirect-response uri))))
|
||||||
|
|
||||||
@ -707,21 +705,19 @@
|
|||||||
:iss :prepared-register
|
:iss :prepared-register
|
||||||
:exp (ct/in-future {:hours 48}))
|
:exp (ct/in-future {:hours 48}))
|
||||||
|
|
||||||
params {:token (tokens/generate cfg info)
|
params {:screen "auth-register-validate"
|
||||||
|
:token (tokens/generate cfg info)
|
||||||
:provider (:provider (:id provider))
|
:provider (:provider (:id provider))
|
||||||
:fullname (:fullname info)}
|
:fullname (:fullname info)}]
|
||||||
params (d/without-nils params)]
|
|
||||||
|
|
||||||
(redirect-response
|
(redirect-response
|
||||||
(-> (u/uri (cf/get :public-uri))
|
(-> (u/uri (cf/get :public-uri))
|
||||||
(assoc :path "/#/auth/register/validate")
|
|
||||||
(assoc :query (u/map->query-string params))))))
|
(assoc :query (u/map->query-string params))))))
|
||||||
|
|
||||||
(defn- redirect-to-verify-token
|
(defn- redirect-to-verify-token
|
||||||
[token]
|
[token]
|
||||||
(let [params {:token token}
|
(let [params {:screen "auth-verify-token" :token token}
|
||||||
uri (-> (u/uri (cf/get :public-uri))
|
uri (-> (u/uri (cf/get :public-uri))
|
||||||
(assoc :path "/#/auth/verify-token")
|
|
||||||
(assoc :query (u/map->query-string params)))]
|
(assoc :query (u/map->query-string params)))]
|
||||||
|
|
||||||
(redirect-response uri)))
|
(redirect-response uri)))
|
||||||
|
|||||||
@ -147,7 +147,7 @@
|
|||||||
(let [result (#'oidc/redirect-with-error "auth-error" "hint message")
|
(let [result (#'oidc/redirect-with-error "auth-error" "hint message")
|
||||||
loc (get-in result [::yres/headers "location"])]
|
loc (get-in result [::yres/headers "location"])]
|
||||||
(t/is (= 302 (::yres/status result)))
|
(t/is (= 302 (::yres/status result)))
|
||||||
(t/is (.contains loc "http://localhost:3449/#/auth/login?"))
|
(t/is (.contains loc "http://localhost:3449?screen=auth-login&"))
|
||||||
(t/is (.contains loc "error=auth-error"))
|
(t/is (.contains loc "error=auth-error"))
|
||||||
(t/is (.contains loc "hint=hint"))))
|
(t/is (.contains loc "hint=hint"))))
|
||||||
(t/testing "without hint omits hint param"
|
(t/testing "without hint omits hint param"
|
||||||
@ -161,7 +161,7 @@
|
|||||||
(let [result (#'oidc/redirect-to-verify-token "test-token-value")
|
(let [result (#'oidc/redirect-to-verify-token "test-token-value")
|
||||||
loc (get-in result [::yres/headers "location"])]
|
loc (get-in result [::yres/headers "location"])]
|
||||||
(t/is (= 302 (::yres/status result)))
|
(t/is (= 302 (::yres/status result)))
|
||||||
(t/is (.contains loc "http://localhost:3449/#/auth/verify-token?"))
|
(t/is (.contains loc "http://localhost:3449?screen=auth-verify-token&"))
|
||||||
(t/is (.contains loc "token=test-token-value")))))
|
(t/is (.contains loc "token=test-token-value")))))
|
||||||
|
|
||||||
(t/deftest build-redirect-uri-constructs-redirect
|
(t/deftest build-redirect-uri-constructs-redirect
|
||||||
@ -485,7 +485,7 @@
|
|||||||
app.auth.oidc/get-profile (constantly (assoc test-profile :is-active false))]
|
app.auth.oidc/get-profile (constantly (assoc test-profile :is-active false))]
|
||||||
(let [result (#'oidc/callback-handler cfg request)
|
(let [result (#'oidc/callback-handler cfg request)
|
||||||
loc (redirect-location result)]
|
loc (redirect-location result)]
|
||||||
(t/is (.contains loc "http://localhost:3449/#/auth/register/validate?"))
|
(t/is (.contains loc "http://localhost:3449?screen=auth-register-validate&"))
|
||||||
(t/is (.contains loc "token=")))))))
|
(t/is (.contains loc "token=")))))))
|
||||||
|
|
||||||
(t/deftest callback-success-flow
|
(t/deftest callback-success-flow
|
||||||
@ -503,7 +503,7 @@
|
|||||||
app.loggers.audit/submit (constantly nil)]
|
app.loggers.audit/submit (constantly nil)]
|
||||||
(let [result (#'oidc/callback-handler cfg request)
|
(let [result (#'oidc/callback-handler cfg request)
|
||||||
loc (redirect-location result)]
|
loc (redirect-location result)]
|
||||||
(t/is (.contains loc "http://localhost:3449/#/auth/verify-token?"))
|
(t/is (.contains loc "http://localhost:3449?screen=auth-verify-token&"))
|
||||||
(t/is (.contains loc "token=")))))))
|
(t/is (.contains loc "token=")))))))
|
||||||
|
|
||||||
(t/deftest callback-gracefully-handles-unable-to-retrieve-user-info
|
(t/deftest callback-gracefully-handles-unable-to-retrieve-user-info
|
||||||
|
|||||||
@ -23,58 +23,50 @@ The whole feature is gated behind the `link-preview` flag (enabled with
|
|||||||
|
|
||||||
## How it works, end to end
|
## How it works, end to end
|
||||||
|
|
||||||
The main obstacle is that Penpot is a SPA and all the routing state lives in
|
Penpot routes by query string: the screen and its context travel as
|
||||||
the URL **fragment** (`#/workspace?file-id=...`). The fragment is never sent to
|
normal query params (`/?screen=workspace&file-id=...`), so the backend
|
||||||
the server, so with a plain URL the backend has no way to know which file the
|
sees everything directly. The feature is therefore built from three
|
||||||
link points to. The feature is therefore built from three cooperating pieces:
|
cooperating pieces:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
user shares URL crawler (Slackbot, ...) regular browser
|
user shares URL crawler (Slackbot, ...) regular browser
|
||||||
│ │ │
|
│ │ │
|
||||||
│ https://host/?file-id=X#/workspace?... │
|
│ https://host/?screen=workspace&file-id=X │
|
||||||
│ │ │
|
│ │ │
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
[frontend] [nginx] [nginx]
|
[frontend] [nginx] [nginx]
|
||||||
mirrors context user-agent matches crawler user-agent is normal
|
nothing special: user-agent matches crawler user-agent is normal
|
||||||
params before the rewrite / -> /link-preview serve SPA index.html
|
screen+ids are rewrite / -> /link-preview serve SPA index.html
|
||||||
fragment on every (query string preserved)
|
already in the (query string preserved)
|
||||||
navigation │
|
query │
|
||||||
▼
|
▼
|
||||||
[backend]
|
[backend]
|
||||||
GET /link-preview?file-id=X
|
GET /link-preview?screen=workspace&file-id=X
|
||||||
query DB, render Open
|
query DB, render Open
|
||||||
Graph HTML template
|
Graph HTML template
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1. Frontend: mirroring context params on the query string
|
### 1. Frontend: nothing to do
|
||||||
|
|
||||||
File: `frontend/src/app/main/router.cljs`
|
File: `frontend/src/app/main/router.cljs`
|
||||||
|
|
||||||
On every navigation, the `navigated` event reads the freshly stored
|
No URL surgery is needed: navigation writes the query string
|
||||||
`(:route state)` and syncs its `file-id`/`team-id`/`project-id` fragment
|
directly (`screen` plus the screen params), so when the user copies
|
||||||
params into the query string (before the fragment) using
|
the URL from the address bar and shares it, the context ids travel
|
||||||
`history.replaceState`. Every other param in the URL is left untouched,
|
in a part of the URL that *does* reach the server. The resulting
|
||||||
so unrelated params owned by other code survive. The write is skipped
|
URLs look like:
|
||||||
when the resulting href already matches the address bar. The backend
|
|
||||||
applies its own file > project > team priority, so no filtering happens
|
|
||||||
on the frontend. The resulting URLs look like:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://design.penpot.app/?file-id=<uuid>#/workspace?team-id=...&file-id=...&page-id=...
|
https://design.penpot.app/?screen=workspace&team-id=...&file-id=...&page-id=...
|
||||||
https://design.penpot.app/?team-id=<uuid>&project-id=<uuid>#/dashboard/recent?...
|
https://design.penpot.app/?screen=dashboard-recent&team-id=...&project-id=...
|
||||||
https://design.penpot.app/?team-id=<uuid>#/dashboard/recent?team-id=...
|
https://design.penpot.app/?screen=auth-login
|
||||||
```
|
```
|
||||||
|
|
||||||
Routes without any of those ids (e.g. auth pages) clear them from the
|
Legacy hash URLs (`#/workspace?...`) from bookmarks and old emails
|
||||||
query string; `replaceState` only writes when the computed href differs
|
are translated client-side to the query format on load (one-version
|
||||||
from the current one, so no URL churn happens on navigation.
|
compatibility window, see the query-string routing plan); the
|
||||||
|
fragment never reaches the server, so this translation can only
|
||||||
This way, when the user copies the URL from the address bar and shares it, the
|
happen in the browser.
|
||||||
context ids travel in a part of the URL that *does* reach the server.
|
|
||||||
|
|
||||||
Legacy hash routes (`/workspace/:project-id/:file-id`, `/view/:file-id`,
|
|
||||||
`/dashboard/team/:team-id/...`) were removed: those old URLs no longer
|
|
||||||
redirect and resolve to the not-found page instead.
|
|
||||||
|
|
||||||
### 2. Nginx: detecting link preview crawlers
|
### 2. Nginx: detecting link preview crawlers
|
||||||
|
|
||||||
@ -106,8 +98,12 @@ location = /link-preview {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Regular browsers are not affected: they keep receiving the SPA `index.html`.
|
Regular browsers are not affected: they keep receiving the SPA `index.html`.
|
||||||
If you self-host behind a different reverse proxy, you need to replicate this
|
No SPA fallback rules are needed for app screens because the app lives
|
||||||
routing there.
|
on the single `/` path — the `try_files … /index.html` fallback and the
|
||||||
|
`^/[^/]+` deep-path rule already cover everything, and no new path
|
||||||
|
rules will ever be needed for routing. If you self-host behind a
|
||||||
|
different reverse proxy, you only need to replicate the crawler
|
||||||
|
rewrite on `/` there.
|
||||||
|
|
||||||
### 3. Backend: the `/link-preview` endpoint
|
### 3. Backend: the `/link-preview` endpoint
|
||||||
|
|
||||||
@ -163,12 +159,12 @@ equivalent `twitter:*` card tags and `<meta name="robots" content="noindex">`.
|
|||||||
The body contains a single script:
|
The body contains a single script:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script>location.replace((location.pathname.replace(/link-preview\/?$/, "") || "/") + location.search + location.hash);</script>
|
<script>location.replace((location.pathname.replace(/link-preview\/?$/, "") || "/") + location.search);</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
so that if a *human* somehow lands on `/link-preview` (e.g. some clients let users
|
so that if a *human* somehow lands on `/link-preview` (e.g. some clients let users
|
||||||
click through to the fetched URL), the browser bounces back to the SPA root
|
click through to the fetched URL), the browser bounces back to the SPA root
|
||||||
keeping the query string and the fragment, and the app loads normally. The
|
keeping the query string, and the app loads normally. The
|
||||||
redirect strips only the trailing `link-preview` segment so subpath
|
redirect strips only the trailing `link-preview` segment so subpath
|
||||||
deployments keep their prefix. Crawlers do not execute
|
deployments keep their prefix. Crawlers do not execute
|
||||||
JavaScript, so they just read the meta tags.
|
JavaScript, so they just read the meta tags.
|
||||||
@ -240,8 +236,8 @@ indexing these preview pages, and responses are marked non-cacheable.
|
|||||||
|
|
||||||
3. In the browser (`http://localhost:3449`), open a file in the workspace and
|
3. In the browser (`http://localhost:3449`), open a file in the workspace and
|
||||||
go back to the dashboard — leaving the workspace is what generates the
|
go back to the dashboard — leaving the workspace is what generates the
|
||||||
dashboard thumbnail. Verify the address bar now shows `?file-id=...`
|
dashboard thumbnail. Verify the address bar now shows
|
||||||
before the `#`.
|
`?screen=workspace&file-id=...` (screen plus context, no fragment).
|
||||||
|
|
||||||
4. Hit the endpoint directly (bypasses the user-agent detection):
|
4. Hit the endpoint directly (bypasses the user-agent detection):
|
||||||
|
|
||||||
@ -257,7 +253,7 @@ indexing these preview pages, and responses are marked non-cacheable.
|
|||||||
nginx → rewrite → backend path:
|
nginx → rewrite → backend path:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -A "Slackbot-LinkExpanding 1.0" "http://localhost:3449/?file-id=<FILE_ID>"
|
curl -A "Slackbot-LinkExpanding 1.0" "http://localhost:3449/?screen=workspace&file-id=<FILE_ID>"
|
||||||
```
|
```
|
||||||
|
|
||||||
The same URL with a normal user-agent must return the SPA `index.html`.
|
The same URL with a normal user-agent must return the SPA `index.html`.
|
||||||
@ -287,9 +283,9 @@ indexing these preview pages, and responses are marked non-cacheable.
|
|||||||
* `backend/test/backend_tests/http_assets_test.clj`
|
* `backend/test/backend_tests/http_assets_test.clj`
|
||||||
(`objects-handler-file-thumbnail-bucket-link-preview-flag`) — the
|
(`objects-handler-file-thumbnail-bucket-link-preview-flag`) — the
|
||||||
`file-thumbnail` bucket is public only while the flag is enabled.
|
`file-thumbnail` bucket is public only while the flag is enabled.
|
||||||
* `frontend/test/frontend_tests/router_test.cljs` — the `navigated` URL
|
* `frontend/test/frontend_tests/router_test.cljs` — the `screen`
|
||||||
surgery (mirror, skip, stale-strip, clear, unrelated-param
|
match/resolve rules (token building, missing/unknown screen,
|
||||||
preservation, every-present-id, repeated-key, subpath base).
|
repeated keys).
|
||||||
|
|
||||||
## Relevant files
|
## Relevant files
|
||||||
|
|
||||||
@ -300,6 +296,7 @@ indexing these preview pages, and responses are marked non-cacheable.
|
|||||||
| `backend/src/app/http/assets.clj` | Makes `file-thumbnail` bucket public under the flag |
|
| `backend/src/app/http/assets.clj` | Makes `file-thumbnail` bucket public under the flag |
|
||||||
| `backend/src/app/http.clj`, `backend/src/app/main.clj` | Route registration and system wiring |
|
| `backend/src/app/http.clj`, `backend/src/app/main.clj` | Route registration and system wiring |
|
||||||
| `common/src/app/common/flags.cljc` | `:link-preview` flag definition |
|
| `common/src/app/common/flags.cljc` | `:link-preview` flag definition |
|
||||||
| `frontend/src/app/main/router.cljs` | Mirrors context ids on the query string on navigation |
|
| `frontend/src/app/main/router.cljs` | Screen match/resolve over the query string |
|
||||||
|
| `frontend/src/app/main/ui/routes.cljs` | Route table plus the one-version legacy hash translation |
|
||||||
| `docker/devenv/files/nginx.conf` | Devenv crawler detection and `/link-preview` routing |
|
| `docker/devenv/files/nginx.conf` | Devenv crawler detection and `/link-preview` routing |
|
||||||
| `docker/images/files/nginx.conf.template` | Same routing for the production image |
|
| `docker/images/files/nginx.conf.template` | Same routing for the production image |
|
||||||
|
|||||||
@ -28,6 +28,10 @@ export class MockWebSocketHelper extends EventTarget {
|
|||||||
await page.addInitScript({ path: "playwright/scripts/MockWebSocket.js" });
|
await page.addInitScript({ path: "playwright/scripts/MockWebSocket.js" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static clear() {
|
||||||
|
this.#mocks = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
static waitForURL(url) {
|
static waitForURL(url) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const intervalID = setInterval(() => {
|
const intervalID = setInterval(() => {
|
||||||
|
|||||||
@ -235,63 +235,63 @@ export class DashboardPage extends BaseWebSocketPage {
|
|||||||
|
|
||||||
async goToDashboard() {
|
async goToDashboard() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/recent?team-id=${DashboardPage.anyTeamId}`,
|
`/?screen=dashboard-recent&team-id=${DashboardPage.anyTeamId}`,
|
||||||
);
|
);
|
||||||
await expect(this.mainHeading).toBeVisible();
|
await expect(this.mainHeading).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToSecondTeamDashboard() {
|
async goToSecondTeamDashboard() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/recent?team-id=${DashboardPage.secondTeamId}`,
|
`/?screen=dashboard-recent&team-id=${DashboardPage.secondTeamId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToSecondTeamMembersSection() {
|
async goToSecondTeamMembersSection() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/members?team-id=${DashboardPage.secondTeamId}`,
|
`/?screen=dashboard-members&team-id=${DashboardPage.secondTeamId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToSecondTeamInvitationsSection() {
|
async goToSecondTeamInvitationsSection() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/invitations?team-id=${DashboardPage.secondTeamId}`,
|
`/?screen=dashboard-invitations&team-id=${DashboardPage.secondTeamId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToSecondTeamWebhooksSection() {
|
async goToSecondTeamWebhooksSection() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/webhooks?team-id=${DashboardPage.secondTeamId}`,
|
`/?screen=dashboard-webhooks&team-id=${DashboardPage.secondTeamId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToSecondTeamWebhooksSection() {
|
async goToSecondTeamWebhooksSection() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/webhooks?team-id=${DashboardPage.secondTeamId}`,
|
`/?screen=dashboard-webhooks&team-id=${DashboardPage.secondTeamId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToSecondTeamSettingsSection() {
|
async goToSecondTeamSettingsSection() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/settings?team-id=${DashboardPage.secondTeamId}`,
|
`/?screen=dashboard-settings&team-id=${DashboardPage.secondTeamId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToSearch() {
|
async goToSearch() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/search?team-id=${DashboardPage.anyTeamId}`,
|
`/?screen=dashboard-search&team-id=${DashboardPage.anyTeamId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToDrafts() {
|
async goToDrafts() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/files?team-id=${DashboardPage.anyTeamId}&project-id=${DashboardPage.draftProjectId}`,
|
`/?screen=dashboard-files&team-id=${DashboardPage.anyTeamId}&project-id=${DashboardPage.draftProjectId}`,
|
||||||
);
|
);
|
||||||
await expect(this.mainHeading).toHaveText("Drafts");
|
await expect(this.mainHeading).toHaveText("Drafts");
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToFonts() {
|
async goToFonts() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/fonts?team-id=${DashboardPage.anyTeamId}`,
|
`/?screen=dashboard-fonts&team-id=${DashboardPage.anyTeamId}`,
|
||||||
);
|
);
|
||||||
await expect(this.mainHeading).toHaveText("Fonts");
|
await expect(this.mainHeading).toHaveText("Fonts");
|
||||||
}
|
}
|
||||||
@ -304,14 +304,14 @@ export class DashboardPage extends BaseWebSocketPage {
|
|||||||
|
|
||||||
async goToLibraries() {
|
async goToLibraries() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/libraries?team-id=${DashboardPage.anyTeamId}`,
|
`/?screen=dashboard-libraries&team-id=${DashboardPage.anyTeamId}`,
|
||||||
);
|
);
|
||||||
await expect(this.mainHeading).toHaveText("Libraries");
|
await expect(this.mainHeading).toHaveText("Libraries");
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToDeleted() {
|
async goToDeleted() {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`#/dashboard/deleted?team-id=${DashboardPage.anyTeamId}`,
|
`/?screen=dashboard-deleted&team-id=${DashboardPage.anyTeamId}`,
|
||||||
);
|
);
|
||||||
await expect(this.mainHeading).toHaveText("Projects");
|
await expect(this.mainHeading).toHaveText("Projects");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,7 +78,7 @@ export class RegisterPage extends BasePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async goToVerifyToken(token = "verify-email-token") {
|
async goToVerifyToken(token = "verify-email-token") {
|
||||||
await this.page.goto(`/#/auth/verify-token?token=${token}`);
|
await this.page.goto(`/?screen=auth-verify-token&token=${token}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async init(page) {
|
static async init(page) {
|
||||||
|
|||||||
@ -158,7 +158,7 @@ export class ShortcutsPage extends BaseWebSocketPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async goToShortcuts() {
|
async goToShortcuts() {
|
||||||
await this.page.goto("#/settings/shortcuts");
|
await this.page.goto("/?screen=settings-shortcuts");
|
||||||
await expect(this.shortcutsSection).toBeVisible();
|
await expect(this.shortcutsSection).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export class SubscriptionProfilePage extends DashboardPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async goToSubscriptions() {
|
async goToSubscriptions() {
|
||||||
await this.page.goto(`#/settings/subscriptions`);
|
await this.page.goto(`/?screen=settings-subscription`);
|
||||||
await expect(this.mainHeading).toBeVisible();
|
await expect(this.mainHeading).toBeVisible();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { BaseWebSocketPage } from "./BaseWebSocketPage";
|
import { BaseWebSocketPage } from "./BaseWebSocketPage";
|
||||||
|
import { MockWebSocketHelper } from "../../helpers/MockWebSocketHelper";
|
||||||
|
|
||||||
export class ViewerPage extends BaseWebSocketPage {
|
export class ViewerPage extends BaseWebSocketPage {
|
||||||
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
|
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
|
||||||
@ -119,9 +120,22 @@ export class ViewerPage extends BaseWebSocketPage {
|
|||||||
fileId = ViewerPage.anyFileId,
|
fileId = ViewerPage.anyFileId,
|
||||||
pageId = ViewerPage.anyPageId,
|
pageId = ViewerPage.anyPageId,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
// Same as WorkspacePage.goToWorkspace: skip the reload when already
|
||||||
|
// on the target file so repeated setups keep the in-memory file
|
||||||
|
// state. Extra query params are ignored.
|
||||||
|
const currentParams = new URL(this.page.url()).searchParams;
|
||||||
|
const sameFile =
|
||||||
|
currentParams.get("screen") === "viewer" &&
|
||||||
|
currentParams.get("file-id") === fileId &&
|
||||||
|
currentParams.get("page-id") === pageId;
|
||||||
|
if (!sameFile) {
|
||||||
|
// Same as WorkspacePage.goToWorkspace: drop stale mocks from any
|
||||||
|
// previous document before reloading the app.
|
||||||
|
MockWebSocketHelper.clear();
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`/#/view?file-id=${fileId}&page-id=${pageId}§ion=interactions&index=0`,
|
`/?screen=viewer&file-id=${fileId}&page-id=${pageId}§ion=interactions&index=0`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.#ws = await this.waitForNotificationsWebSocket();
|
this.#ws = await this.waitForNotificationsWebSocket();
|
||||||
await this.#ws.mockOpen();
|
await this.#ws.mockOpen();
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { MockWebSocketHelper } from "../../helpers/MockWebSocketHelper";
|
||||||
import { BaseWebSocketPage } from "./BaseWebSocketPage";
|
import { BaseWebSocketPage } from "./BaseWebSocketPage";
|
||||||
import { Transit } from "../../helpers/Transit";
|
import { Transit } from "../../helpers/Transit";
|
||||||
|
|
||||||
@ -218,13 +219,39 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
pageId = this.pageId ?? WorkspacePage.anyPageId,
|
pageId = this.pageId ?? WorkspacePage.anyPageId,
|
||||||
pageName = "Page 1",
|
pageName = "Page 1",
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
// Helpers often call setup (and this) several times per test with the
|
||||||
|
// same file. Re-navigating would reload the document and wipe the
|
||||||
|
// in-memory file state (e.g. tokens created by previous steps), so
|
||||||
|
// only navigate when the target file actually changes. Extra query
|
||||||
|
// params the app adds itself (e.g. layout=tokens) are ignored, and
|
||||||
|
// navigating away and back still reloads as before.
|
||||||
|
const currentParams = new URL(this.page.url()).searchParams;
|
||||||
|
const sameFile =
|
||||||
|
currentParams.get("screen") === "workspace" &&
|
||||||
|
currentParams.get("team-id") === WorkspacePage.anyTeamId &&
|
||||||
|
currentParams.get("file-id") === fileId &&
|
||||||
|
currentParams.get("page-id") === pageId;
|
||||||
|
if (!sameFile) {
|
||||||
|
// Drop mocks from any previous document: page.goto reloads the app,
|
||||||
|
// so entries registered by the old document would otherwise resolve
|
||||||
|
// waitForNotificationsWebSocket immediately with a stale mock that
|
||||||
|
// no longer exists in the new document.
|
||||||
|
MockWebSocketHelper.clear();
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
|
`/?screen=workspace&team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.#ws = await this.waitForNotificationsWebSocket();
|
this.#ws = await this.waitForNotificationsWebSocket();
|
||||||
await this.#ws.mockOpen();
|
await this.#ws.mockOpen();
|
||||||
|
if (!sameFile) {
|
||||||
await this.#waitForWebSocketReadiness(pageName);
|
await this.#waitForWebSocketReadiness(pageName);
|
||||||
|
} else {
|
||||||
|
// Already on the target file (e.g. Tokens tab open, where the
|
||||||
|
// sitemap page name is not rendered): just ensure the canvas is
|
||||||
|
// present instead of waiting for the page name.
|
||||||
|
await expect(this.viewport).toBeVisible({ timeout: 30000 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async #waitForWebSocketReadiness(pageName) {
|
async #waitForWebSocketReadiness(pageName) {
|
||||||
|
|||||||
@ -10,7 +10,7 @@ test("Dashboard page has title ", async ({ page }) => {
|
|||||||
|
|
||||||
await dashboardPage.goToDashboard();
|
await dashboardPage.goToDashboard();
|
||||||
|
|
||||||
await expect(dashboardPage.page).toHaveURL(/dashboard/);
|
await expect(dashboardPage.page).toHaveURL(/screen=dashboard/);
|
||||||
await expect(dashboardPage.mainHeading).toBeVisible();
|
await expect(dashboardPage.mainHeading).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ test.describe("Email verification", () => {
|
|||||||
await registerPage.setupEmailVerificationSuccess();
|
await registerPage.setupEmailVerificationSuccess();
|
||||||
|
|
||||||
await registerPage.goToVerifyToken();
|
await registerPage.goToVerifyToken();
|
||||||
await page.waitForURL("**/dashboard/**");
|
await page.waitForURL(/screen=dashboard/);
|
||||||
|
|
||||||
// `default` is the body class applied for dark theme, `light` for
|
// `default` is the body class applied for dark theme, `light` for
|
||||||
// light theme (see app.util.theme/set-color-scheme).
|
// light theme (see app.util.theme/set-color-scheme).
|
||||||
@ -33,9 +33,9 @@ test.describe("Email verification", () => {
|
|||||||
await registerPage.setupEmailVerificationSuccess();
|
await registerPage.setupEmailVerificationSuccess();
|
||||||
|
|
||||||
await registerPage.goToVerifyToken();
|
await registerPage.goToVerifyToken();
|
||||||
await page.waitForURL("**/dashboard/**");
|
await page.waitForURL(/screen=dashboard/);
|
||||||
|
|
||||||
await page.goto("/#/settings/options");
|
await page.goto("/?screen=settings-options");
|
||||||
|
|
||||||
// The language select is the first combobox on the page, the theme
|
// The language select is the first combobox on the page, the theme
|
||||||
// select is the second one.
|
// select is the second one.
|
||||||
|
|||||||
36
frontend/playwright/ui/specs/legacy-hash-redirect.spec.js
Normal file
36
frontend/playwright/ui/specs/legacy-hash-redirect.spec.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { LoginPage } from "../pages/LoginPage";
|
||||||
|
|
||||||
|
// One-version compatibility: legacy `#/…` hash URLs translate to the
|
||||||
|
// query-string format client-side (the fragment never reaches the
|
||||||
|
// server). TODO(next-version): delete with the legacy hash shim.
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await LoginPage.init(page);
|
||||||
|
|
||||||
|
const login = new LoginPage(page);
|
||||||
|
await login.initWithLoggedOutUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Legacy auth hash URL redirects to query-string format", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
|
||||||
|
await page.goto("/#/auth/login");
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/screen=auth-login/);
|
||||||
|
expect(new URL(page.url()).hash).toBe("");
|
||||||
|
await expect(loginPage.initialHeading).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Unknown legacy hash falls through to the query flow", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const loginPage = new LoginPage(page);
|
||||||
|
// The unknown-route fallback rechecks profile AND teams.
|
||||||
|
await loginPage.mockRPC("get-teams", "logged-in-user/get-teams-default.json");
|
||||||
|
|
||||||
|
await page.goto("/?template=foo#/nope");
|
||||||
|
|
||||||
|
await expect(loginPage.initialHeading).toBeVisible();
|
||||||
|
});
|
||||||
@ -7,7 +7,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
const login = new LoginPage(page);
|
const login = new LoginPage(page);
|
||||||
await login.initWithLoggedOutUser();
|
await login.initWithLoggedOutUser();
|
||||||
|
|
||||||
await page.goto("/#/auth/login");
|
await page.goto("/?screen=auth-login");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("User is redirected to the login page when logged out", async ({
|
test("User is redirected to the login page when logged out", async ({
|
||||||
@ -17,11 +17,22 @@ test("User is redirected to the login page when logged out", async ({
|
|||||||
|
|
||||||
await loginPage.setupLoggedInUser();
|
await loginPage.setupLoggedInUser();
|
||||||
|
|
||||||
await expect(loginPage.page).toHaveURL(/auth\/login$/);
|
await expect(loginPage.page).toHaveURL(/screen=auth-login$/);
|
||||||
await expect(loginPage.initialHeading).toBeVisible();
|
await expect(loginPage.initialHeading).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Login form", () => {
|
test.describe("Login form", () => {
|
||||||
|
test("User navigates to register by clicking the create account link", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await LoginPage.mockConfigFlags(page, ["registration"]);
|
||||||
|
await page.goto("/?screen=auth-login");
|
||||||
|
|
||||||
|
await page.getByTestId("register-submit").click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/screen=auth-register/);
|
||||||
|
});
|
||||||
|
|
||||||
test("User logs in by filling the login form", async ({ page }) => {
|
test("User logs in by filling the login form", async ({ page }) => {
|
||||||
const loginPage = new LoginPage(page);
|
const loginPage = new LoginPage(page);
|
||||||
await loginPage.setupLoginSuccess();
|
await loginPage.setupLoginSuccess();
|
||||||
@ -30,8 +41,8 @@ test.describe("Login form", () => {
|
|||||||
await loginPage.fillEmailAndPasswordInputs("foo@example.com", "loremipsum");
|
await loginPage.fillEmailAndPasswordInputs("foo@example.com", "loremipsum");
|
||||||
await loginPage.clickLoginButton();
|
await loginPage.clickLoginButton();
|
||||||
|
|
||||||
await page.waitForURL("**/dashboard/**");
|
await page.waitForURL(/screen=dashboard/);
|
||||||
await expect(loginPage.page).toHaveURL(/dashboard/);
|
await expect(loginPage.page).toHaveURL(/screen=dashboard/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("User gets error message when submitting an bad formatted email ", async ({
|
test("User gets error message when submitting an bad formatted email ", async ({
|
||||||
@ -58,6 +69,6 @@ test.describe("Login form", () => {
|
|||||||
await loginPage.clickLoginButton();
|
await loginPage.clickLoginButton();
|
||||||
|
|
||||||
await expect(loginPage.invalidCredentialsError).toBeVisible();
|
await expect(loginPage.invalidCredentialsError).toBeVisible();
|
||||||
await expect(loginPage.page).toHaveURL(/auth\/login$/);
|
await expect(loginPage.page).toHaveURL(/screen=auth-login$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { RegisterPage } from "../pages/RegisterPage";
|
|||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await RegisterPage.initWithLoggedOutUser(page);
|
await RegisterPage.initWithLoggedOutUser(page);
|
||||||
await page.goto("/#/auth/register");
|
await page.goto("/?screen=auth-register");
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Register form errors", () => {
|
test.describe("Register form errors", () => {
|
||||||
|
|||||||
@ -1936,6 +1936,9 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||||
|
|
||||||
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
|
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
|
||||||
|
// Same file as the outer setup: re-navigating would reload the
|
||||||
|
// app and wipe the fixture tokens this test clashes against.
|
||||||
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
flags: ["enable-token-shadow"],
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1975,6 +1978,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -1990,6 +1994,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2005,6 +2010,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2020,6 +2026,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2035,6 +2042,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2050,6 +2058,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2065,6 +2074,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2080,6 +2090,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2095,6 +2106,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2110,6 +2122,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2125,6 +2138,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2140,6 +2154,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2155,6 +2170,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
const { tokenThemesSetsSidebar, tokensSidebar, tokenContextMenuForToken } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2177,6 +2193,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar } =
|
const { tokenThemesSetsSidebar, tokensSidebar } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2186,6 +2203,9 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||||
|
|
||||||
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
|
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
|
||||||
|
// Same file as the outer setup: re-navigating would reload the
|
||||||
|
// app and wipe the fixture tokens this test clashes against.
|
||||||
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
flags: ["enable-token-shadow"],
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2249,6 +2269,7 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const { tokenThemesSetsSidebar, tokensSidebar } =
|
const { tokenThemesSetsSidebar, tokensSidebar } =
|
||||||
await setupTokensFileRender(page, {
|
await setupTokensFileRender(page, {
|
||||||
file: "workspace/get-file-tokens-all-types.json",
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(tokensSidebar).toBeVisible();
|
await expect(tokensSidebar).toBeVisible();
|
||||||
@ -2258,6 +2279,9 @@ test.describe("User can't create groups that clash with token names", () => {
|
|||||||
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" });
|
||||||
|
|
||||||
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
|
const { tokensUpdateCreateModal } = await setupTokensFileRender(page, {
|
||||||
|
// Same file as the outer setup: re-navigating would reload the
|
||||||
|
// app and wipe the fixture tokens this test clashes against.
|
||||||
|
file: "workspace/get-file-tokens-all-types.json",
|
||||||
flags: ["enable-token-shadow"],
|
flags: ["enable-token-shadow"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ test("User goes to an empty dashboard", async ({ page }) => {
|
|||||||
const dashboardPage = new DashboardPage(page);
|
const dashboardPage = new DashboardPage(page);
|
||||||
|
|
||||||
await dashboardPage.goToDashboard();
|
await dashboardPage.goToDashboard();
|
||||||
await expect(dashboardPage.page).toHaveURL(/dashboard/);
|
await expect(dashboardPage.page).toHaveURL(/screen=dashboard/);
|
||||||
|
|
||||||
await expect(dashboardPage.mainHeading).toBeVisible();
|
await expect(dashboardPage.mainHeading).toBeVisible();
|
||||||
await expect(dashboardPage.page).toHaveScreenshot();
|
await expect(dashboardPage.page).toHaveScreenshot();
|
||||||
|
|||||||
@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
|
|
||||||
const login = new LoginPage(page);
|
const login = new LoginPage(page);
|
||||||
await login.initWithLoggedOutUser();
|
await login.initWithLoggedOutUser();
|
||||||
await login.page.goto("/#/auth/login");
|
await login.page.goto("/?screen=auth-login");
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Login form", () => {
|
test.describe("Login form", () => {
|
||||||
@ -33,7 +33,7 @@ test.describe("Login form", () => {
|
|||||||
await login.clickLoginButton();
|
await login.clickLoginButton();
|
||||||
|
|
||||||
await expect(login.invalidCredentialsError).toBeVisible();
|
await expect(login.invalidCredentialsError).toBeVisible();
|
||||||
await expect(login.page).toHaveURL(/auth\/login$/);
|
await expect(login.page).toHaveURL(/screen=auth-login$/);
|
||||||
await expect(login.page).toHaveScreenshot();
|
await expect(login.page).toHaveScreenshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -100,7 +100,7 @@
|
|||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:team-permissions team-permissions}))
|
:team-permissions team-permissions}))
|
||||||
|
|
||||||
(def go-to-subscription-url (dm/str (u/join cf/public-uri "#/settings/subscriptions")))
|
(def go-to-subscription-url (dm/str cf/public-uri "?screen=settings-subscription"))
|
||||||
|
|
||||||
(def go-to-ac-url (build-admin-console-url ""))
|
(def go-to-ac-url (build-admin-console-url ""))
|
||||||
|
|
||||||
|
|||||||
@ -77,8 +77,7 @@
|
|||||||
(let [data (ex-data cause)]
|
(let [data (ex-data cause)]
|
||||||
(if (and (= :authorization (:type data))
|
(if (and (= :authorization (:type data))
|
||||||
(= :challenge-required (:code data)))
|
(= :challenge-required (:code data)))
|
||||||
(let [path (rt/get-current-path)
|
(let [href (->> (rt/get-current-href)
|
||||||
href (->> path
|
|
||||||
(js/encodeURIComponent)
|
(js/encodeURIComponent)
|
||||||
(str "/challenge.html?redirect="))]
|
(str "/challenge.html?redirect="))]
|
||||||
(rx/of (rt/nav-raw :href href)))
|
(rx/of (rt/nav-raw :href href)))
|
||||||
|
|||||||
@ -683,11 +683,9 @@
|
|||||||
|
|
||||||
(->> (rp/cmd! :get-team-invitation-token params)
|
(->> (rp/cmd! :get-team-invitation-token params)
|
||||||
(rx/map (fn [params]
|
(rx/map (fn [params]
|
||||||
(rt/resolve router :auth-verify-token params)))
|
(rt/resolve-uri router :auth-verify-token params)))
|
||||||
(rx/map (fn [fragment]
|
|
||||||
(assoc cf/public-uri :fragment fragment)))
|
|
||||||
(rx/tap (fn [uri]
|
(rx/tap (fn [uri]
|
||||||
(clipboard/to-clipboard (str uri))))
|
(clipboard/to-clipboard uri)))
|
||||||
(rx/tap on-success)
|
(rx/tap on-success)
|
||||||
(rx/ignore)
|
(rx/ignore)
|
||||||
(rx/catch on-error))))))
|
(rx/catch on-error))))))
|
||||||
|
|||||||
@ -18,26 +18,35 @@
|
|||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[goog.events :as e]
|
[goog.events :as e]
|
||||||
[potok.v2.core :as ptk]
|
[potok.v2.core :as ptk]))
|
||||||
[reitit.core :as r]))
|
|
||||||
|
|
||||||
;; --- Router API
|
;; --- Router API
|
||||||
|
|
||||||
(defn map->Match
|
;; Query-string routing: `router` is the set of enabled route names
|
||||||
[data]
|
;; (see `app.main.ui.routes/routes`). The `screen` query param carries
|
||||||
(r/map->Match data))
|
;; the route name; every other param travels as a plain query param.
|
||||||
|
|
||||||
(defn resolve
|
|
||||||
([router id]
|
|
||||||
(resolve router id {}))
|
|
||||||
([router id params]
|
|
||||||
(when router
|
|
||||||
(when-let [match (r/match-by-name router id)]
|
|
||||||
(r/match->path match params)))))
|
|
||||||
|
|
||||||
(defn create
|
(defn create
|
||||||
[routes]
|
[routes]
|
||||||
(r/router routes))
|
routes)
|
||||||
|
|
||||||
|
(defn resolve
|
||||||
|
"Build the history token (`?screen=<name>¶ms`) for a route.
|
||||||
|
Returns nil when the route id is not enabled."
|
||||||
|
([router id]
|
||||||
|
(resolve router id {}))
|
||||||
|
([router id params]
|
||||||
|
(when (contains? router id)
|
||||||
|
;; The target screen always wins: callers often forward the
|
||||||
|
;; current query params (which carry the previous screen).
|
||||||
|
(str "?" (u/map->query-string
|
||||||
|
(into {:screen (name id)} (dissoc params :screen)))))))
|
||||||
|
|
||||||
|
(defn resolve-uri
|
||||||
|
"Build the absolute URL string for a route under `cf/public-uri`."
|
||||||
|
[router id params]
|
||||||
|
(when-let [token (resolve router id params)]
|
||||||
|
(dm/str cf/public-uri "?" (subs token 1))))
|
||||||
|
|
||||||
(defn initialize-router
|
(defn initialize-router
|
||||||
[routes]
|
[routes]
|
||||||
@ -50,19 +59,6 @@
|
|||||||
[url]
|
[url]
|
||||||
(js/encodeURIComponent url))
|
(js/encodeURIComponent url))
|
||||||
|
|
||||||
(defn match
|
|
||||||
"Given routing tree and current path, return match with possibly
|
|
||||||
coerced parameters. Return nil if no match found."
|
|
||||||
[router path]
|
|
||||||
(let [uri (u/uri path)]
|
|
||||||
(when-let [match (r/match-by-path router (:path uri))]
|
|
||||||
(let [query-params (u/query-string->map (:query uri))
|
|
||||||
params {:path (:path-params match)
|
|
||||||
:query query-params}]
|
|
||||||
(-> match
|
|
||||||
(assoc :params params)
|
|
||||||
(assoc :query-params query-params))))))
|
|
||||||
|
|
||||||
;; --- Navigate (Event)
|
;; --- Navigate (Event)
|
||||||
|
|
||||||
(defn get-query-param
|
(defn get-query-param
|
||||||
@ -75,6 +71,22 @@
|
|||||||
(let [v (get params k)]
|
(let [v (get params k)]
|
||||||
(if (sequential? v) (peek v) v)))
|
(if (sequential? v) (peek v) v)))
|
||||||
|
|
||||||
|
(defn match
|
||||||
|
"Given the enabled routes and the current history token (the query
|
||||||
|
string, `?screen=<name>¶ms`), return a match shaped like the old
|
||||||
|
reitit one (`:data/:name`, `:params/:query`, `:query-params`).
|
||||||
|
Return nil when there is no usable `screen`."
|
||||||
|
[router token]
|
||||||
|
(let [query (if (str/starts-with? (or token "") "?")
|
||||||
|
(subs token 1)
|
||||||
|
(or token ""))
|
||||||
|
query-params (u/query-string->map query)
|
||||||
|
screen (some-> (get-query-param query-params :screen) keyword)]
|
||||||
|
(when (contains? router screen)
|
||||||
|
{:data {:name screen}
|
||||||
|
:params {:path {} :query query-params}
|
||||||
|
:query-params query-params})))
|
||||||
|
|
||||||
(defn navigated
|
(defn navigated
|
||||||
[match send-event-info?]
|
[match send-event-info?]
|
||||||
(ptk/reify ::navigated
|
(ptk/reify ::navigated
|
||||||
@ -95,30 +107,7 @@
|
|||||||
(update [_ state]
|
(update [_ state]
|
||||||
(-> state
|
(-> state
|
||||||
(assoc :route match)
|
(assoc :route match)
|
||||||
(dissoc :exception)))
|
(dissoc :exception)))))
|
||||||
|
|
||||||
ptk/EffectEvent
|
|
||||||
(effect [_ state _]
|
|
||||||
;; The route is read from the state the `update` above just stored:
|
|
||||||
;; the effect always runs after the update. The sharing-context ids
|
|
||||||
;; are synced into the pre-fragment query (the fragment never reaches
|
|
||||||
;; the server, so shared links need them there); every other param is
|
|
||||||
;; left untouched, except valueless ones (`?flag`), which the query
|
|
||||||
;; codec cannot round-trip and are dropped. The backend applies its
|
|
||||||
;; own file > project > team priority, so no filtering is needed
|
|
||||||
;; here.
|
|
||||||
(let [params (:query-params (:route state))
|
|
||||||
uri (u/uri (.-href globals/location))
|
|
||||||
search (reduce (fn [m k]
|
|
||||||
(let [v (get-query-param params k)]
|
|
||||||
(if (some? v)
|
|
||||||
(assoc m k v)
|
|
||||||
(dissoc m k))))
|
|
||||||
(u/query-string->map (:query uri))
|
|
||||||
[:file-id :team-id :project-id])
|
|
||||||
href (str (assoc uri :query (u/map->query-string search)))]
|
|
||||||
(when (not= href (.-href globals/location))
|
|
||||||
(.replaceState js/history nil "" href))))))
|
|
||||||
|
|
||||||
(defn navigate
|
(defn navigate
|
||||||
[id params & {:keys [::replace ::new-window] :as options}]
|
[id params & {:keys [::replace ::new-window] :as options}]
|
||||||
@ -137,7 +126,7 @@
|
|||||||
|
|
||||||
(if ^boolean new-window
|
(if ^boolean new-window
|
||||||
(let [name (or (::window-name options) "_blank")
|
(let [name (or (::window-name options) "_blank")
|
||||||
uri (assoc cf/public-uri :fragment path)]
|
uri (assoc cf/public-uri :query (some-> path (subs 1)))]
|
||||||
(dom/open-new-window uri name nil))
|
(dom/open-new-window uri name nil))
|
||||||
(ts/asap
|
(ts/asap
|
||||||
#(if ^boolean replace
|
#(if ^boolean replace
|
||||||
@ -215,37 +204,26 @@
|
|||||||
[]
|
[]
|
||||||
(.-href globals/location))
|
(.-href globals/location))
|
||||||
|
|
||||||
(defn get-current-path
|
|
||||||
[]
|
|
||||||
(let [hash (.-hash globals/location)]
|
|
||||||
(if (str/starts-with? hash "#")
|
|
||||||
(subs hash 1)
|
|
||||||
hash)))
|
|
||||||
|
|
||||||
|
|
||||||
;; --- History API
|
;; --- History API
|
||||||
|
|
||||||
;; Check the urls to see if we need to send the navigated event.
|
;; Check the urls to see if we need to send the navigated event.
|
||||||
;; If two paths are the same we only send the event when there is a
|
;; If two query strings select the same screen we only send the event
|
||||||
;; change in the parameters `file-id`, `page-id` or `team-id`
|
;; when there is a change in the parameters `screen`, `file-id`,
|
||||||
|
;; `page-id` or `team-id`
|
||||||
(defn- send-event-info?
|
(defn- send-event-info?
|
||||||
[old-url new-url]
|
[old-url new-url]
|
||||||
(let [params [:file-id :page-id :team-id]
|
(let [params [:screen :file-id :page-id :team-id]
|
||||||
new-uri (u/uri new-url)
|
new-params (-> (u/uri new-url) :query u/query-string->map (select-keys params))
|
||||||
new-path (:path new-uri)
|
old-params (-> (u/uri old-url) :query u/query-string->map (select-keys params))]
|
||||||
new-params (-> new-uri :query u/query-string->map (select-keys params))
|
(not= new-params old-params)))
|
||||||
old-uri (u/uri old-url)
|
|
||||||
old-path (:path old-uri)
|
|
||||||
old-params (-> old-uri :query u/query-string->map (select-keys params))]
|
|
||||||
(or (not= old-path new-path)
|
|
||||||
(not= new-params old-params))))
|
|
||||||
|
|
||||||
(defn initialize-history
|
(defn initialize-history
|
||||||
[on-change]
|
[on-change]
|
||||||
(ptk/reify ::initialize-history
|
(ptk/reify ::initialize-history
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [history (bhistory/create)]
|
(let [history (bhistory/create (:path cf/public-uri))]
|
||||||
(bhistory/enable! history)
|
(bhistory/enable! history)
|
||||||
(assoc state :history history)))
|
(assoc state :history history)))
|
||||||
|
|
||||||
|
|||||||
@ -47,7 +47,7 @@
|
|||||||
:auth-section true
|
:auth-section true
|
||||||
:register is-register)}
|
:register is-register)}
|
||||||
[:> heading* {:level 1 :typography "title-large" :class (stl/css :logo-container)}
|
[:> heading* {:level 1 :typography "title-large" :class (stl/css :logo-container)}
|
||||||
[:a {:href "#/" :title "Penpot" :class (stl/css :logo-btn)}
|
[:a {:href "/" :title "Penpot" :class (stl/css :logo-btn)}
|
||||||
[:> raw-svg* {:id raw-svg/penpot-logo
|
[:> raw-svg* {:id raw-svg/penpot-logo
|
||||||
:class (stl/css :logo)}]]]
|
:class (stl/css :logo)}]]]
|
||||||
[:div {:class (stl/css :login-illustration)}
|
[:div {:class (stl/css :login-illustration)}
|
||||||
|
|||||||
@ -20,7 +20,6 @@
|
|||||||
[app.util.i18n :as i18n :refer [tr]]
|
[app.util.i18n :as i18n :refer [tr]]
|
||||||
[app.util.keyboard :as kbd]
|
[app.util.keyboard :as kbd]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[lambdaisland.uri :as u]
|
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(defn get-subscription-type
|
(defn get-subscription-type
|
||||||
@ -77,7 +76,7 @@
|
|||||||
(let [subscription (:subscription (:props profile))
|
(let [subscription (:subscription (:props profile))
|
||||||
subscription-type (get-subscription-type subscription)
|
subscription-type (get-subscription-type subscription)
|
||||||
subscription-is-trial (= (:status subscription) "trialing")
|
subscription-is-trial (= (:status subscription) "trialing")
|
||||||
subscription-href (dm/str (u/join cf/public-uri "#/settings/subscriptions"))]
|
subscription-href (dm/str cf/public-uri "?screen=settings-subscription")]
|
||||||
|
|
||||||
(case subscription-type
|
(case subscription-type
|
||||||
"professional"
|
"professional"
|
||||||
@ -341,7 +340,7 @@
|
|||||||
[{:keys [profile]}]
|
[{:keys [profile]}]
|
||||||
(let [subscription (-> profile :props :subscription)
|
(let [subscription (-> profile :props :subscription)
|
||||||
subscription-type (get-subscription-type subscription)
|
subscription-type (get-subscription-type subscription)
|
||||||
go-to-subscription (dm/str (u/join cf/public-uri "#/settings/subscriptions"))
|
go-to-subscription (dm/str cf/public-uri "?screen=settings-subscription")
|
||||||
seats (:quantity subscription)
|
seats (:quantity subscription)
|
||||||
editors (count (:editors subscription))
|
editors (count (:editors subscription))
|
||||||
cta-title
|
cta-title
|
||||||
|
|||||||
@ -20,7 +20,8 @@
|
|||||||
[app.util.storage :as storage]
|
[app.util.storage :as storage]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]
|
||||||
|
[reitit.core :as r]))
|
||||||
|
|
||||||
(def ^:private sso-authorization-max-age-ms
|
(def ^:private sso-authorization-max-age-ms
|
||||||
(* 5 60 1000))
|
(* 5 60 1000))
|
||||||
@ -29,6 +30,52 @@
|
|||||||
(atom {}))
|
(atom {}))
|
||||||
|
|
||||||
(def routes
|
(def routes
|
||||||
|
"Enabled route names. Query-string routing: the `screen` query param
|
||||||
|
carries the route name (`?screen=<name>¶ms`); it is router-owned
|
||||||
|
and reserved, every other param travels as a plain query param."
|
||||||
|
(into #{:auth-login
|
||||||
|
:auth-register
|
||||||
|
:auth-register-validate
|
||||||
|
:auth-register-success
|
||||||
|
:auth-recovery-request
|
||||||
|
:auth-recovery
|
||||||
|
:auth-verify-token
|
||||||
|
:settings-profile
|
||||||
|
:settings-password
|
||||||
|
:settings-feedback
|
||||||
|
:settings-options
|
||||||
|
:settings-subscription
|
||||||
|
:settings-integrations
|
||||||
|
:settings-notifications
|
||||||
|
:settings-shortcuts
|
||||||
|
:frame-preview
|
||||||
|
:viewer
|
||||||
|
:render-sprite
|
||||||
|
:dashboard-members
|
||||||
|
:dashboard-invitations
|
||||||
|
:dashboard-webhooks
|
||||||
|
:dashboard-settings
|
||||||
|
:dashboard-recent
|
||||||
|
:dashboard-search
|
||||||
|
:dashboard-fonts
|
||||||
|
:dashboard-font-providers
|
||||||
|
:dashboard-libraries
|
||||||
|
:dashboard-files
|
||||||
|
:dashboard-deleted
|
||||||
|
:workspace}
|
||||||
|
(concat
|
||||||
|
(when (contains? cf/flags :admin-console)
|
||||||
|
[:nitrate-entry])
|
||||||
|
(when *assert*
|
||||||
|
[:debug-icons-preview
|
||||||
|
:debug-playground]))))
|
||||||
|
|
||||||
|
;; TODO(next-version): delete the legacy hash table, `legacy-match`
|
||||||
|
;; and the hash branch of `on-navigate` below. Legacy `#/…` URLs stop
|
||||||
|
;; resolving after one Penpot version of compatibility.
|
||||||
|
(def ^:private legacy-routes
|
||||||
|
"Pre-query-string route table, kept only to translate legacy
|
||||||
|
`#/…` hash URLs during the compatibility window."
|
||||||
[["/auth"
|
[["/auth"
|
||||||
["/login" :auth-login]
|
["/login" :auth-login]
|
||||||
["/register" :auth-register]
|
["/register" :auth-register]
|
||||||
@ -79,6 +126,19 @@
|
|||||||
|
|
||||||
["/workspace" :workspace]])
|
["/workspace" :workspace]])
|
||||||
|
|
||||||
|
(defonce ^:private legacy-router
|
||||||
|
(r/router legacy-routes))
|
||||||
|
|
||||||
|
(defn- legacy-match
|
||||||
|
"Match a legacy hash path (`/workspace?...`, without the `#`) against
|
||||||
|
the pre-query-string table. Returns `{:name params}` or nil."
|
||||||
|
[hash-path]
|
||||||
|
(let [uri (u/uri hash-path)]
|
||||||
|
(when-let [match (r/match-by-path legacy-router (:path uri))]
|
||||||
|
{:name (get-in match [:data :name])
|
||||||
|
:params (merge (:path-params match)
|
||||||
|
(u/query-string->map (:query uri)))})))
|
||||||
|
|
||||||
|
|
||||||
(defn- store-session-params
|
(defn- store-session-params
|
||||||
[{:keys [template plugin]}]
|
[{:keys [template plugin]}]
|
||||||
@ -157,24 +217,45 @@
|
|||||||
:is-dashboard is-dashboard?}))
|
:is-dashboard is-dashboard?}))
|
||||||
(check-sso-and-navigate match send-event-info? url))))
|
(check-sso-and-navigate match send-event-info? url))))
|
||||||
|
|
||||||
|
(declare on-query-navigate)
|
||||||
|
|
||||||
(defn on-navigate
|
(defn on-navigate
|
||||||
[router path send-event-info?]
|
"Query-string routing entry point. `token` is the history token (the
|
||||||
|
query string, `?screen=<name>¶ms`, or empty on bootstrap)."
|
||||||
|
[router token send-event-info?]
|
||||||
(let [location (.-location js/document)
|
(let [location (.-location js/document)
|
||||||
[base-path qs] (str/split path "?")
|
|
||||||
location-path (dm/str (.-origin location) (.-pathname location))
|
location-path (dm/str (.-origin location) (.-pathname location))
|
||||||
valid-location? (= location-path (dm/str cf/public-uri))
|
valid-location? (= location-path (dm/str cf/public-uri))
|
||||||
match (rt/match router path)
|
legacy-hash (.-hash location)]
|
||||||
empty-path? (or (= base-path "") (= base-path "/"))
|
|
||||||
query-params (u/query-string->map qs)]
|
|
||||||
|
|
||||||
(cond
|
(cond
|
||||||
(not valid-location?)
|
(not valid-location?)
|
||||||
(st/emit! (rt/assign-exception {:type :not-found}))
|
(st/emit! (rt/assign-exception {:type :not-found}))
|
||||||
|
|
||||||
(some? match)
|
;; TODO(next-version): delete with `legacy-routes`. Legacy `#/…`
|
||||||
(handle-sso-error-and-navigate match send-event-info? (rt/get-current-href))
|
;; URLs translate to the query format once (replace, no extra
|
||||||
|
;; history entry); the fragment never reaches the server, so this
|
||||||
|
;; can only run client-side. Untranslatable hashes fall through
|
||||||
|
;; to the normal query flow below.
|
||||||
|
(str/starts-with? legacy-hash "#/")
|
||||||
|
(if-let [{:keys [name params]} (legacy-match (subs legacy-hash 1))]
|
||||||
|
(st/emit! (rt/nav name params {::rt/replace true}))
|
||||||
|
(on-query-navigate router token send-event-info?))
|
||||||
|
|
||||||
:else
|
:else
|
||||||
|
(on-query-navigate router token send-event-info?))))
|
||||||
|
|
||||||
|
(defn- on-query-navigate
|
||||||
|
[router token send-event-info?]
|
||||||
|
(let [token-query (if (str/starts-with? (or token "") "?")
|
||||||
|
(subs token 1)
|
||||||
|
(or token ""))
|
||||||
|
query-params (u/query-string->map token-query)
|
||||||
|
empty-token? (str/blank? token-query)
|
||||||
|
match (rt/match router token)]
|
||||||
|
(if (some? match)
|
||||||
|
(handle-sso-error-and-navigate match send-event-info? (rt/get-current-href))
|
||||||
|
|
||||||
;; We just recheck with an additional profile request; this
|
;; We just recheck with an additional profile request; this
|
||||||
;; avoids some race conditions that causes unexpected redirects
|
;; avoids some race conditions that causes unexpected redirects
|
||||||
;; on invitations workflows (and probably other cases).
|
;; on invitations workflows (and probably other cases).
|
||||||
@ -190,7 +271,7 @@
|
|||||||
(store-session-params query-params)
|
(store-session-params query-params)
|
||||||
(st/emit! (rt/nav :auth-login)))
|
(st/emit! (rt/nav :auth-login)))
|
||||||
|
|
||||||
empty-path?
|
empty-token?
|
||||||
(let [default-team-id (:default-team-id profile)
|
(let [default-team-id (:default-team-id profile)
|
||||||
last-team-id (dtm/get-last-team-id)
|
last-team-id (dtm/get-last-team-id)
|
||||||
team-id (if (contains? teams last-team-id)
|
team-id (if (contains? teams last-team-id)
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.logging :as log]
|
[app.common.logging :as log]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cf]
|
|
||||||
[app.main.data.common :as dc]
|
[app.main.data.common :as dc]
|
||||||
[app.main.data.event :as ev]
|
[app.main.data.event :as ev]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
@ -78,8 +77,8 @@
|
|||||||
(dissoc params :zoom)
|
(dissoc params :zoom)
|
||||||
(assoc params :zoom zoom-type))
|
(assoc params :zoom zoom-type))
|
||||||
|
|
||||||
href (rt/resolve router :viewer params)]
|
href (rt/resolve-uri router :viewer params)]
|
||||||
(dm/str (assoc cf/public-uri :fragment href))))))
|
href))))
|
||||||
|
|
||||||
on-close
|
on-close
|
||||||
(fn [event]
|
(fn [event]
|
||||||
|
|||||||
@ -79,7 +79,7 @@
|
|||||||
[{:keys [is-open is-local file-id page-id file-name]}]
|
[{:keys [is-open is-local file-id page-id file-name]}]
|
||||||
(let [router (mf/deref refs/router)
|
(let [router (mf/deref refs/router)
|
||||||
team-id (mf/use-ctx ctx/current-team-id)
|
team-id (mf/use-ctx ctx/current-team-id)
|
||||||
url (rt/resolve router :workspace
|
url (rt/resolve-uri router :workspace
|
||||||
{:team-id team-id
|
{:team-id team-id
|
||||||
:file-id file-id
|
:file-id file-id
|
||||||
:page-id page-id})
|
:page-id page-id})
|
||||||
@ -110,7 +110,7 @@
|
|||||||
(when-not ^boolean is-local
|
(when-not ^boolean is-local
|
||||||
[:span {:title (tr "workspace.assets.open-library")}
|
[:span {:title (tr "workspace.assets.open-library")}
|
||||||
[:a {:class (stl/css :file-link)
|
[:a {:class (stl/css :file-link)
|
||||||
:href (str "#" url)
|
:href url
|
||||||
:target "_blank"
|
:target "_blank"
|
||||||
:on-click on-click}
|
:on-click on-click}
|
||||||
deprecated-icon/open-link]])]]))
|
deprecated-icon/open-link]])]]))
|
||||||
|
|||||||
@ -29,7 +29,6 @@
|
|||||||
[app.util.i18n :as i18n :refer [tr]]
|
[app.util.i18n :as i18n :refer [tr]]
|
||||||
[app.util.keyboard :as kbd]
|
[app.util.keyboard :as kbd]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[lambdaisland.uri :as u]
|
|
||||||
[okulary.core :as l]
|
[okulary.core :as l]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
@ -55,7 +54,7 @@
|
|||||||
is-owner? (-> team :permissions :is-owner)
|
is-owner? (-> team :permissions :is-owner)
|
||||||
email-owner (:email (some #(when (:is-owner %) %) (:members team)))
|
email-owner (:email (some #(when (:is-owner %) %) (:members team)))
|
||||||
support-email "support@penpot.app"
|
support-email "support@penpot.app"
|
||||||
go-to-subscription (dm/str (u/join cfg/public-uri "#/settings/subscriptions"))]
|
go-to-subscription (dm/str cfg/public-uri "?screen=settings-subscription")]
|
||||||
|
|
||||||
(if (contains? cfg/flags :subscriptions)
|
(if (contains? cfg/flags :subscriptions)
|
||||||
(if is-owner?
|
(if is-owner?
|
||||||
|
|||||||
@ -16,8 +16,10 @@ goog.scope(function() {
|
|||||||
const Html5History = goog.history.Html5History;
|
const Html5History = goog.history.Html5History;
|
||||||
|
|
||||||
class TokenTransformer {
|
class TokenTransformer {
|
||||||
|
// Query-string routing: the history token is the query string
|
||||||
|
// ("?screen=...&..."), the path always stays the application base.
|
||||||
retrieveToken(pathPrefix, location) {
|
retrieveToken(pathPrefix, location) {
|
||||||
return location.pathname.substr(pathPrefix.length) + location.search;
|
return location.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
createUrl(token, pathPrefix, location) {
|
createUrl(token, pathPrefix, location) {
|
||||||
@ -25,9 +27,10 @@ goog.scope(function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.create = function() {
|
self.create = function(pathPrefix) {
|
||||||
const instance = new Html5History(null, new TokenTransformer());
|
const instance = new Html5History(null, new TokenTransformer());
|
||||||
instance.setUseFragment(true);
|
instance.setUseFragment(false);
|
||||||
|
instance.setPathPrefix(pathPrefix);
|
||||||
return instance;
|
return instance;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -7,115 +7,39 @@
|
|||||||
(ns frontend-tests.router-test
|
(ns frontend-tests.router-test
|
||||||
(:require
|
(:require
|
||||||
[app.main.router :as rt]
|
[app.main.router :as rt]
|
||||||
[app.util.globals :as globals]
|
[cljs.test :as t :include-macros true]))
|
||||||
[cljs.test :as t :include-macros true]
|
|
||||||
[potok.v2.core :as ptk]))
|
|
||||||
|
|
||||||
(defn- with-stubbed-href
|
(def ^:private test-routes
|
||||||
"Run `thunk` with the `globals/location` href replaced and a recording
|
#{:auth-login :auth-register :dashboard-recent :workspace})
|
||||||
`js/history.replaceState`. Only the href is stubbed: the effect parses
|
|
||||||
everything it needs out of it."
|
|
||||||
[href replace-calls thunk]
|
|
||||||
(let [loc globals/location
|
|
||||||
old-href (.-href loc)
|
|
||||||
old-history (.-history js/globalThis)]
|
|
||||||
(set! (.-href loc) href)
|
|
||||||
(set! (.-history js/globalThis)
|
|
||||||
#js {:replaceState (fn [_ _ url] (swap! replace-calls conj url))})
|
|
||||||
(try
|
|
||||||
(thunk)
|
|
||||||
(finally
|
|
||||||
(set! (.-href loc) old-href)
|
|
||||||
(set! (.-history js/globalThis) old-history)))))
|
|
||||||
|
|
||||||
(defn- emit-navigated
|
(t/deftest resolve-target-screen-wins-over-forwarded-params
|
||||||
"Run the `navigated` effect with `match` stored as the state route.
|
;; Screens forward the current query params when navigating (e.g.
|
||||||
The closed-over match is deliberately empty to prove the effect reads
|
;; login passes its params to the register link); the stale screen
|
||||||
the route from the state, not from the closure."
|
;; they carry must never override the destination.
|
||||||
[match]
|
(t/is (= "?screen=auth-register&foo=1"
|
||||||
(ptk/effect (rt/navigated {} false) {:route match} nil))
|
(rt/resolve test-routes :auth-register {:screen "auth-login"
|
||||||
|
:foo "1"}))))
|
||||||
|
|
||||||
(t/deftest navigated-mirrors-context-on-change
|
(t/deftest resolve-builds-screen-token
|
||||||
;; New context in the state route triggers exactly one mirrored write.
|
(t/is (= "?screen=dashboard-recent&team-id=team-1"
|
||||||
(let [calls (atom [])]
|
(rt/resolve test-routes :dashboard-recent {:team-id "team-1"})))
|
||||||
(with-stubbed-href
|
(t/is (= "?screen=auth-login"
|
||||||
"http://localhost/#/workspace?file-id=file-1"
|
(rt/resolve test-routes :auth-login)))
|
||||||
calls
|
(t/is (nil? (rt/resolve test-routes :unknown-screen {:team-id "team-1"}))))
|
||||||
(fn []
|
|
||||||
(emit-navigated {:query-params {:file-id "file-1"}})
|
|
||||||
(t/is (= ["http://localhost/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
|
||||||
|
|
||||||
(t/deftest navigated-skips-write-when-mirrored
|
(t/deftest resolve-uri-builds-absolute-url
|
||||||
;; When the URL already carries the mirrored context, nothing is written.
|
(let [uri (rt/resolve-uri test-routes :workspace {:file-id "file-1"})]
|
||||||
(let [calls (atom [])]
|
(t/is (string? uri))
|
||||||
(with-stubbed-href
|
(t/is (re-find #"\?screen=workspace&file-id=file-1$" uri))))
|
||||||
"http://localhost/?file-id=file-1#/workspace?file-id=file-1"
|
|
||||||
calls
|
|
||||||
(fn []
|
|
||||||
(emit-navigated {:query-params {:file-id "file-1"}})
|
|
||||||
(t/is (= [] @calls))))))
|
|
||||||
|
|
||||||
(t/deftest navigated-strips-stale-context
|
(t/deftest match-resolves-screen-token
|
||||||
;; A stale pre-fragment query is replaced with the current context.
|
(let [match (rt/match test-routes "?screen=workspace&team-id=team-1&file-id=file-1")]
|
||||||
(let [calls (atom [])]
|
(t/is (= :workspace (get-in match [:data :name])))
|
||||||
(with-stubbed-href
|
(t/is (= "team-1" (get-in match [:params :query :team-id])))
|
||||||
"http://localhost/?file-id=old#/dashboard/recent?team-id=team-1"
|
(t/is (= "file-1" (get-in match [:query-params :file-id])))
|
||||||
calls
|
(t/is (= {} (get-in match [:params :path])))))
|
||||||
(fn []
|
|
||||||
(emit-navigated {:query-params {:team-id "team-1"}})
|
|
||||||
(t/is (= ["http://localhost/?team-id=team-1#/dashboard/recent?team-id=team-1"] @calls))))))
|
|
||||||
|
|
||||||
(t/deftest navigated-clears-query-without-context
|
(t/deftest match-rejects-missing-or-unknown-screen
|
||||||
;; Routes without context clear a stale pre-fragment query.
|
(t/is (nil? (rt/match test-routes "")))
|
||||||
(let [calls (atom [])]
|
(t/is (nil? (rt/match test-routes "?team-id=team-1")))
|
||||||
(with-stubbed-href
|
(t/is (nil? (rt/match test-routes "?screen=nope&team-id=team-1"))))
|
||||||
"http://localhost/?file-id=old#/auth/login"
|
|
||||||
calls
|
|
||||||
(fn []
|
|
||||||
(emit-navigated {:query-params {:token "some-token"}})
|
|
||||||
(t/is (= ["http://localhost/#/auth/login"] @calls))))))
|
|
||||||
|
|
||||||
(t/deftest navigated-preserves-unrelated-params
|
|
||||||
;; Params owned by other code are kept as they are.
|
|
||||||
(let [calls (atom [])]
|
|
||||||
(with-stubbed-href
|
|
||||||
"http://localhost/?debug=1&file-id=old#/workspace?file-id=file-1"
|
|
||||||
calls
|
|
||||||
(fn []
|
|
||||||
(emit-navigated {:query-params {:file-id "file-1"}})
|
|
||||||
(t/is (= ["http://localhost/?debug=1&file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
|
||||||
|
|
||||||
(t/deftest navigated-mirrors-every-present-context-id
|
|
||||||
;; Every present context id is mirrored; the backend applies its own
|
|
||||||
;; file > project > team priority, so no filtering happens here.
|
|
||||||
(let [calls (atom [])]
|
|
||||||
(with-stubbed-href
|
|
||||||
"http://localhost/#/workspace?file-id=file-1&team-id=team-1&project-id=project-1&page-id=page-1"
|
|
||||||
calls
|
|
||||||
(fn []
|
|
||||||
(emit-navigated {:query-params {:file-id "file-1"
|
|
||||||
:team-id "team-1"
|
|
||||||
:project-id "project-1"
|
|
||||||
:page-id "page-1"}})
|
|
||||||
(t/is (= ["http://localhost/?file-id=file-1&team-id=team-1&project-id=project-1#/workspace?file-id=file-1&team-id=team-1&project-id=project-1&page-id=page-1"]
|
|
||||||
@calls))))))
|
|
||||||
|
|
||||||
(t/deftest navigated-repeated-key-last-wins
|
|
||||||
;; A repeated query key arrives as a vector; the last value wins.
|
|
||||||
(let [calls (atom [])]
|
|
||||||
(with-stubbed-href
|
|
||||||
"http://localhost/#/workspace?file-id=file-1"
|
|
||||||
calls
|
|
||||||
(fn []
|
|
||||||
(emit-navigated {:query-params {:file-id ["file-old" "file-1"]}})
|
|
||||||
(t/is (= ["http://localhost/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
|
||||||
|
|
||||||
(t/deftest navigated-keeps-subpath-base
|
|
||||||
;; Under a subpath deployment the prefix survives untouched.
|
|
||||||
(let [calls (atom [])]
|
|
||||||
(with-stubbed-href
|
|
||||||
"http://localhost/penpot/#/workspace?file-id=file-1"
|
|
||||||
calls
|
|
||||||
(fn []
|
|
||||||
(emit-navigated {:query-params {:file-id "file-1"}})
|
|
||||||
(t/is (= ["http://localhost/penpot/?file-id=file-1#/workspace?file-id=file-1"] @calls))))))
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import puppeteer, { ConsoleMessage } from 'puppeteer';
|
import puppeteer, { ConsoleMessage } from 'puppeteer';
|
||||||
import { PenpotApi } from './api';
|
import { PenpotApi } from './api';
|
||||||
import { getFileUrl } from './get-file-url';
|
import { getFileUrl } from './get-file-url';
|
||||||
import { idObjectToArray } from './clean-id';
|
import { cleanId, idObjectToArray } from './clean-id';
|
||||||
import { Shape } from '../models/shape.model';
|
import { Shape } from '../models/shape.model';
|
||||||
|
|
||||||
const screenshotsEnable = process.env['E2E_SCREENSHOTS'] === 'true';
|
const screenshotsEnable = process.env['E2E_SCREENSHOTS'] === 'true';
|
||||||
@ -52,7 +52,8 @@ export async function Agent() {
|
|||||||
const file = await penpotApi.createFile();
|
const file = await penpotApi.createFile();
|
||||||
console.log('File created with id:', file['~:id']);
|
console.log('File created with id:', file['~:id']);
|
||||||
|
|
||||||
const fileUrl = getFileUrl(file);
|
const project = await penpotApi.getProject(cleanId(file['~:project-id']));
|
||||||
|
const fileUrl = getFileUrl(file, cleanId(project['~:team-id']));
|
||||||
console.log('File URL:', fileUrl);
|
console.log('File URL:', fileUrl);
|
||||||
|
|
||||||
console.log('Launching browser...');
|
console.log('Launching browser...');
|
||||||
|
|||||||
@ -71,6 +71,20 @@ export async function PenpotApi() {
|
|||||||
console.log('File data received:', fileData);
|
console.log('File data received:', fileData);
|
||||||
return fileData;
|
return fileData;
|
||||||
},
|
},
|
||||||
|
getProject: async (projectId: string) => {
|
||||||
|
const getProjectRequest = await fetch(
|
||||||
|
`${apiUrl}/api/main/methods/get-project?id=${projectId}`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
cookie: authToken,
|
||||||
|
credentials: 'include',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (await getProjectRequest.json()) as Record<string, string>;
|
||||||
|
},
|
||||||
deleteFile: async (fileId: string) => {
|
deleteFile: async (fileId: string) => {
|
||||||
const deleteFileRequest = await fetch(
|
const deleteFileRequest = await fetch(
|
||||||
`${apiUrl}/api/main/methods/delete-file`,
|
`${apiUrl}/api/main/methods/delete-file`,
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { FileRpc } from '../models/file-rpc.model';
|
import { FileRpc } from '../models/file-rpc.model';
|
||||||
import { cleanId } from './clean-id';
|
import { cleanId } from './clean-id';
|
||||||
|
|
||||||
export function getFileUrl(file: FileRpc) {
|
export function getFileUrl(file: FileRpc, teamId: string) {
|
||||||
const projectId = cleanId(file['~:project-id']);
|
|
||||||
const fileId = cleanId(file['~:id']);
|
const fileId = cleanId(file['~:id']);
|
||||||
const pageId = cleanId(file['~:data']['~:pages'][0]);
|
const pageId = cleanId(file['~:data']['~:pages'][0]);
|
||||||
|
|
||||||
return `https://localhost:3449/#/workspace/${projectId}/${fileId}?page-id=${pageId}`;
|
return `https://localhost:3449/?screen=workspace&team-id=${teamId}&file-id=${fileId}&page-id=${pageId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user