Compare commits
83 Commits
eva-fix-sh
...
elenatorro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4baa894ee4 | ||
|
|
64b892f82d | ||
|
|
04185b3544 | ||
|
|
0a01fc8af9 | ||
|
|
ae624b3728 | ||
|
|
a48b719966 | ||
|
|
6425c0cb7d | ||
|
|
368f4cfe81 | ||
|
|
fdffa14d75 | ||
|
|
7fe965a870 | ||
|
|
127fa931c7 | ||
|
|
30413dbc66 | ||
|
|
2810ae681f | ||
|
|
d706bb7c8d | ||
|
|
ef271db879 | ||
|
|
ec5e814a72 | ||
|
|
c44fd2dd1d | ||
|
|
6aa797f51b | ||
|
|
3cc54fd988 | ||
|
|
2233f34a15 | ||
|
|
839bb470df | ||
|
|
450ce869ba | ||
|
|
665587d492 | ||
|
|
8aaa953604 | ||
|
|
a2cb84ba0d | ||
|
|
639952abc8 | ||
|
|
2d63730bfa | ||
|
|
c1638817b2 | ||
|
|
76f6f71e02 | ||
|
|
0a700864c9 | ||
|
|
04ce4c3233 | ||
|
|
befcca86df | ||
|
|
b7bae3850b | ||
|
|
3f05dae455 | ||
|
|
4a887840c6 | ||
|
|
10cf2c7f35 | ||
|
|
d048a251f1 | ||
|
|
0b3fc6a663 | ||
|
|
363b4e3778 | ||
|
|
f248ab5644 | ||
|
|
33da6fbec2 | ||
|
|
07bede8ba2 | ||
|
|
05bea14a88 | ||
|
|
718f42aa94 | ||
|
|
f2f8a488ad | ||
|
|
7594f1883b | ||
|
|
5c2dde7308 | ||
|
|
483a1bd703 | ||
|
|
e1a275c7a9 | ||
|
|
96d9724516 | ||
|
|
8158f2956f | ||
|
|
e45994e836 | ||
|
|
83da59e03c | ||
|
|
fb21a98b0c | ||
|
|
23baf6d18b | ||
|
|
28cf67e7ff | ||
|
|
1b50c13c4d | ||
|
|
7de95e108b | ||
|
|
c6b907d05c | ||
|
|
ffb4d6a890 | ||
|
|
fa25307c05 | ||
|
|
43a136a9e9 | ||
|
|
3ec4c96b48 | ||
|
|
2eaeb8e9a5 | ||
|
|
604f6ca024 | ||
|
|
e3cf70d3a8 | ||
|
|
6aedac35f2 | ||
|
|
a11b0f54d7 | ||
|
|
ec0dc2931c | ||
|
|
9d65d11c91 | ||
|
|
f00fd1d5a8 | ||
|
|
d796dbb572 | ||
|
|
e979476b0e | ||
|
|
097897d8da | ||
|
|
ba092f03e1 | ||
|
|
61202e1cab | ||
|
|
f496ba78f3 | ||
|
|
b9a0c6d932 | ||
|
|
a59ce2ed16 | ||
|
|
c221b9366f | ||
|
|
02a1992a0a | ||
|
|
7d5c1c9b5f | ||
|
|
cd53d3659c |
55
.github/workflows/release.yml
vendored
@@ -37,36 +37,43 @@ jobs:
|
||||
ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
|
||||
# --- Publicly release the docker images ---
|
||||
- name: Login to private registry
|
||||
uses: docker/login-action@v3
|
||||
- name: Configure ECR credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
aws-access-key-id: ${{ secrets.DOCKER_USERNAME }}
|
||||
aws-secret-access-key: ${{ secrets.DOCKER_PASSWORD }}
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
|
||||
- name: Publish docker images to DockerHub
|
||||
env:
|
||||
TAG: ${{ steps.vars.outputs.gh_ref }}
|
||||
REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
|
||||
HUB: ${{ secrets.PUB_DOCKER_HUB }}
|
||||
- name: Install Skopeo
|
||||
run: |
|
||||
IMAGES=("frontend" "backend" "exporter")
|
||||
EXTRA_TAGS=("main" "latest")
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y skopeo
|
||||
|
||||
- name: Copy images from AWS ECR to Docker Hub
|
||||
env:
|
||||
AWS_REGION: ${{ secrets.AWS_REGION }}
|
||||
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
|
||||
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
|
||||
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
|
||||
TAG: ${{ steps.vars.outputs.gh_ref }}
|
||||
run: |
|
||||
aws ecr get-login-password --region $AWS_REGION | \
|
||||
skopeo login --username AWS --password-stdin \
|
||||
$DOCKER_REGISTRY
|
||||
|
||||
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
|
||||
|
||||
IMAGES=("frontend" "backend" "exporter" "storybook")
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
docker pull "$REGISTRY/$image:$TAG"
|
||||
docker tag "$REGISTRY/$image:$TAG" "penpotapp/$image:$TAG"
|
||||
docker push "penpotapp/$image:$TAG"
|
||||
skopeo copy --all \
|
||||
docker://$DOCKER_REGISTRY/$image:$TAG \
|
||||
docker://docker.io/penpotapp/$image:$TAG
|
||||
|
||||
for tag in "${EXTRA_TAGS[@]}"; do
|
||||
docker tag "$REGISTRY/$image:$TAG" "penpotapp/$image:$tag"
|
||||
docker push "penpotapp/$image:$tag"
|
||||
for alias in main latest; do
|
||||
skopeo copy --all \
|
||||
docker://$DOCKER_REGISTRY/$image:$TAG \
|
||||
docker://docker.io/penpotapp/$image:$alias
|
||||
done
|
||||
done
|
||||
|
||||
|
||||
58
CHANGES.md
@@ -4,12 +4,56 @@
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
#### Backend RPC API changes
|
||||
|
||||
The backend RPC API URLS are changed from `/api/rpc/command/<name>` to
|
||||
`/api/main/methods/<name>` (the previou PATH is preserved for backward
|
||||
compatibility; however, if you are a user of this API, it is strongly
|
||||
recommended that you adapt your code to use the new PATH.
|
||||
|
||||
|
||||
#### Updated SSO Callback URL
|
||||
|
||||
The OAuth / Single Sign-On (SSO) callback endpoint has changed to
|
||||
align with the new OpenID Connect (OIDC) implementation.
|
||||
|
||||
Old callback URL:
|
||||
|
||||
```
|
||||
https://<your_domain>/api/auth/oauth/<oauth_provider>/callback
|
||||
```
|
||||
|
||||
New callback URL:
|
||||
|
||||
```
|
||||
https://<your_domain>/api/auth/oidc/callback
|
||||
```
|
||||
|
||||
**Action required:**
|
||||
|
||||
If you have SSO/Social-Auth configured on your on-premise instance,
|
||||
the following actions are required before update:
|
||||
|
||||
Update your OAuth or SSO provider configuration (e.g., Okta, Google,
|
||||
Azure AD, etc.) to use the new callback URL. Failure to update may
|
||||
result in authentication failures after upgrading.
|
||||
|
||||
**Reason for change:**
|
||||
|
||||
This update standardizes all authentication flows under the single URL
|
||||
and makis it more modular, enabling the ability to configure SSO auth
|
||||
provider dinamically.
|
||||
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- Select boards to export as PDF [Taiga #12320](https://tree.taiga.io/project/penpot/issue/12320)
|
||||
- Toggle for switching boolean property values [Taiga #12341](https://tree.taiga.io/project/penpot/us/12341)
|
||||
- Add auth flow changes [Taiga #12333](https://tree.taiga.io/project/penpot/us/12333)
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
@@ -23,8 +67,13 @@
|
||||
- Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265)
|
||||
- Fix shortcut conflict in text editor (increase/decrease font size vs word selection)
|
||||
- Fix problem with plugins generating code for pages different than current one [Taiga #12312](https://tree.taiga.io/project/penpot/issue/12312)
|
||||
- Fix input confirmation behavior is not uniform [Taiga #12294](https://tree.taiga.io/project/penpot/issue/12294)
|
||||
|
||||
## 2.11.0 (Unreleased)
|
||||
## 2.11.1
|
||||
|
||||
- Fix WEBP shape export on docker images [Taiga #3838](https://tree.taiga.io/project/penpot/issue/3838)
|
||||
|
||||
## 2.11.0
|
||||
|
||||
### :boom: Breaking changes & Deprecations
|
||||
|
||||
@@ -52,10 +101,6 @@
|
||||
services which use netty internally (redis connection, S3 SDK client). This
|
||||
configuration is not very commonly used so don't expected real impact on any user.
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
### :heart: Community contributions (Thank you!)
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
- New composite token: Typography [Taiga #10200](https://tree.taiga.io/project/penpot/us/10200)
|
||||
@@ -100,6 +145,9 @@
|
||||
- Fix options button does not work for comments created in the lower part of the screen [Taiga #12422](https://tree.taiga.io/project/penpot/issue/12422)
|
||||
- Fix problem when checking usage with removed teams [Taiga #12442](https://tree.taiga.io/project/penpot/issue/12442)
|
||||
- Fix focus mode persisting across page/file navigation [Taiga #12469](https://tree.taiga.io/project/penpot/issue/12469)
|
||||
- Fix shadow color validation [Github #7705](https://github.com/penpot/penpot/pull/7705)
|
||||
- Fix exception on selection blend-mode using keyboard [Github #7710](https://github.com/penpot/penpot/pull/7710)
|
||||
- Fix crash when using decimal (floating-point) values for X/Y or width/height [Taiga #12543](https://tree.taiga.io/project/penpot/issue/12543)
|
||||
|
||||
## 2.10.1
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
[app.common.transit :as t]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.main :as main]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Builtin API Documentation - Penpot</title>
|
||||
<title>{{label|upper}} API Documentation</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -19,7 +19,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Penpot API Documentation (v{{version}})</h1>
|
||||
<h1>{{label|upper}}: API Documentation (v{{version}})</h1>
|
||||
<small class="menu">
|
||||
[
|
||||
<nav>
|
||||
@@ -31,9 +31,10 @@
|
||||
</header>
|
||||
<section class="doc-content">
|
||||
<h2>INTRODUCTION</h2>
|
||||
<p>This documentation is intended to be a general overview of the penpot RPC API.
|
||||
If you prefer, you can use <a href="/api/openapi.json">OpenAPI</a>
|
||||
and/or <a href="/api/openapi">SwaggerUI</a> as alternative.</p>
|
||||
<p>This documentation is intended to be a general overview of
|
||||
the {{label}} API. If you prefer, you can
|
||||
use <a href="{{openapi}}">Swagger/OpenAPI</a> as
|
||||
alternative.</p>
|
||||
|
||||
<h2>GENERAL NOTES</h2>
|
||||
|
||||
@@ -43,7 +44,7 @@
|
||||
that starts with <b>get-</b> in the name, can use GET HTTP
|
||||
method which in many cases benefits from the HTTP cache.</p>
|
||||
|
||||
|
||||
{% block auth-section %}
|
||||
<h3>Authentication</h3>
|
||||
<p>The penpot backend right now offers two way for authenticate the request:
|
||||
<b>cookies</b> (the same mechanism that we use ourselves on accessing the API from the
|
||||
@@ -56,9 +57,10 @@
|
||||
<p>The access token can be obtained on the appropriate section on profile settings
|
||||
and it should be provided using <b>`Authorization`</b> header with <b>`Token
|
||||
<token-string>`</b> value.</p>
|
||||
{% endblock %}
|
||||
|
||||
<h3>Content Negotiation</h3>
|
||||
<p>The penpot API by default operates indistinctly with: <b>`application/json`</b>
|
||||
<p>This API operates indistinctly with: <b>`application/json`</b>
|
||||
and <b>`application/transit+json`</b> content types. You should specify the
|
||||
desired content-type on the <b>`Accept`</b> header, the transit encoding is used
|
||||
by default.</p>
|
||||
@@ -75,13 +77,16 @@
|
||||
standard <a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">Fetch
|
||||
API</a></p>
|
||||
|
||||
{% block limits-section %}
|
||||
<h3>Limits</h3>
|
||||
<p>The rate limit work per user basis (this means that different api keys share
|
||||
the same rate limit). For now the limits are not documented because we are
|
||||
studying and analyzing the data. As a general rule, it should not be abused, if an
|
||||
abusive use is detected, we will proceed to block the user's access to the
|
||||
API.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block webhooks-section %}
|
||||
<h3>Webhooks</h3>
|
||||
<p>All methods that emit webhook events are marked with flag <b>WEBHOOK</b>, the
|
||||
data structure defined on each method represents the <i>payload</i> of the
|
||||
@@ -97,9 +102,11 @@
|
||||
"profileId": "db601c95-045f-808b-8002-361312e63531"
|
||||
}
|
||||
</pre>
|
||||
{% endblock %}
|
||||
|
||||
</section>
|
||||
<section class="rpc-doc-content">
|
||||
<h2>RPC METHODS REFERENCE:</h2>
|
||||
<h2>METHODS REFERENCE:</h2>
|
||||
<ul class="rpc-items">
|
||||
{% for item in methods %}
|
||||
{% include "app/templates/api-doc-entry.tmpl" with item=item %}
|
||||
|
||||
1
backend/resources/app/templates/main-api-doc.tmpl
Normal file
@@ -0,0 +1 @@
|
||||
{% extends "app/templates/api-doc.tmpl" %}
|
||||
10
backend/resources/app/templates/management-api-doc.tmpl
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "app/templates/api-doc.tmpl" %}
|
||||
|
||||
{% block auth-section %}
|
||||
{% endblock %}
|
||||
|
||||
{% block limits-section %}
|
||||
{% endblock %}
|
||||
|
||||
{% block webhooks-section %}
|
||||
{% endblock %}
|
||||
@@ -7,7 +7,7 @@
|
||||
name="description"
|
||||
content="SwaggerUI"
|
||||
/>
|
||||
<title>PENPOT Swagger UI</title>
|
||||
<title>{{label|upper}} API</title>
|
||||
<style>{{swagger-css|safe}}</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -16,7 +16,7 @@
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: '{{public-uri}}/api/openapi.json',
|
||||
url: '{{uri}}',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
|
||||
@@ -7,12 +7,12 @@ export PENPOT_HOST=devenv
|
||||
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-login-with-ldap \
|
||||
enable-login-with-password
|
||||
enable-login-with-oidc \
|
||||
enable-login-with-google \
|
||||
enable-login-with-github \
|
||||
enable-login-with-gitlab \
|
||||
disable-login-with-ldap \
|
||||
disable-login-with-oidc \
|
||||
disable-login-with-google \
|
||||
disable-login-with-github \
|
||||
disable-login-with-gitlab \
|
||||
enable-backend-worker \
|
||||
enable-backend-asserts \
|
||||
disable-feature-fdata-pointer-map \
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
:auto-file-snapshot-timeout "3h"
|
||||
|
||||
:public-uri "http://localhost:3449"
|
||||
|
||||
:host "localhost"
|
||||
:tenant "default"
|
||||
|
||||
@@ -57,6 +58,8 @@
|
||||
:objects-storage-backend "fs"
|
||||
:objects-storage-fs-directory "assets"
|
||||
|
||||
:auth-token-cookie-name "auth-token"
|
||||
|
||||
:assets-path "/internal/assets/"
|
||||
:smtp-default-reply-to "Penpot <no-reply@example.com>"
|
||||
:smtp-default-from "Penpot <no-reply@example.com>"
|
||||
@@ -90,7 +93,7 @@
|
||||
[:secret-key {:optional true} :string]
|
||||
|
||||
[:tenant {:optional false} :string]
|
||||
[:public-uri {:optional false} :string]
|
||||
[:public-uri {:optional false} ::sm/uri]
|
||||
[:host {:optional false} :string]
|
||||
|
||||
[:http-server-port {:optional true} ::sm/int]
|
||||
@@ -165,7 +168,7 @@
|
||||
[:google-client-id {:optional true} :string]
|
||||
[:google-client-secret {:optional true} :string]
|
||||
[:oidc-client-id {:optional true} :string]
|
||||
[:oidc-user-info-source {:optional true} :keyword]
|
||||
[:oidc-user-info-source {:optional true} [:enum "auto" "userinfo" "token"]]
|
||||
[:oidc-client-secret {:optional true} :string]
|
||||
[:oidc-base-uri {:optional true} :string]
|
||||
[:oidc-token-uri {:optional true} :string]
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
[app.main :as-alias main]
|
||||
[app.metrics :as mtx]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.doc :as-alias rpc.doc]
|
||||
[app.setup :as-alias setup]
|
||||
[integrant.core :as ig]
|
||||
[reitit.core :as r]
|
||||
@@ -149,7 +148,6 @@
|
||||
[:map
|
||||
[::ws/routes schema:routes]
|
||||
[::rpc/routes schema:routes]
|
||||
[::rpc.doc/routes schema:routes]
|
||||
[::oidc/routes schema:routes]
|
||||
[::assets/routes schema:routes]
|
||||
[::debug/routes schema:routes]
|
||||
@@ -171,8 +169,9 @@
|
||||
[sec/sec-fetch-metadata]
|
||||
[mw/params]
|
||||
[mw/format-response]
|
||||
[session/soft-auth cfg]
|
||||
[actoken/soft-auth cfg]
|
||||
[mw/auth {:bearer (partial session/decode-token cfg)
|
||||
:cookie (partial session/decode-token cfg)
|
||||
:token (partial actoken/decode-token cfg)}]
|
||||
[mw/parse-request]
|
||||
[mw/errors errors/handle]
|
||||
[mw/restrict-methods]]}
|
||||
@@ -188,9 +187,5 @@
|
||||
(::mgmt/routes cfg)]
|
||||
|
||||
(::ws/routes cfg)
|
||||
|
||||
["/api" {:middleware [[mw/cors]
|
||||
[sec/client-header-check]]}
|
||||
(::oidc/routes cfg)
|
||||
(::rpc.doc/routes cfg)
|
||||
(::rpc/routes cfg)]]]))
|
||||
(::oidc/routes cfg)
|
||||
(::rpc/routes cfg)]]))
|
||||
|
||||
@@ -9,23 +9,19 @@
|
||||
[app.common.logging :as l]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.main :as-alias main]
|
||||
[app.setup :as-alias setup]
|
||||
[app.tokens :as tokens]
|
||||
[yetti.request :as yreq]))
|
||||
[app.tokens :as tokens]))
|
||||
|
||||
(def header-re #"(?i)^Token\s+(.*)")
|
||||
|
||||
(defn get-token
|
||||
[request]
|
||||
(some->> (yreq/get-header request "authorization")
|
||||
(re-matches header-re)
|
||||
(second)))
|
||||
|
||||
(defn- decode-token
|
||||
(defn decode-token
|
||||
[cfg token]
|
||||
(when token
|
||||
(tokens/verify cfg {:token token :iss "access-token"})))
|
||||
(try
|
||||
(tokens/verify cfg {:token token :iss "access-token"})
|
||||
(catch Throwable cause
|
||||
(l/trc :hint "exception on decoding token"
|
||||
:token token
|
||||
:cause cause))))
|
||||
|
||||
(def sql:get-token-data
|
||||
"SELECT perms, profile_id, expires_at
|
||||
@@ -35,47 +31,28 @@
|
||||
OR (expires_at > now()));")
|
||||
|
||||
(defn- get-token-data
|
||||
[pool token-id]
|
||||
[pool claims]
|
||||
(when-not (db/read-only? pool)
|
||||
(some-> (db/exec-one! pool [sql:get-token-data token-id])
|
||||
(update :perms db/decode-pgarray #{}))))
|
||||
|
||||
(defn- wrap-soft-auth
|
||||
"Soft Authentication, will be executed synchronously on the undertow
|
||||
worker thread."
|
||||
[handler cfg]
|
||||
(letfn [(handle-request [request]
|
||||
(try
|
||||
(let [token (get-token request)
|
||||
claims (decode-token cfg token)]
|
||||
(cond-> request
|
||||
(map? claims)
|
||||
(assoc ::id (:tid claims))))
|
||||
(catch Throwable cause
|
||||
(l/trace :hint "exception on decoding malformed token" :cause cause)
|
||||
request)))]
|
||||
|
||||
(fn [request]
|
||||
(handler (handle-request request)))))
|
||||
(when-let [token-id (get claims :tid)]
|
||||
(some-> (db/exec-one! pool [sql:get-token-data token-id])
|
||||
(update :perms db/decode-pgarray #{})))))
|
||||
|
||||
(defn- wrap-authz
|
||||
"Authorization middleware, will be executed synchronously on vthread."
|
||||
[handler {:keys [::db/pool]}]
|
||||
(fn [request]
|
||||
(let [{:keys [perms profile-id expires-at]} (some->> (::id request) (get-token-data pool))]
|
||||
(handler (cond-> request
|
||||
(some? perms)
|
||||
(assoc ::perms perms)
|
||||
(some? profile-id)
|
||||
(assoc ::profile-id profile-id)
|
||||
(some? expires-at)
|
||||
(assoc ::expires-at expires-at))))))
|
||||
(let [{:keys [type claims]} (get request ::http/auth-data)]
|
||||
(if (= :token type)
|
||||
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
|
||||
;; FIXME: revisit this, this data looks unused
|
||||
(handler (cond-> request
|
||||
(some? perms)
|
||||
(assoc ::perms perms)
|
||||
(some? profile-id)
|
||||
(assoc ::profile-id profile-id)
|
||||
(some? expires-at)
|
||||
(assoc ::expires-at expires-at))))
|
||||
|
||||
(def soft-auth
|
||||
{:name ::soft-auth
|
||||
:compile (fn [& _]
|
||||
(when (contains? cf/flags :access-tokens)
|
||||
wrap-soft-auth))})
|
||||
(handler request)))))
|
||||
|
||||
(def authz
|
||||
{:name ::authz
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[integrant.core :as ig]
|
||||
[java-http-clj.core :as http]
|
||||
[promesa.core :as p])
|
||||
[java-http-clj.core :as http])
|
||||
(:import
|
||||
java.net.http.HttpClient))
|
||||
|
||||
@@ -29,14 +28,9 @@
|
||||
|
||||
(defn send!
|
||||
([client req] (send! client req {}))
|
||||
([client req {:keys [response-type sync?] :or {response-type :string sync? false}}]
|
||||
([client req {:keys [response-type] :or {response-type :string}}]
|
||||
(assert (client? client) "expected valid http client")
|
||||
(if sync?
|
||||
(http/send req {:client client :as response-type})
|
||||
(try
|
||||
(http/send-async req {:client client :as response-type})
|
||||
(catch Throwable cause
|
||||
(p/rejected cause))))))
|
||||
(http/send req {:client client :as response-type})))
|
||||
|
||||
(defn- resolve-client
|
||||
[params]
|
||||
@@ -56,8 +50,8 @@
|
||||
([cfg-or-client request]
|
||||
(let [client (resolve-client cfg-or-client)
|
||||
request (update request :uri str)]
|
||||
(send! client request {:sync? true})))
|
||||
(send! client request {})))
|
||||
([cfg-or-client request options]
|
||||
(let [client (resolve-client cfg-or-client)
|
||||
request (update request :uri str)]
|
||||
(send! client request (merge {:sync? true} options)))))
|
||||
(send! client request options))))
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[app.config :as cf]
|
||||
[app.http :as-alias http]
|
||||
[app.http.access-token :as-alias actoken]
|
||||
[app.http.auth :as-alias auth]
|
||||
[app.http.session :as-alias session]
|
||||
[app.util.inet :as inet]
|
||||
[clojure.spec.alpha :as s]
|
||||
@@ -22,16 +23,15 @@
|
||||
(defn request->context
|
||||
"Extracts error report relevant context data from request."
|
||||
[request]
|
||||
(let [claims (-> {}
|
||||
(into (::session/token-claims request))
|
||||
(into (::actoken/token-claims request)))]
|
||||
(let [{:keys [claims] :as auth} (get request ::http/auth-data)]
|
||||
(-> (cf/logging-context)
|
||||
(assoc :request/path (:path request))
|
||||
(assoc :request/method (:method request))
|
||||
(assoc :request/params (:params request))
|
||||
(assoc :request/user-agent (yreq/get-header request "user-agent"))
|
||||
(assoc :request/ip-addr (inet/parse-request request))
|
||||
(assoc :request/profile-id (:uid claims))
|
||||
(assoc :request/profile-id (get claims :uid))
|
||||
(assoc :request/auth-data auth)
|
||||
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")))))
|
||||
|
||||
(defmulti handle-error
|
||||
@@ -60,7 +60,6 @@
|
||||
::yres/body data}
|
||||
|
||||
(binding [l/*context* (request->context request)]
|
||||
(l/wrn :hint "restriction error" :cause err)
|
||||
{::yres/status 400
|
||||
::yres/body data}))))
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.access-token :refer [get-token]]
|
||||
[app.http.middleware :as mw]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc.commands.profile :as cmd.profile]
|
||||
[app.setup :as-alias setup]
|
||||
@@ -32,20 +32,6 @@
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expect valid database pool"))
|
||||
|
||||
(def ^:private auth
|
||||
{:name ::auth
|
||||
:compile
|
||||
(fn [_ _]
|
||||
(fn [handler shared-key]
|
||||
(if shared-key
|
||||
(fn [request]
|
||||
(let [token (get-token request)]
|
||||
(if (= token shared-key)
|
||||
(handler request)
|
||||
{::yres/status 403})))
|
||||
(fn [_ _]
|
||||
{::yres/status 403}))))})
|
||||
|
||||
(def ^:private default-system
|
||||
{:name ::default-system
|
||||
:compile
|
||||
@@ -65,7 +51,7 @@
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ cfg]
|
||||
["" {:middleware [[auth (cf/get :management-api-shared-key)]
|
||||
["" {:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
|
||||
[default-system cfg]
|
||||
[transaction]]}
|
||||
["/authenticate"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
[app.common.schema :as-alias sm]
|
||||
[app.common.transit :as t]
|
||||
[app.config :as cf]
|
||||
[app.http :as-alias http]
|
||||
[app.http.errors :as errors]
|
||||
[app.util.pointer-map :as pmap]
|
||||
[cuerdas.core :as str]
|
||||
@@ -240,3 +241,60 @@
|
||||
(if (contains? allowed method)
|
||||
(handler request)
|
||||
{::yres/status 405}))))))})
|
||||
|
||||
|
||||
(defn- wrap-auth
|
||||
[handler decoders]
|
||||
(let [token-re
|
||||
#"(?i)^(Token|Bearer)\s+(.*)"
|
||||
|
||||
get-token-from-authorization
|
||||
(fn [request]
|
||||
(when-let [[_ token-type token] (some->> (yreq/get-header request "authorization")
|
||||
(re-matches token-re))]
|
||||
(if (= "token" (str/lower token-type))
|
||||
{:type :token
|
||||
:token token}
|
||||
{:type :bearer
|
||||
:token token})))
|
||||
|
||||
get-token-from-cookie
|
||||
(fn [request]
|
||||
(let [cname (cf/get :auth-token-cookie-name)
|
||||
token (some-> (yreq/get-cookie request cname) :value)]
|
||||
(when-not (str/empty? token)
|
||||
{:type :cookie
|
||||
:token token})))
|
||||
|
||||
get-token
|
||||
(some-fn get-token-from-cookie get-token-from-authorization)
|
||||
|
||||
process-request
|
||||
(fn [request]
|
||||
(if-let [{:keys [type token] :as auth} (get-token request)]
|
||||
(if-let [decode-fn (get decoders type)]
|
||||
(assoc request ::http/auth-data (assoc auth :claims (decode-fn token)))
|
||||
(assoc request ::http/auth-data auth))
|
||||
request))]
|
||||
|
||||
(fn [request]
|
||||
(-> request process-request handler))))
|
||||
|
||||
(def auth
|
||||
{:name ::auth
|
||||
:compile (constantly wrap-auth)})
|
||||
|
||||
(defn- wrap-shared-key-auth
|
||||
[handler shared-key]
|
||||
(if shared-key
|
||||
(fn [request]
|
||||
(let [key (yreq/get-header request "x-shared-key")]
|
||||
(if (= key shared-key)
|
||||
(handler request)
|
||||
{::yres/status 403})))
|
||||
(fn [_ _]
|
||||
{::yres/status 403})))
|
||||
|
||||
(def shared-key-auth
|
||||
{:name ::shared-key-auth
|
||||
:compile (constantly wrap-shared-key-auth)})
|
||||
|
||||
@@ -11,28 +11,24 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.http :as-alias http]
|
||||
[app.http.auth :as-alias http.auth]
|
||||
[app.http.session.tasks :as-alias tasks]
|
||||
[app.main :as-alias main]
|
||||
[app.setup :as-alias setup]
|
||||
[app.tokens :as tokens]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[yetti.request :as yreq]))
|
||||
[yetti.request :as yreq]
|
||||
[yetti.response :as yres]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; DEFAULTS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
;; A default cookie name for storing the session.
|
||||
(def default-auth-token-cookie-name "auth-token")
|
||||
|
||||
;; A cookie that we can use to check from other sites of the same
|
||||
;; domain if a user is authenticated.
|
||||
(def default-auth-data-cookie-name "auth-data")
|
||||
|
||||
;; Default value for cookie max-age
|
||||
(def default-cookie-max-age (ct/duration {:days 7}))
|
||||
|
||||
@@ -44,10 +40,10 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defprotocol ISessionManager
|
||||
(read [_ key])
|
||||
(write! [_ key data])
|
||||
(update! [_ data])
|
||||
(delete! [_ key]))
|
||||
(read-session [_ id])
|
||||
(create-session [_ params])
|
||||
(update-session [_ session])
|
||||
(delete-session [_ id]))
|
||||
|
||||
(defn manager?
|
||||
[o]
|
||||
@@ -62,71 +58,82 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private schema:params
|
||||
[:map {:title "session-params"}
|
||||
[:user-agent ::sm/text]
|
||||
[:map {:title "SessionParams" :closed true}
|
||||
[:profile-id ::sm/uuid]
|
||||
[:created-at ::ct/inst]])
|
||||
[:user-agent {:optional true} ::sm/text]
|
||||
[:sso-provider-id {:optional true} ::sm/uuid]
|
||||
[:sso-session-id {:optional true} :string]])
|
||||
|
||||
(def ^:private valid-params?
|
||||
(sm/validator schema:params))
|
||||
|
||||
(defn- prepare-session-params
|
||||
[params key]
|
||||
(assert (string? key) "expected key to be a string")
|
||||
(assert (not (str/blank? key)) "expected key to be not empty")
|
||||
(assert (valid-params? params) "expected valid params")
|
||||
|
||||
{:user-agent (:user-agent params)
|
||||
:profile-id (:profile-id params)
|
||||
:created-at (:created-at params)
|
||||
:updated-at (:created-at params)
|
||||
:id key})
|
||||
|
||||
(defn- database-manager
|
||||
[pool]
|
||||
(reify ISessionManager
|
||||
(read [_ token]
|
||||
(db/exec-one! pool (sql/select :http-session {:id token})))
|
||||
(read-session [_ id]
|
||||
(if (string? id)
|
||||
;; Backward compatibility
|
||||
(let [session (db/exec-one! pool (sql/select :http-session {:id id}))]
|
||||
(-> session
|
||||
(assoc :modified-at (:updated-at session))
|
||||
(dissoc :updated-at)))
|
||||
(db/exec-one! pool (sql/select :http-session-v2 {:id id}))))
|
||||
|
||||
(write! [_ key params]
|
||||
(let [params (-> params
|
||||
(assoc :created-at (ct/now))
|
||||
(prepare-session-params key))]
|
||||
(db/insert! pool :http-session params)
|
||||
params))
|
||||
(create-session [_ params]
|
||||
(assert (valid-params? params) "expect valid session params")
|
||||
|
||||
(update! [_ params]
|
||||
(let [updated-at (ct/now)]
|
||||
(db/update! pool :http-session
|
||||
{:updated-at updated-at}
|
||||
{:id (:id params)})
|
||||
(assoc params :updated-at updated-at)))
|
||||
(let [now (ct/now)
|
||||
params (-> params
|
||||
(assoc :id (uuid/next))
|
||||
(assoc :created-at now)
|
||||
(assoc :modified-at now))]
|
||||
(db/insert! pool :http-session-v2 params
|
||||
{::db/return-keys true})))
|
||||
|
||||
(delete! [_ token]
|
||||
(db/delete! pool :http-session {:id token})
|
||||
(update-session [_ session]
|
||||
(let [modified-at (ct/now)]
|
||||
(if (string? (:id session))
|
||||
(let [params (-> session
|
||||
(assoc :id (uuid/next))
|
||||
(assoc :created-at modified-at)
|
||||
(assoc :modified-at modified-at))]
|
||||
(db/insert! pool :http-session-v2 params))
|
||||
|
||||
(db/update! pool :http-session-v2
|
||||
{:modified-at modified-at}
|
||||
{:id (:id session)}))))
|
||||
|
||||
(delete-session [_ id]
|
||||
(if (string? id)
|
||||
(db/delete! pool :http-session {:id id} {::db/return-keys false})
|
||||
(db/delete! pool :http-session-v2 {:id id} {::db/return-keys false}))
|
||||
nil)))
|
||||
|
||||
(defn inmemory-manager
|
||||
[]
|
||||
(let [cache (atom {})]
|
||||
(reify ISessionManager
|
||||
(read [_ token]
|
||||
(get @cache token))
|
||||
(read-session [_ id]
|
||||
(get @cache id))
|
||||
|
||||
(write! [_ key params]
|
||||
(let [params (-> params
|
||||
(assoc :created-at (ct/now))
|
||||
(prepare-session-params key))]
|
||||
(swap! cache assoc key params)
|
||||
params))
|
||||
(create-session [_ params]
|
||||
(assert (valid-params? params) "expect valid session params")
|
||||
|
||||
(update! [_ params]
|
||||
(let [updated-at (ct/now)]
|
||||
(swap! cache update (:id params) assoc :updated-at updated-at)
|
||||
(assoc params :updated-at updated-at)))
|
||||
(let [now (ct/now)
|
||||
session (-> params
|
||||
(assoc :id (uuid/next))
|
||||
(assoc :created-at now)
|
||||
(assoc :modified-at now))]
|
||||
(swap! cache assoc (:id session) session)
|
||||
session))
|
||||
|
||||
(delete! [_ token]
|
||||
(swap! cache dissoc token)
|
||||
(update-session [_ session]
|
||||
(let [modified-at (ct/now)]
|
||||
(swap! cache update (:id session) assoc :modified-at modified-at)
|
||||
(assoc session :modified-at modified-at)))
|
||||
|
||||
(delete-session [_ id]
|
||||
(swap! cache dissoc id)
|
||||
nil))))
|
||||
|
||||
(defmethod ig/assert-key ::manager
|
||||
@@ -146,103 +153,114 @@
|
||||
;; MANAGER IMPL
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(declare ^:private assign-auth-token-cookie)
|
||||
(declare ^:private clear-auth-token-cookie)
|
||||
(declare ^:private gen-token)
|
||||
(declare ^:private assign-session-cookie)
|
||||
(declare ^:private clear-session-cookie)
|
||||
|
||||
(defn- assign-token
|
||||
[cfg session]
|
||||
(let [token (tokens/generate cfg
|
||||
{:iss "authentication"
|
||||
:aud "penpot"
|
||||
:sid (:id session)
|
||||
:iat (:modified-at session)
|
||||
:uid (:profile-id session)
|
||||
:sso-provider-id (:sso-provider-id session)
|
||||
:sso-session-id (:sso-session-id session)})]
|
||||
(assoc session :token token)))
|
||||
|
||||
(defn create-fn
|
||||
[{:keys [::manager] :as cfg} profile-id]
|
||||
[{:keys [::manager] :as cfg} {profile-id :id :as profile}
|
||||
& {:keys [sso-provider-id sso-session-id]}]
|
||||
|
||||
(assert (manager? manager) "expected valid session manager")
|
||||
(assert (uuid? profile-id) "expected valid uuid for profile-id")
|
||||
|
||||
(fn [request response]
|
||||
(let [uagent (yreq/get-header request "user-agent")
|
||||
params {:profile-id profile-id
|
||||
:user-agent uagent}
|
||||
token (gen-token cfg params)
|
||||
session (write! manager token params)]
|
||||
(l/trc :hint "create" :profile-id (str profile-id))
|
||||
(-> response
|
||||
(assign-auth-token-cookie session)))))
|
||||
session (->> {:user-agent uagent
|
||||
:profile-id profile-id
|
||||
:sso-provider-id sso-provider-id
|
||||
:sso-session-id sso-session-id}
|
||||
(d/without-nils)
|
||||
(create-session manager)
|
||||
(assign-token cfg))]
|
||||
|
||||
(l/trc :hint "create" :id (str (:id session)) :profile-id (str profile-id))
|
||||
(assign-session-cookie response session))))
|
||||
|
||||
(defn delete-fn
|
||||
[{:keys [::manager]}]
|
||||
(assert (manager? manager) "expected valid session manager")
|
||||
(fn [request response]
|
||||
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
cookie (yreq/get-cookie request cname)]
|
||||
(l/trc :hint "delete" :profile-id (:profile-id request))
|
||||
(some->> (:value cookie) (delete! manager))
|
||||
(-> response
|
||||
(assoc :status 204)
|
||||
(assoc :body nil)
|
||||
(clear-auth-token-cookie)))))
|
||||
(some->> (get request ::id) (delete-session manager))
|
||||
(clear-session-cookie response)))
|
||||
|
||||
(defn- gen-token
|
||||
[cfg {:keys [profile-id created-at]}]
|
||||
(tokens/generate cfg {:iss "authentication"
|
||||
:iat created-at
|
||||
:uid profile-id}))
|
||||
(defn- decode-token
|
||||
(defn decode-token
|
||||
[cfg token]
|
||||
(when token
|
||||
(tokens/verify cfg {:token token :iss "authentication"})))
|
||||
(try
|
||||
(tokens/verify cfg {:token token :iss "authentication"})
|
||||
(catch Throwable cause
|
||||
(l/trc :hint "exception on decoding token"
|
||||
:token token
|
||||
:cause cause))))
|
||||
|
||||
(defn- get-token
|
||||
(defn get-session
|
||||
[request]
|
||||
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
cookie (some-> (yreq/get-cookie request cname) :value)]
|
||||
(when-not (str/empty? cookie)
|
||||
cookie)))
|
||||
(get request ::session))
|
||||
|
||||
(defn- get-session
|
||||
[manager token]
|
||||
(some->> token (read manager)))
|
||||
(defn invalidate-others
|
||||
[cfg session]
|
||||
(let [sql "delete from http_session_v2 where profile_id = ? and id != ?"]
|
||||
(-> (db/exec-one! cfg [sql (:profile-id session) (:id session)])
|
||||
(db/get-update-count))))
|
||||
|
||||
(defn- renew-session?
|
||||
[{:keys [updated-at] :as session}]
|
||||
(and (ct/inst? updated-at)
|
||||
(let [elapsed (ct/diff updated-at (ct/now))]
|
||||
(neg? (compare default-renewal-max-age elapsed)))))
|
||||
|
||||
(defn- wrap-soft-auth
|
||||
[handler {:keys [::manager] :as cfg}]
|
||||
(assert (manager? manager) "expected valid session manager")
|
||||
(letfn [(handle-request [request]
|
||||
(try
|
||||
(let [token (get-token request)
|
||||
claims (decode-token cfg token)]
|
||||
(cond-> request
|
||||
(map? claims)
|
||||
(-> (assoc ::token-claims claims)
|
||||
(assoc ::token token))))
|
||||
(catch Throwable cause
|
||||
(l/trc :hint "exception on decoding malformed token" :cause cause)
|
||||
request)))]
|
||||
|
||||
(fn [request]
|
||||
(handler (handle-request request)))))
|
||||
[{:keys [id modified-at] :as session}]
|
||||
(or (string? id)
|
||||
(and (ct/inst? modified-at)
|
||||
(let [elapsed (ct/diff modified-at (ct/now))]
|
||||
(neg? (compare default-renewal-max-age elapsed))))))
|
||||
|
||||
(defn- wrap-authz
|
||||
[handler {:keys [::manager]}]
|
||||
[handler {:keys [::manager] :as cfg}]
|
||||
(assert (manager? manager) "expected valid session manager")
|
||||
(fn [request]
|
||||
(let [session (get-session manager (::token request))
|
||||
request (cond-> request
|
||||
(some? session)
|
||||
(assoc ::profile-id (:profile-id session)
|
||||
::id (:id session)))
|
||||
response (handler request)]
|
||||
(let [{:keys [type token claims]} (get request ::http/auth-data)]
|
||||
(cond
|
||||
(= type :cookie)
|
||||
(let [session (if-let [sid (:sid claims)]
|
||||
(read-session manager sid)
|
||||
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
|
||||
(read-session manager token))
|
||||
|
||||
(if (renew-session? session)
|
||||
(let [session (update! manager session)]
|
||||
(-> response
|
||||
(assign-auth-token-cookie session)))
|
||||
response))))
|
||||
request (cond-> request
|
||||
(some? session)
|
||||
(-> (assoc ::profile-id (:profile-id session))
|
||||
(assoc ::session session)))
|
||||
|
||||
(def soft-auth
|
||||
{:name ::soft-auth
|
||||
:compile (constantly wrap-soft-auth)})
|
||||
response (handler request)]
|
||||
|
||||
(if (renew-session? session)
|
||||
(let [session (->> session
|
||||
(update-session manager)
|
||||
(assign-token cfg))]
|
||||
(assign-session-cookie response session))
|
||||
response))
|
||||
|
||||
(= type :bearer)
|
||||
(let [session (if-let [sid (:sid claims)]
|
||||
(read-session manager sid)
|
||||
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
|
||||
(read-session manager token))
|
||||
|
||||
request (cond-> request
|
||||
(some? session)
|
||||
(-> (assoc ::profile-id (:profile-id session))
|
||||
(assoc ::session session)))]
|
||||
(handler request))
|
||||
|
||||
:else
|
||||
(handler request)))))
|
||||
|
||||
(def authz
|
||||
{:name ::authz
|
||||
@@ -250,16 +268,16 @@
|
||||
|
||||
;; --- IMPL
|
||||
|
||||
(defn- assign-auth-token-cookie
|
||||
[response {token :id updated-at :updated-at}]
|
||||
(defn- assign-session-cookie
|
||||
[response {token :token modified-at :modified-at}]
|
||||
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)
|
||||
created-at updated-at
|
||||
created-at modified-at
|
||||
renewal (ct/plus created-at default-renewal-max-age)
|
||||
expires (ct/plus created-at max-age)
|
||||
secure? (contains? cf/flags :secure-session-cookies)
|
||||
strict? (contains? cf/flags :strict-session-cookies)
|
||||
cors? (contains? cf/flags :cors)
|
||||
name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
|
||||
name (cf/get :auth-token-cookie-name)
|
||||
comment (str "Renewal at: " (ct/format-inst renewal :rfc1123))
|
||||
cookie {:path "/"
|
||||
:http-only true
|
||||
@@ -268,12 +286,12 @@
|
||||
:comment comment
|
||||
:same-site (if cors? :none (if strict? :strict :lax))
|
||||
:secure secure?}]
|
||||
(update response :cookies assoc name cookie)))
|
||||
(update response ::yres/cookies assoc name cookie)))
|
||||
|
||||
(defn- clear-auth-token-cookie
|
||||
(defn- clear-session-cookie
|
||||
[response]
|
||||
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
|
||||
(update response :cookies assoc cname {:path "/" :value "" :max-age 0})))
|
||||
(let [cname (cf/get :auth-token-cookie-name)]
|
||||
(update response ::yres/cookies assoc cname {:path "/" :value "" :max-age 0})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; TASK: SESSION GC
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
[app.util.inet :as inet]
|
||||
[app.util.services :as-alias sv]
|
||||
[app.worker :as wrk]
|
||||
[cuerdas.core :as str]))
|
||||
[cuerdas.core :as str]
|
||||
[yetti.request :as yreq]))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; HELPERS
|
||||
@@ -90,6 +91,22 @@
|
||||
::ip-addr (::rpc/ip-addr params)
|
||||
::context (d/without-nils context)}))
|
||||
|
||||
(defn get-external-session-id
|
||||
[request]
|
||||
(when-let [session-id (yreq/get-header request "x-external-session-id")]
|
||||
(when-not (or (> (count session-id) 256)
|
||||
(= session-id "null")
|
||||
(str/blank? session-id))
|
||||
session-id)))
|
||||
|
||||
(defn- get-external-event-origin
|
||||
[request]
|
||||
(when-let [origin (yreq/get-header request "x-event-origin")]
|
||||
(when-not (or (> (count origin) 256)
|
||||
(= origin "null")
|
||||
(str/blank? origin))
|
||||
origin)))
|
||||
|
||||
;; --- SPECS
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -126,8 +143,6 @@
|
||||
(::rpc/profile-id params)
|
||||
uuid/zero)
|
||||
|
||||
session-id (get params ::rpc/external-session-id)
|
||||
event-origin (get params ::rpc/external-event-origin)
|
||||
props (-> (or (::replace-props resultm)
|
||||
(-> params
|
||||
(merge (::props resultm))
|
||||
@@ -138,8 +153,10 @@
|
||||
|
||||
token-id (::actoken/id request)
|
||||
context (-> (::context resultm)
|
||||
(assoc :external-session-id session-id)
|
||||
(assoc :external-event-origin event-origin)
|
||||
(assoc :external-session-id
|
||||
(get-external-session-id request))
|
||||
(assoc :external-event-origin
|
||||
(get-external-event-origin request))
|
||||
(assoc :access-token-id (some-> token-id str))
|
||||
(d/without-nils))
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
[app.http.client :as-alias http.client]
|
||||
[app.http.debug :as-alias http.debug]
|
||||
[app.http.management :as mgmt]
|
||||
[app.http.session :as-alias session]
|
||||
[app.http.session :as session]
|
||||
[app.http.session.tasks :as-alias session.tasks]
|
||||
[app.http.websocket :as http.ws]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
@@ -31,7 +31,6 @@
|
||||
[app.redis :as-alias rds]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.doc :as-alias rpc.doc]
|
||||
[app.setup :as-alias setup]
|
||||
[app.srepl :as-alias srepl]
|
||||
[app.storage :as-alias sto]
|
||||
@@ -260,14 +259,17 @@
|
||||
::oidc.providers/generic
|
||||
{::http.client/client (ig/ref ::http.client/client)}
|
||||
|
||||
::oidc/providers
|
||||
[(ig/ref ::oidc.providers/google)
|
||||
(ig/ref ::oidc.providers/github)
|
||||
(ig/ref ::oidc.providers/gitlab)
|
||||
(ig/ref ::oidc.providers/generic)]
|
||||
|
||||
::oidc/routes
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::setup/props (ig/ref ::setup/props)
|
||||
::oidc/providers {:google (ig/ref ::oidc.providers/google)
|
||||
:github (ig/ref ::oidc.providers/github)
|
||||
:gitlab (ig/ref ::oidc.providers/gitlab)
|
||||
:oidc (ig/ref ::oidc.providers/generic)}
|
||||
::oidc/providers (ig/ref ::oidc/providers)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::email/blacklist (ig/ref ::email/blacklist)
|
||||
::email/whitelist (ig/ref ::email/whitelist)}
|
||||
@@ -280,7 +282,6 @@
|
||||
{::session/manager (ig/ref ::session/manager)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::rpc/routes (ig/ref ::rpc/routes)
|
||||
::rpc.doc/routes (ig/ref ::rpc.doc/routes)
|
||||
::setup/props (ig/ref ::setup/props)
|
||||
::mtx/routes (ig/ref ::mtx/routes)
|
||||
::oidc/routes (ig/ref ::oidc/routes)
|
||||
@@ -300,6 +301,7 @@
|
||||
{::db/pool (ig/ref ::db/pool)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::mbus/msgbus (ig/ref ::mbus/msgbus)
|
||||
::setup/props (ig/ref ::setup/props)
|
||||
::session/manager (ig/ref ::session/manager)}
|
||||
|
||||
:app.http.assets/routes
|
||||
@@ -337,14 +339,26 @@
|
||||
::email/blacklist (ig/ref ::email/blacklist)
|
||||
::email/whitelist (ig/ref ::email/whitelist)}
|
||||
|
||||
:app.rpc.doc/routes
|
||||
{:app.rpc/methods (ig/ref :app.rpc/methods)}
|
||||
:app.rpc/management-methods
|
||||
{::http.client/client (ig/ref ::http.client/client)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::rds/pool (ig/ref ::rds/pool)
|
||||
::wrk/executor (ig/ref ::wrk/netty-executor)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::sto/storage (ig/ref ::sto/storage)
|
||||
::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
::mbus/msgbus (ig/ref ::mbus/msgbus)
|
||||
::rds/client (ig/ref ::rds/client)
|
||||
::setup/props (ig/ref ::setup/props)}
|
||||
|
||||
::rpc/routes
|
||||
{::rpc/methods (ig/ref :app.rpc/methods)
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::setup/props (ig/ref ::setup/props)}
|
||||
{::rpc/methods (ig/ref :app.rpc/methods)
|
||||
::rpc/management-methods (ig/ref :app.rpc/management-methods)
|
||||
|
||||
;; FIXME: revisit if db/pool is necessary here
|
||||
::db/pool (ig/ref ::db/pool)
|
||||
::session/manager (ig/ref ::session/manager)
|
||||
::setup/props (ig/ref ::setup/props)}
|
||||
|
||||
::wrk/registry
|
||||
{::mtx/metrics (ig/ref ::mtx/metrics)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as-alias db]
|
||||
[app.http.client :as http]
|
||||
[app.storage :as-alias sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[buddy.core.bytes :as bb]
|
||||
@@ -37,6 +38,9 @@
|
||||
org.im4java.core.IMOperation
|
||||
org.im4java.core.Info))
|
||||
|
||||
(def default-max-file-size
|
||||
(* 1024 1024 10)) ; 10 MiB
|
||||
|
||||
(def schema:upload
|
||||
[:map {:title "Upload"}
|
||||
[:filename :string]
|
||||
@@ -241,7 +245,7 @@
|
||||
(ex/raise :type :validation
|
||||
:code :invalid-svg-file
|
||||
:hint "uploaded svg does not provides dimensions"))
|
||||
(merge input info {:ts (ct/now)}))
|
||||
(merge input info {:ts (ct/now) :size (fs/size path)}))
|
||||
|
||||
(let [instance (Info. (str path))
|
||||
mtype' (.getProperty instance "Mime type")]
|
||||
@@ -261,6 +265,7 @@
|
||||
(assoc input
|
||||
:width width
|
||||
:height height
|
||||
:size (fs/size path)
|
||||
:ts (ct/now)))))))
|
||||
|
||||
(defmethod process-error org.im4java.core.InfoException
|
||||
@@ -270,6 +275,54 @@
|
||||
:hint "invalid image"
|
||||
:cause error))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; IMAGE HELPERS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn download-image
|
||||
"Download an image from the provided URI and return the media input object"
|
||||
[{:keys [::http/client]} uri]
|
||||
(letfn [(parse-and-validate [{:keys [headers] :as response}]
|
||||
(let [size (some-> (get headers "content-length") d/parse-integer)
|
||||
mtype (get headers "content-type")
|
||||
format (cm/mtype->format mtype)
|
||||
max-size (cf/get :media-max-file-size default-max-file-size)]
|
||||
|
||||
(when-not size
|
||||
(ex/raise :type :validation
|
||||
:code :unknown-size
|
||||
:hint "seems like the url points to resource with unknown size"))
|
||||
|
||||
(when (> size max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :file-too-large
|
||||
:hint (str/ffmt "the file size % is greater than the maximum %"
|
||||
size
|
||||
default-max-file-size)))
|
||||
|
||||
(when (nil? format)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "seems like the url points to an invalid media object"))
|
||||
|
||||
{:size size :mtype mtype :format format}))]
|
||||
|
||||
(let [{:keys [body] :as response} (http/req! client
|
||||
{:method :get :uri uri}
|
||||
{:response-type :input-stream})
|
||||
{:keys [size mtype]} (parse-and-validate response)
|
||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||
written (io/write* path body :size size)]
|
||||
|
||||
(when (not= written size)
|
||||
(ex/raise :type :internal
|
||||
:code :mismatch-write-size
|
||||
:hint "unexpected state: unable to write to file"))
|
||||
|
||||
{;; :size size
|
||||
:path path
|
||||
:mtype mtype})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; FONTS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -450,7 +450,13 @@
|
||||
:fn (mg/resource "app/migrations/sql/0141-add-idx-to-file-library-rel.sql")}
|
||||
|
||||
{:name "0141-add-file-data-table.sql"
|
||||
:fn (mg/resource "app/migrations/sql/0141-add-file-data-table.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0141-add-file-data-table.sql")}
|
||||
|
||||
{:name "0142-add-sso-provider-table"
|
||||
:fn (mg/resource "app/migrations/sql/0142-add-sso-provider-table.sql")}
|
||||
|
||||
{:name "0143-http-session-v2-table"
|
||||
:fn (mg/resource "app/migrations/sql/0143-add-http-session-v2-table.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE sso_provider (
|
||||
id uuid PRIMARY KEY,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
modified_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
is_enabled boolean NOT NULL DEFAULT true,
|
||||
|
||||
type text NOT NULL CHECK (type IN ('oidc')),
|
||||
domain text NOT NULL,
|
||||
|
||||
client_id text NOT NULL,
|
||||
client_secret text NOT NULL,
|
||||
|
||||
base_uri text NOT NULL,
|
||||
token_uri text NULL,
|
||||
auth_uri text NULL,
|
||||
user_uri text NULL,
|
||||
jwks_uri text NULL,
|
||||
logout_uri text NULL,
|
||||
|
||||
roles_attr text NULL,
|
||||
email_attr text NULL,
|
||||
name_attr text NULL,
|
||||
user_info_source text NOT NULL DEFAULT 'token'
|
||||
CHECK (user_info_source IN ('token', 'userinfo', 'auto')),
|
||||
|
||||
scopes text[] NULL,
|
||||
roles text[] NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX sso_provider__domain__idx
|
||||
ON sso_provider(domain);
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE http_session_v2 (
|
||||
id uuid PRIMARY KEY,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
modified_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
profile_id uuid REFERENCES profile(id) ON DELETE CASCADE,
|
||||
user_agent text NULL,
|
||||
|
||||
sso_provider_id uuid NULL REFERENCES sso_provider(id) ON DELETE CASCADE,
|
||||
sso_session_id text NULL
|
||||
);
|
||||
|
||||
CREATE INDEX http_session_v2__profile_id__idx
|
||||
ON http_session_v2(profile_id);
|
||||
|
||||
CREATE INDEX http_session_v2__sso_provider_id__idx
|
||||
ON http_session_v2(sso_provider_id)
|
||||
WHERE sso_provider_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX http_session_v2__sso_session_id__idx
|
||||
ON http_session_v2(sso_session_id)
|
||||
WHERE sso_session_id IS NOT NULL;
|
||||
@@ -13,11 +13,14 @@
|
||||
[app.common.schema :as sm]
|
||||
[app.common.spec :as us]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.http.access-token :as actoken]
|
||||
[app.http.client :as-alias http.client]
|
||||
[app.http.middleware :as mw]
|
||||
[app.http.security :as sec]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as-alias main]
|
||||
@@ -26,6 +29,7 @@
|
||||
[app.redis :as rds]
|
||||
[app.rpc.climit :as climit]
|
||||
[app.rpc.cond :as cond]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.rpc.retry :as retry]
|
||||
[app.rpc.rlimit :as rlimit]
|
||||
@@ -36,7 +40,6 @@
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[promesa.core :as p]
|
||||
[yetti.request :as yreq]
|
||||
[yetti.response :as yres]))
|
||||
|
||||
@@ -44,7 +47,7 @@
|
||||
|
||||
(defn- default-handler
|
||||
[_]
|
||||
(p/rejected (ex/error :type :not-found)))
|
||||
(ex/raise :type :not-found))
|
||||
|
||||
(defn- handle-response-transformation
|
||||
[response request mdata]
|
||||
@@ -65,70 +68,57 @@
|
||||
response (if (fn? result)
|
||||
(result request)
|
||||
(let [result (rph/unwrap result)
|
||||
status (::http/status mdata 200)
|
||||
status (or (::http/status mdata)
|
||||
(if (nil? result)
|
||||
204
|
||||
200))
|
||||
headers (cond-> (::http/headers mdata {})
|
||||
(yres/stream-body? result)
|
||||
(assoc "content-type" "application/octet-stream"))]
|
||||
{::yres/status status
|
||||
::yres/headers headers
|
||||
::yres/body result}))]
|
||||
|
||||
(-> response
|
||||
(handle-response-transformation request mdata)
|
||||
(handle-before-comple-hook mdata))))
|
||||
|
||||
(defn get-external-session-id
|
||||
[request]
|
||||
(when-let [session-id (yreq/get-header request "x-external-session-id")]
|
||||
(when-not (or (> (count session-id) 256)
|
||||
(= session-id "null")
|
||||
(str/blank? session-id))
|
||||
session-id)))
|
||||
|
||||
(defn- get-external-event-origin
|
||||
[request]
|
||||
(when-let [origin (yreq/get-header request "x-event-origin")]
|
||||
(when-not (or (> (count origin) 256)
|
||||
(= origin "null")
|
||||
(str/blank? origin))
|
||||
origin)))
|
||||
|
||||
(defn- rpc-handler
|
||||
(defn- make-rpc-handler
|
||||
"Ring handler that dispatches cmd requests and convert between
|
||||
internal async flow into ring async flow."
|
||||
[methods {:keys [params path-params method] :as request}]
|
||||
(let [handler-name (:type path-params)
|
||||
etag (yreq/get-header request "if-none-match")
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request))
|
||||
[methods]
|
||||
(let [methods (update-vals methods peek)]
|
||||
(fn [{:keys [params path-params method] :as request}]
|
||||
(let [handler-name (:type path-params)
|
||||
etag (yreq/get-header request "if-none-match")
|
||||
profile-id (or (::session/profile-id request)
|
||||
(::actoken/profile-id request))
|
||||
ip-addr (inet/parse-request request)
|
||||
|
||||
ip-addr (inet/parse-request request)
|
||||
session-id (get-external-session-id request)
|
||||
event-origin (get-external-event-origin request)
|
||||
data (-> params
|
||||
(assoc ::handler-name handler-name)
|
||||
(assoc ::ip-addr ip-addr)
|
||||
(assoc ::request-at (ct/now))
|
||||
(assoc ::cond/key etag)
|
||||
(cond-> (uuid? profile-id)
|
||||
(assoc ::profile-id profile-id)))
|
||||
|
||||
data (-> params
|
||||
(assoc ::handler-name handler-name)
|
||||
(assoc ::ip-addr ip-addr)
|
||||
(assoc ::request-at (ct/now))
|
||||
(assoc ::external-session-id session-id)
|
||||
(assoc ::external-event-origin event-origin)
|
||||
(assoc ::session/id (::session/id request))
|
||||
(assoc ::cond/key etag)
|
||||
(cond-> (uuid? profile-id)
|
||||
(assoc ::profile-id profile-id)))
|
||||
data (with-meta data
|
||||
{::http/request request})
|
||||
|
||||
data (vary-meta data assoc ::http/request request)
|
||||
handler-fn (get methods (keyword handler-name) default-handler)]
|
||||
handler-fn (get methods (keyword handler-name) default-handler)]
|
||||
|
||||
(when (and (or (= method :get)
|
||||
(= method :head))
|
||||
(not (str/starts-with? handler-name "get-")))
|
||||
(ex/raise :type :restriction
|
||||
:code :method-not-allowed
|
||||
:hint "method not allowed for this request"))
|
||||
(when (and (or (= method :get)
|
||||
(= method :head))
|
||||
(not (str/starts-with? handler-name "get-")))
|
||||
(ex/raise :type :restriction
|
||||
:code :method-not-allowed
|
||||
:hint "method not allowed for this request"))
|
||||
|
||||
(binding [cond/*enabled* true]
|
||||
(let [response (handler-fn data)]
|
||||
(handle-response request response)))))
|
||||
;; FIXME: why we have this cond enabled here, we need to move it outside this handler
|
||||
(binding [cond/*enabled* true]
|
||||
(let [response (handler-fn data)]
|
||||
(handle-response request response)))))))
|
||||
|
||||
(defn- wrap-metrics
|
||||
"Wrap service method with metrics measurement."
|
||||
@@ -205,7 +195,7 @@
|
||||
::sm/explain (explain params)))))))
|
||||
f))
|
||||
|
||||
(defn- wrap-all
|
||||
(defn- wrap
|
||||
[cfg f mdata]
|
||||
(as-> f $
|
||||
(wrap-db-transaction cfg $ mdata)
|
||||
@@ -219,17 +209,30 @@
|
||||
(wrap-params-validation cfg $ mdata)
|
||||
(wrap-authentication cfg $ mdata)))
|
||||
|
||||
(defn- wrap
|
||||
(defn- wrap-management
|
||||
[cfg f mdata]
|
||||
(l/trc :hint "register method" :name (::sv/name mdata))
|
||||
(let [f (wrap-all cfg f mdata)]
|
||||
(partial f cfg)))
|
||||
(as-> f $
|
||||
(wrap-db-transaction cfg $ mdata)
|
||||
(retry/wrap-retry cfg $ mdata)
|
||||
(climit/wrap cfg $ mdata)
|
||||
(wrap-metrics cfg $ mdata)
|
||||
(wrap-audit cfg $ mdata)
|
||||
(wrap-spec-conform cfg $ mdata)
|
||||
(wrap-params-validation cfg $ mdata)
|
||||
(wrap-authentication cfg $ mdata)))
|
||||
|
||||
(defn- process-method
|
||||
[cfg [vfn mdata]]
|
||||
[(keyword (::sv/name mdata)) [mdata (wrap cfg vfn mdata)]])
|
||||
[cfg module wrap-fn [f mdata]]
|
||||
(l/trc :hint "add method" :module module :name (::sv/name mdata))
|
||||
(let [f (wrap-fn cfg f mdata)
|
||||
k (keyword (::sv/name mdata))]
|
||||
[k [mdata (partial f cfg)]]))
|
||||
|
||||
(defn- resolve-command-methods
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; API METHODS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- resolve-methods
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
|
||||
(->> (sv/scan-ns
|
||||
@@ -258,7 +261,7 @@
|
||||
'app.rpc.commands.verify-token
|
||||
'app.rpc.commands.viewer
|
||||
'app.rpc.commands.webhooks)
|
||||
(map (partial process-method cfg))
|
||||
(map (partial process-method cfg "rpc" wrap))
|
||||
(into {}))))
|
||||
|
||||
(def ^:private schema:methods-params
|
||||
@@ -282,7 +285,49 @@
|
||||
(defmethod ig/init-key ::methods
|
||||
[_ cfg]
|
||||
(let [cfg (d/without-nils cfg)]
|
||||
(resolve-command-methods cfg)))
|
||||
(resolve-methods cfg)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; MANAGEMENT METHODS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- resolve-management-methods
|
||||
[cfg]
|
||||
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
|
||||
(->> (sv/scan-ns
|
||||
'app.rpc.management.subscription)
|
||||
(map (partial process-method cfg "management" wrap-management))
|
||||
(into {}))))
|
||||
|
||||
(def ^:private schema:management-methods-params
|
||||
[:map {:title "management-methods-params"}
|
||||
::session/manager
|
||||
::http.client/client
|
||||
::db/pool
|
||||
::rds/pool
|
||||
::mbus/msgbus
|
||||
::sto/storage
|
||||
::mtx/metrics
|
||||
::setup/props])
|
||||
|
||||
(defmethod ig/assert-key ::management-methods
|
||||
[_ params]
|
||||
(assert (sm/check schema:management-methods-params params)))
|
||||
|
||||
(defmethod ig/init-key ::management-methods
|
||||
[_ cfg]
|
||||
(let [cfg (d/without-nils cfg)]
|
||||
(resolve-management-methods cfg)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; ROUTES
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- redirect
|
||||
[href]
|
||||
(fn [_]
|
||||
{::yres/status 308
|
||||
::yres/headers {"location" (str href)}}))
|
||||
|
||||
(def ^:private schema:methods
|
||||
[:map-of :keyword [:tuple :map ::sm/fn]])
|
||||
@@ -297,11 +342,48 @@
|
||||
(assert (db/pool? (::db/pool params)) "expect valid database pool")
|
||||
(assert (some? (::setup/props params)))
|
||||
(assert (session/manager? (::session/manager params)) "expect valid session manager")
|
||||
(assert (valid-methods? (::methods params)) "expect valid methods map"))
|
||||
(assert (valid-methods? (::methods params)) "expect valid methods map")
|
||||
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [::methods] :as cfg}]
|
||||
(let [methods (update-vals methods peek)]
|
||||
[["/rpc" {:middleware [[session/authz cfg]
|
||||
[actoken/authz cfg]]}
|
||||
["/command/:type" {:handler (partial rpc-handler methods)}]]]))
|
||||
[_ {:keys [::methods ::management-methods] :as cfg}]
|
||||
|
||||
(let [public-uri (cf/get :public-uri)]
|
||||
["/api"
|
||||
|
||||
["/management"
|
||||
["/methods/:type"
|
||||
{:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
|
||||
[session/authz cfg]]
|
||||
:handler (make-rpc-handler management-methods)}]
|
||||
|
||||
(doc/routes :methods management-methods
|
||||
:label "management"
|
||||
:base-uri (u/join public-uri "/api/management")
|
||||
:description "MANAGEMENT API")]
|
||||
|
||||
["/main"
|
||||
["/methods/:type"
|
||||
{:middleware [[mw/cors]
|
||||
[sec/client-header-check]
|
||||
[session/authz cfg]
|
||||
[actoken/authz cfg]]
|
||||
:handler (make-rpc-handler methods)}]
|
||||
|
||||
(doc/routes :methods methods
|
||||
:label "main"
|
||||
:base-uri (u/join public-uri "/api/main")
|
||||
:description "MAIN API")]
|
||||
|
||||
;; BACKWARD COMPATIBILITY
|
||||
["/_doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
|
||||
["/doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
|
||||
["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}]
|
||||
["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}]
|
||||
|
||||
["/rpc/command/:type"
|
||||
{:middleware [[mw/cors]
|
||||
[sec/client-header-check]
|
||||
[session/authz cfg]
|
||||
[actoken/authz cfg]]
|
||||
:handler (make-rpc-handler methods)}]]))
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
expires-at (some-> expiration (ct/in-future))
|
||||
created-at (ct/now)
|
||||
token (tokens/generate cfg {:iss "access-token"
|
||||
:uid profile-id
|
||||
:iat created-at
|
||||
:tid token-id})
|
||||
|
||||
|
||||
@@ -7,21 +7,24 @@
|
||||
(ns app.rpc.commands.auth
|
||||
(:require
|
||||
[app.auth :as auth]
|
||||
[app.auth.oidc :as oidc]
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.features :as cfeat]
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uri :as u]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.email :as eml]
|
||||
[app.email.blacklist :as email.blacklist]
|
||||
[app.email.whitelist :as email.whitelist]
|
||||
[app.http :as-alias http]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.climit :as-alias climit]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
@@ -30,6 +33,7 @@
|
||||
[app.rpc.helpers :as rph]
|
||||
[app.setup :as-alias setup]
|
||||
[app.setup.welcome-file :refer [create-welcome-file]]
|
||||
[app.storage :as sto]
|
||||
[app.tokens :as tokens]
|
||||
[app.util.services :as sv]
|
||||
[app.worker :as wrk]
|
||||
@@ -109,7 +113,7 @@
|
||||
(assoc profile :is-admin (let [admins (cf/get :admins)]
|
||||
(contains? admins (:email profile)))))]
|
||||
(-> response
|
||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||
(rph/with-transform (session/create-fn cfg profile))
|
||||
(rph/with-meta {::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))))]
|
||||
|
||||
@@ -145,7 +149,24 @@
|
||||
[cfg params]
|
||||
(if (= (:profile-id params)
|
||||
(::rpc/profile-id params))
|
||||
(rph/with-transform {} (session/delete-fn cfg))
|
||||
(let [{:keys [claims]}
|
||||
(rph/get-auth-data params)
|
||||
|
||||
provider
|
||||
(some->> (get claims :sso-provider-id)
|
||||
(oidc/get-provider cfg))
|
||||
|
||||
response
|
||||
(if (and provider (:logout-uri provider))
|
||||
(let [params {"logout_hint" (get claims :sso-session-id)
|
||||
"client_id" (get provider :client-id)
|
||||
"post_logout_redirect_uri" (str (cf/get :public-uri))}
|
||||
uri (-> (u/uri (:logout-uri provider))
|
||||
(assoc :query (u/map->query-string params)))]
|
||||
{:redirect-uri uri})
|
||||
{})]
|
||||
|
||||
(rph/with-transform response (session/delete-fn cfg)))
|
||||
{}))
|
||||
|
||||
;; ---- COMMAND: Recover Profile
|
||||
@@ -271,11 +292,29 @@
|
||||
|
||||
;; ---- COMMAND: Register Profile
|
||||
|
||||
(defn create-profile!
|
||||
(defn import-profile-picture
|
||||
[cfg uri]
|
||||
(try
|
||||
(let [storage (sto/resolve cfg)
|
||||
input (media/download-image cfg uri)
|
||||
input (media/run {:cmd :info :input input})
|
||||
hash (sto/calculate-hash (:path input))
|
||||
content (-> (sto/content (:path input) (:size input))
|
||||
(sto/wrap-with-hash hash))
|
||||
sobject (sto/put-object! storage {::sto/content content
|
||||
::sto/deduplicate? true
|
||||
:bucket "profile"
|
||||
:content-type (:mtype input)})]
|
||||
(:id sobject))
|
||||
(catch Throwable cause
|
||||
(l/err :hint "unable to import profile picture"
|
||||
:cause cause)
|
||||
nil)))
|
||||
|
||||
(defn create-profile
|
||||
"Create the profile entry on the database with limited set of input
|
||||
attrs (all the other attrs are filled with default values)."
|
||||
[conn {:keys [email] :as params}]
|
||||
(dm/assert! ::sm/email email)
|
||||
[{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
|
||||
(let [id (or (:id params) (uuid/next))
|
||||
props (-> (audit/extract-utm-params params)
|
||||
(merge (:props params))
|
||||
@@ -283,8 +322,7 @@
|
||||
:viewed-walkthrough? false
|
||||
:nudge {:big 10 :small 1}
|
||||
:v2-info-shown true
|
||||
:release-notes-viewed (:main cf/version)})
|
||||
(db/tjson))
|
||||
:release-notes-viewed (:main cf/version)}))
|
||||
|
||||
password (or (:password params) "!")
|
||||
|
||||
@@ -299,6 +337,12 @@
|
||||
theme (:theme params nil)
|
||||
email (str/lower email)
|
||||
|
||||
photo-id (some->> (or (:oidc/picture props)
|
||||
(:google/picture props)
|
||||
(:github/picture props)
|
||||
(:gitlab/picture props))
|
||||
(import-profile-picture cfg))
|
||||
|
||||
params {:id id
|
||||
:fullname (:fullname params)
|
||||
:email email
|
||||
@@ -306,11 +350,13 @@
|
||||
:lang locale
|
||||
:password password
|
||||
:deleted-at (:deleted-at params)
|
||||
:props props
|
||||
:props (db/tjson props)
|
||||
:theme theme
|
||||
:photo-id photo-id
|
||||
:is-active is-active
|
||||
:is-muted is-muted
|
||||
:is-demo is-demo}]
|
||||
|
||||
(try
|
||||
(-> (db/insert! conn :profile params)
|
||||
(profile/decode-row))
|
||||
@@ -323,7 +369,7 @@
|
||||
(throw cause))))))
|
||||
|
||||
|
||||
(defn create-profile-rels!
|
||||
(defn create-profile-rels
|
||||
[conn {:keys [id] :as profile}]
|
||||
(let [features (cfeat/get-enabled-features cf/flags)
|
||||
team (teams/create-team conn
|
||||
@@ -373,12 +419,13 @@
|
||||
;; to detect if the profile is already registered
|
||||
(or (profile/get-profile-by-email conn (:email claims))
|
||||
(let [is-active (or (boolean (:is-active claims))
|
||||
(boolean (:email-verified claims))
|
||||
(not (contains? cf/flags :email-verification)))
|
||||
params (-> params
|
||||
(assoc :is-active is-active)
|
||||
(update :password auth/derive-password))
|
||||
profile (->> (create-profile! conn params)
|
||||
(create-profile-rels! conn))]
|
||||
profile (->> (create-profile cfg params)
|
||||
(create-profile-rels conn))]
|
||||
(vary-meta profile assoc :created true))))
|
||||
|
||||
created? (-> profile meta :created true?)
|
||||
@@ -416,10 +463,10 @@
|
||||
(and (some? invitation)
|
||||
(= (:email profile)
|
||||
(:member-email invitation)))
|
||||
(let [claims (assoc invitation :member-id (:id profile))
|
||||
token (tokens/generate cfg claims)]
|
||||
(let [invitation (assoc invitation :member-id (:id profile))
|
||||
token (tokens/generate cfg invitation)]
|
||||
(-> {:invitation-token token}
|
||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
(rph/with-meta {::audit/replace-props props
|
||||
::audit/context {:action "accept-invitation"}
|
||||
::audit/profile-id (:id profile)})))
|
||||
@@ -430,7 +477,7 @@
|
||||
created?
|
||||
(if (:is-active profile)
|
||||
(-> (profile/strip-private-attrs profile)
|
||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||
(rph/with-transform (session/create-fn cfg profile claims))
|
||||
(rph/with-defer create-welcome-file-when-needed)
|
||||
(rph/with-meta
|
||||
{::audit/replace-props props
|
||||
@@ -559,4 +606,32 @@
|
||||
[cfg params]
|
||||
(db/tx-run! cfg request-profile-recovery params))
|
||||
|
||||
;; --- COMMAND: get-sso-config
|
||||
|
||||
(defn- extract-domain
|
||||
"Extract the domain part from email"
|
||||
[email]
|
||||
(let [at (str/last-index-of email "@")]
|
||||
(when (and (>= at 0)
|
||||
(< at (dec (count email))))
|
||||
(-> (subs email (inc at))
|
||||
(str/trim)
|
||||
(str/lower)))))
|
||||
|
||||
(def ^:private schema:get-sso-provider
|
||||
[:map {:title "get-sso-config"}
|
||||
[:email ::sm/email]])
|
||||
|
||||
(def ^:private schema:get-sso-provider-result
|
||||
[:map {:title "SSOProvider"}
|
||||
[:id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::get-sso-provider
|
||||
{::rpc/auth false
|
||||
::doc/added "2.12"
|
||||
::sm/params schema:get-sso-provider
|
||||
::sm/result schema:get-sso-provider-result}
|
||||
[cfg {:keys [email]}]
|
||||
(when-let [domain (extract-domain email)]
|
||||
(when-let [config (db/get* cfg :sso-provider {:domain domain})]
|
||||
(select-keys config [:id]))))
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
:deleted-at (ct/in-future (cf/get-deletion-delay))
|
||||
:password (derive-password password)
|
||||
:props {}}
|
||||
profile (db/tx-run! cfg (fn [{:keys [::db/conn]}]
|
||||
(->> (auth/create-profile! conn params)
|
||||
(auth/create-profile-rels! conn))))]
|
||||
profile (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
|
||||
(->> (auth/create-profile cfg params)
|
||||
(auth/create-profile-rels conn))))]
|
||||
(with-meta {:email email
|
||||
:password password}
|
||||
{::audit/profile-id (:id profile)})))
|
||||
|
||||
@@ -66,12 +66,12 @@
|
||||
:member-email (:email profile))
|
||||
token (tokens/generate cfg claims)]
|
||||
(-> {:invitation-token token}
|
||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||
(rph/with-transform (session/create-fn cfg profile))
|
||||
(rph/with-meta {::audit/props (:props profile)
|
||||
::audit/profile-id (:id profile)})))
|
||||
|
||||
(-> (profile/strip-private-attrs profile)
|
||||
(rph/with-transform (session/create-fn cfg (:id profile)))
|
||||
(rph/with-transform (session/create-fn cfg profile))
|
||||
(rph/with-meta {::audit/props (:props profile)
|
||||
::audit/profile-id (:id profile)}))))))
|
||||
|
||||
@@ -83,6 +83,6 @@
|
||||
(profile/clean-email)
|
||||
(profile/get-profile-by-email conn))
|
||||
(->> (assoc info :is-active true :is-demo false)
|
||||
(auth/create-profile! conn)
|
||||
(auth/create-profile-rels! conn)
|
||||
(auth/create-profile cfg)
|
||||
(auth/create-profile-rels conn)
|
||||
(profile/strip-private-attrs))))))
|
||||
|
||||
@@ -7,14 +7,10 @@
|
||||
(ns app.rpc.commands.media
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.media :as cm]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http.client :as http]
|
||||
[app.loggers.audit :as-alias audit]
|
||||
[app.media :as media]
|
||||
[app.rpc :as-alias rpc]
|
||||
@@ -22,13 +18,7 @@
|
||||
[app.rpc.commands.files :as files]
|
||||
[app.rpc.doc :as-alias doc]
|
||||
[app.storage :as sto]
|
||||
[app.storage.tmp :as tmp]
|
||||
[app.util.services :as sv]
|
||||
[cuerdas.core :as str]
|
||||
[datoteka.io :as io]))
|
||||
|
||||
(def default-max-file-size
|
||||
(* 1024 1024 10)) ; 10 MiB
|
||||
[app.util.services :as sv]))
|
||||
|
||||
(def thumbnail-options
|
||||
{:width 100
|
||||
@@ -197,56 +187,12 @@
|
||||
|
||||
mobj))
|
||||
|
||||
(defn download-image
|
||||
[{:keys [::http/client]} uri]
|
||||
(letfn [(parse-and-validate [{:keys [headers] :as response}]
|
||||
(let [size (some-> (get headers "content-length") d/parse-integer)
|
||||
mtype (get headers "content-type")
|
||||
format (cm/mtype->format mtype)
|
||||
max-size (cf/get :media-max-file-size default-max-file-size)]
|
||||
|
||||
(when-not size
|
||||
(ex/raise :type :validation
|
||||
:code :unknown-size
|
||||
:hint "seems like the url points to resource with unknown size"))
|
||||
|
||||
(when (> size max-size)
|
||||
(ex/raise :type :validation
|
||||
:code :file-too-large
|
||||
:hint (str/ffmt "the file size % is greater than the maximum %"
|
||||
size
|
||||
default-max-file-size)))
|
||||
|
||||
(when (nil? format)
|
||||
(ex/raise :type :validation
|
||||
:code :media-type-not-allowed
|
||||
:hint "seems like the url points to an invalid media object"))
|
||||
|
||||
{:size size :mtype mtype :format format}))]
|
||||
|
||||
(let [{:keys [body] :as response} (http/req! client
|
||||
{:method :get :uri uri}
|
||||
{:response-type :input-stream :sync? true})
|
||||
{:keys [size mtype]} (parse-and-validate response)
|
||||
path (tmp/tempfile :prefix "penpot.media.download.")
|
||||
written (io/write* path body :size size)]
|
||||
|
||||
(when (not= written size)
|
||||
(ex/raise :type :internal
|
||||
:code :mismatch-write-size
|
||||
:hint "unexpected state: unable to write to file"))
|
||||
|
||||
{:filename "tempfile"
|
||||
:size size
|
||||
:path path
|
||||
:mtype mtype})))
|
||||
|
||||
(defn- create-file-media-object-from-url
|
||||
[cfg {:keys [url name] :as params}]
|
||||
(let [content (download-image cfg url)
|
||||
(let [content (media/download-image cfg url)
|
||||
params (-> params
|
||||
(assoc :content content)
|
||||
(assoc :name (or name (:filename content))))]
|
||||
(assoc :name (d/nilv name "unknown")))]
|
||||
|
||||
;; NOTE: we use the climit here in a dynamic invocation because we
|
||||
;; don't want saturate the process-image limit with IO (download
|
||||
|
||||
@@ -154,7 +154,6 @@
|
||||
|
||||
(declare validate-password!)
|
||||
(declare update-profile-password!)
|
||||
(declare invalidate-profile-session!)
|
||||
|
||||
(def ^:private
|
||||
schema:update-profile-password
|
||||
@@ -169,8 +168,7 @@
|
||||
::climit/id :auth/global
|
||||
::db/transaction true}
|
||||
[cfg {:keys [::rpc/profile-id password] :as params}]
|
||||
(let [profile (validate-password! cfg (assoc params :profile-id profile-id))
|
||||
session-id (::session/id params)]
|
||||
(let [profile (validate-password! cfg (assoc params :profile-id profile-id))]
|
||||
|
||||
(when (= (:email profile) (str/lower (:password params)))
|
||||
(ex/raise :type :validation
|
||||
@@ -178,14 +176,12 @@
|
||||
:hint "you can't use your email as password"))
|
||||
|
||||
(update-profile-password! cfg (assoc profile :password password))
|
||||
(invalidate-profile-session! cfg profile-id session-id)
|
||||
nil))
|
||||
|
||||
(defn- invalidate-profile-session!
|
||||
"Removes all sessions except the current one."
|
||||
[{:keys [::db/conn]} profile-id session-id]
|
||||
(let [sql "delete from http_session where profile_id = ? and id != ?"]
|
||||
(:next.jdbc/update-count (db/exec-one! conn [sql profile-id session-id]))))
|
||||
(->> (rph/get-request params)
|
||||
(session/get-session)
|
||||
(session/invalidate-others cfg))
|
||||
|
||||
nil))
|
||||
|
||||
(defn- validate-password!
|
||||
[{:keys [::db/conn] :as cfg} {:keys [profile-id old-password] :as params}]
|
||||
@@ -284,9 +280,9 @@
|
||||
:file-path (str (:path file))
|
||||
:file-mtype (:mtype file)}}))))
|
||||
|
||||
(defn- generate-thumbnail!
|
||||
[_ file]
|
||||
(let [input (media/run {:cmd :info :input file})
|
||||
(defn- generate-thumbnail
|
||||
[_ input]
|
||||
(let [input (media/run {:cmd :info :input input})
|
||||
thumb (media/run {:cmd :profile-thumbnail
|
||||
:format :jpeg
|
||||
:quality 85
|
||||
@@ -307,7 +303,7 @@
|
||||
(assoc ::climit/id [[:process-image/by-profile (:profile-id params)]
|
||||
[:process-image/global]])
|
||||
(assoc ::climit/label "upload-photo")
|
||||
(climit/invoke! generate-thumbnail! file))]
|
||||
(climit/invoke! generate-thumbnail file))]
|
||||
(sto/put-object! storage params)))
|
||||
|
||||
;; --- MUTATION: Request Email Change
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
{:id (:id profile)}))
|
||||
|
||||
(-> claims
|
||||
(rph/with-transform (session/create-fn cfg profile-id))
|
||||
(rph/with-transform (session/create-fn cfg profile))
|
||||
(rph/with-meta {::audit/name "verify-profile-email"
|
||||
::audit/props (audit/profile->props profile)
|
||||
::audit/profile-id (:id profile)}))))
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
[app.common.schema.desc-native :as smdn]
|
||||
[app.common.schema.openapi :as oapi]
|
||||
[app.common.schema.registry :as sr]
|
||||
[app.common.uri :as u]
|
||||
[app.config :as cf]
|
||||
[app.http.sse :as-alias sse]
|
||||
[app.loggers.webhooks :as-alias webhooks]
|
||||
@@ -25,7 +26,6 @@
|
||||
[clojure.java.io :as io]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[pretty-spec.core :as ps]
|
||||
[yetti.response :as-alias yres]))
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
;; DOC (human readable)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- prepare-doc-context
|
||||
[methods]
|
||||
(defn- context
|
||||
[{:keys [methods entrypoint label openapi]}]
|
||||
(letfn [(fmt-spec [mdata]
|
||||
(when-let [spec (ex/ignoring (s/spec (::sv/spec mdata)))]
|
||||
(with-out-str
|
||||
@@ -62,8 +62,10 @@
|
||||
:added (::added mdata)
|
||||
:changes (some->> (::changes mdata) (partition-all 2) (map vec))
|
||||
:spec (fmt-spec mdata)
|
||||
:entrypoint (str (cf/get :public-uri) "/api/rpc/command/" (::sv/name mdata))
|
||||
|
||||
:entrypoint (-> entrypoint
|
||||
(u/ensure-path-slash)
|
||||
(u/join (::sv/name mdata))
|
||||
(str))
|
||||
:params-schema-js (fmt-schema :js mdata ::sm/params)
|
||||
:result-schema-js (fmt-schema :js mdata ::sm/result)
|
||||
:webhook-schema-js (fmt-schema :js mdata ::sm/webhook)
|
||||
@@ -72,6 +74,9 @@
|
||||
:webhook-schema-clj (fmt-schema :clj mdata ::sm/webhook)})]
|
||||
|
||||
{:version (:main cf/version)
|
||||
:label label
|
||||
:entrypoint (str entrypoint)
|
||||
:openapi (str openapi)
|
||||
:methods
|
||||
(->> methods
|
||||
(map val)
|
||||
@@ -80,17 +85,19 @@
|
||||
(map get-context)
|
||||
(sort-by (juxt :module :name)))}))
|
||||
|
||||
(defn- doc-handler
|
||||
[context]
|
||||
(defn- handler
|
||||
[& {:keys [template] :as options}]
|
||||
(if (contains? cf/flags :backend-api-doc)
|
||||
(fn [request]
|
||||
(let [params (:query-params request)
|
||||
pstyle (:type params "js")
|
||||
context (assoc @context :param-style pstyle)]
|
||||
(let [context (delay (context options))
|
||||
template (or template "app/templates/api-doc.tmpl")]
|
||||
(fn [request]
|
||||
(let [params (:query-params request)
|
||||
pstyle (:type params "js")
|
||||
context (assoc @context :param-style pstyle)]
|
||||
|
||||
{::yres/status 200
|
||||
::yres/body (-> (io/resource "app/templates/api-doc.tmpl")
|
||||
(tmpl/render context))}))
|
||||
{::yres/status 200
|
||||
::yres/body (-> (io/resource template)
|
||||
(tmpl/render context))})))
|
||||
(fn [_]
|
||||
{::yres/status 404})))
|
||||
|
||||
@@ -98,8 +105,8 @@
|
||||
;; OPENAPI / SWAGGER (v3.1)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn prepare-openapi-context
|
||||
[methods]
|
||||
(defn- openapi-context
|
||||
[{:keys [methods entrypoint description]}]
|
||||
(let [definitions (atom {})
|
||||
options {:registry sr/default-registry
|
||||
::oapi/definitions-path "#/components/schemas/"
|
||||
@@ -112,7 +119,9 @@
|
||||
(fn [tsx schema]
|
||||
(let [schema (sm/schema schema)
|
||||
example (sm/generate schema)
|
||||
example (sm/encode schema example output-transformer)]
|
||||
example (sm/encode schema example output-transformer)
|
||||
example (json/encode example :key-fn json/write-camel-key)]
|
||||
|
||||
{:default
|
||||
{:description "A default response"
|
||||
:content
|
||||
@@ -123,7 +132,9 @@
|
||||
gen-params-doc
|
||||
(fn [tsx schema]
|
||||
(let [example (sm/generate schema)
|
||||
example (sm/encode schema example output-transformer)]
|
||||
example (sm/encode schema example output-transformer)
|
||||
example (json/encode example :key-fn json/write-camel-key)]
|
||||
|
||||
{:required true
|
||||
:content
|
||||
{"application/json"
|
||||
@@ -158,34 +169,35 @@
|
||||
(map gen-method-doc)
|
||||
(sort-by (juxt :module :name))
|
||||
(map (fn [doc]
|
||||
[(str/ffmt "/command/%" (:name doc)) (:repr doc)]))
|
||||
[(:name doc) (:repr doc)]))
|
||||
(into {})))]
|
||||
|
||||
{:openapi "3.0.0"
|
||||
:info {:version (:main cf/version)}
|
||||
:servers [{:url (str/ffmt "%/api/rpc" (cf/get :public-uri))
|
||||
;; :description "penpot backend"
|
||||
}]
|
||||
:servers [{:url (str entrypoint)
|
||||
:description (or description "")}]
|
||||
:paths paths
|
||||
:components {:schemas @definitions}}))
|
||||
|
||||
(defn openapi-json-handler
|
||||
[context]
|
||||
(defn- openapi-json-handler
|
||||
[& {:as options}]
|
||||
(if (contains? cf/flags :backend-openapi-doc)
|
||||
(fn [_]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "application/json; charset=utf-8"}
|
||||
::yres/body (json/encode @context)})
|
||||
(let [context (delay (openapi-context options))]
|
||||
(fn [_]
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "application/json; charset=utf-8"}
|
||||
::yres/body (json/encode @context)}))
|
||||
(fn [_]
|
||||
{::yres/status 404})))
|
||||
|
||||
(defn openapi-handler
|
||||
[]
|
||||
(defn- openapi-handler
|
||||
[& {:keys [uri label]}]
|
||||
(if (contains? cf/flags :backend-openapi-doc)
|
||||
(fn [_]
|
||||
(let [swagger-js (slurp (io/resource "app/assets/swagger-ui-4.18.3.js"))
|
||||
swagger-cs (slurp (io/resource "app/assets/swagger-ui-4.18.3.css"))
|
||||
context {:public-uri (cf/get :public-uri)
|
||||
context {:uri (str uri)
|
||||
:label label
|
||||
:swagger-js swagger-js
|
||||
:swagger-css swagger-cs}]
|
||||
{::yres/status 200
|
||||
@@ -196,27 +208,43 @@
|
||||
{::yres/status 404})))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; MODULE INIT
|
||||
;; ROUTES HELPER
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defmethod ig/assert-key ::routes
|
||||
[_ params]
|
||||
(assert (sm/valid? ::rpc/methods (::rpc/methods params)) "expected valid methods"))
|
||||
(defn routes
|
||||
[& {:keys [label base-uri description methods]}]
|
||||
(let [entrypoint
|
||||
(-> base-uri
|
||||
(u/ensure-path-slash)
|
||||
(u/join "methods"))
|
||||
|
||||
(defmethod ig/init-key ::routes
|
||||
[_ {:keys [::rpc/methods] :as cfg}]
|
||||
[(let [context (delay (prepare-doc-context methods))]
|
||||
[["/_doc"
|
||||
{:handler (doc-handler context)
|
||||
:allowed-methods #{:get}}]
|
||||
["/doc"
|
||||
{:handler (doc-handler context)
|
||||
:allowed-methods #{:get}}]])
|
||||
openapi
|
||||
(-> base-uri
|
||||
(u/ensure-path-slash)
|
||||
(u/join "doc/openapi"))
|
||||
|
||||
(let [context (delay (prepare-openapi-context methods))]
|
||||
[["/openapi"
|
||||
{:handler (openapi-handler)
|
||||
:allowed-methods #{:get}}]
|
||||
["/openapi.json"
|
||||
{:handler (openapi-json-handler context)
|
||||
:allowed-methods #{:get}}]])])
|
||||
template
|
||||
(case label
|
||||
"management" "app/templates/management-api-doc.tmpl"
|
||||
"main" "app/templates/main-api-doc.tmpl")]
|
||||
|
||||
["/doc"
|
||||
["" {:handler (handler :methods methods
|
||||
:label label
|
||||
:entrypoint entrypoint
|
||||
:openapi openapi
|
||||
:template template)
|
||||
:allowed-methods #{:get}}]
|
||||
|
||||
["/openapi"
|
||||
{:handler (openapi-handler
|
||||
:uri (u/join openapi "openapi.json")
|
||||
:label label)
|
||||
:allowed-methods #{:get}}]
|
||||
|
||||
["/openapi.json"
|
||||
{:handler (openapi-json-handler {:entrypoint entrypoint
|
||||
:description description
|
||||
:methods methods})
|
||||
|
||||
:allowed-methods #{:get}}]]))
|
||||
|
||||
@@ -83,3 +83,16 @@
|
||||
"A convenience allias for yetti.response/stream-body"
|
||||
[f]
|
||||
(yres/stream-body f))
|
||||
|
||||
(defn get-request
|
||||
"Get http request from RPC params"
|
||||
[params]
|
||||
(assert (contains? params ::rpc/request-at) "rpc params required")
|
||||
(-> (meta params)
|
||||
(get ::http/request)))
|
||||
|
||||
(defn get-auth-data
|
||||
"Get http auth-data from RPC params"
|
||||
[params]
|
||||
(-> (get-request params)
|
||||
(get ::http/auth-data)))
|
||||
|
||||
183
backend/src/app/rpc/management/subscription.clj
Normal file
@@ -0,0 +1,183 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns app.rpc.management.subscription
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.generators :as sg]
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.profile :as profile]
|
||||
[app.rpc.doc :as doc]
|
||||
[app.util.services :as sv]))
|
||||
|
||||
;; ---- RPC METHOD: AUTHENTICATE
|
||||
|
||||
(def ^:private
|
||||
schema:authenticate-params
|
||||
[:map {:title "authenticate-params"}])
|
||||
|
||||
(def ^:private
|
||||
schema:authenticate-result
|
||||
[:map {:title "authenticate-result"}
|
||||
[:profile-id ::sm/uuid]])
|
||||
|
||||
(sv/defmethod ::auth
|
||||
{::doc/added "2.12"
|
||||
::sm/params schema:authenticate-params
|
||||
::sm/result schema:authenticate-result}
|
||||
[_ {:keys [::rpc/profile-id]}]
|
||||
{:profile-id profile-id})
|
||||
|
||||
;; ---- RPC METHOD: GET-CUSTOMER
|
||||
|
||||
;; FIXME: move to app.common.time
|
||||
(def ^:private schema:timestamp
|
||||
(sm/type-schema
|
||||
{:type ::timestamp
|
||||
:pred ct/inst?
|
||||
:type-properties
|
||||
{:title "inst"
|
||||
:description "The same as :app.common.time/inst but encodes to epoch"
|
||||
:error/message "should be an instant"
|
||||
:gen/gen (->> (sg/small-int)
|
||||
(sg/fmap (fn [v] (ct/inst v))))
|
||||
:decode/string #(some-> % ct/inst)
|
||||
:encode/string #(some-> % inst-ms)
|
||||
:decode/json #(some-> % ct/inst)
|
||||
:encode/json #(some-> % inst-ms)}}))
|
||||
|
||||
(def ^:private schema:subscription
|
||||
[:map {:title "Subscription"}
|
||||
[:id ::sm/text]
|
||||
[:customer-id ::sm/text]
|
||||
[:type [:enum
|
||||
"unlimited"
|
||||
"professional"
|
||||
"enterprise"]]
|
||||
[:status [:enum
|
||||
"active"
|
||||
"canceled"
|
||||
"incomplete"
|
||||
"incomplete_expired"
|
||||
"past_due"
|
||||
"paused"
|
||||
"trialing"
|
||||
"unpaid"]]
|
||||
|
||||
[:billing-period [:enum
|
||||
"month"
|
||||
"day"
|
||||
"week"
|
||||
"year"]]
|
||||
[:quantity :int]
|
||||
[:description [:maybe ::sm/text]]
|
||||
[:created-at schema:timestamp]
|
||||
[:start-date [:maybe schema:timestamp]]
|
||||
[:ended-at [:maybe schema:timestamp]]
|
||||
[:trial-end [:maybe schema:timestamp]]
|
||||
[:trial-start [:maybe schema:timestamp]]
|
||||
[:cancel-at [:maybe schema:timestamp]]
|
||||
[:canceled-at [:maybe schema:timestamp]]
|
||||
[:current-period-end [:maybe schema:timestamp]]
|
||||
[:current-period-start [:maybe schema:timestamp]]
|
||||
[:cancel-at-period-end :boolean]
|
||||
|
||||
[:cancellation-details
|
||||
[:map {:title "CancellationDetails"}
|
||||
[:comment [:maybe ::sm/text]]
|
||||
[:reason [:maybe ::sm/text]]
|
||||
[:feedback [:maybe
|
||||
[:enum
|
||||
"customer_service"
|
||||
"low_quality"
|
||||
"missing_feature"
|
||||
"other"
|
||||
"switched_service"
|
||||
"too_complex"
|
||||
"too_expensive"
|
||||
"unused"]]]]]])
|
||||
|
||||
(def ^:private sql:get-customer-slots
|
||||
"WITH teams AS (
|
||||
SELECT tpr.team_id AS id,
|
||||
tpr.profile_id AS profile_id
|
||||
FROM team_profile_rel AS tpr
|
||||
WHERE tpr.is_owner IS true
|
||||
AND tpr.profile_id = ?
|
||||
), teams_with_slots AS (
|
||||
SELECT tpr.team_id AS id,
|
||||
count(*) AS total
|
||||
FROM team_profile_rel AS tpr
|
||||
WHERE tpr.team_id IN (SELECT id FROM teams)
|
||||
AND tpr.can_edit IS true
|
||||
GROUP BY 1
|
||||
ORDER BY 2
|
||||
)
|
||||
SELECT max(total) AS total FROM teams_with_slots;")
|
||||
|
||||
(defn- get-customer-slots
|
||||
[cfg profile-id]
|
||||
(let [result (db/exec-one! cfg [sql:get-customer-slots profile-id])]
|
||||
(:total result)))
|
||||
|
||||
(def ^:private schema:get-customer-params
|
||||
[:map])
|
||||
|
||||
(def ^:private schema:get-customer-result
|
||||
[:map
|
||||
[:id ::sm/uuid]
|
||||
[:name :string]
|
||||
[:num-editors ::sm/int]
|
||||
[:subscription {:optional true} schema:subscription]])
|
||||
|
||||
(sv/defmethod ::get-customer
|
||||
{::doc/added "2.12"
|
||||
::sm/params schema:get-customer-params
|
||||
::sm/result schema:get-customer-result}
|
||||
[cfg {:keys [::rpc/profile-id]}]
|
||||
(let [profile (profile/get-profile cfg profile-id)]
|
||||
{:id (get profile :id)
|
||||
:name (get profile :fullname)
|
||||
:email (get profile :email)
|
||||
:num-editors (get-customer-slots cfg profile-id)
|
||||
:subscription (-> profile :props :subscription)}))
|
||||
|
||||
|
||||
;; ---- RPC METHOD: GET-CUSTOMER
|
||||
|
||||
(def ^:private schema:update-customer-params
|
||||
[:map
|
||||
[:subscription [:maybe schema:subscription]]])
|
||||
|
||||
(def ^:private schema:update-customer-result
|
||||
[:map])
|
||||
|
||||
(sv/defmethod ::update-customer
|
||||
{::doc/added "2.12"
|
||||
::sm/params schema:update-customer-params
|
||||
::sm/result schema:update-customer-result}
|
||||
[cfg {:keys [::rpc/profile-id subscription]}]
|
||||
(let [{:keys [props] :as profile}
|
||||
(profile/get-profile cfg profile-id ::db/for-update true)
|
||||
|
||||
props
|
||||
(assoc props :subscription subscription)]
|
||||
|
||||
(l/dbg :hint "update customer"
|
||||
:profile-id (str profile-id)
|
||||
:subscription-type (get subscription :type)
|
||||
:subscription-status (get subscription :status)
|
||||
:subscription-quantity (get subscription :quantity))
|
||||
|
||||
(db/update! cfg :profile
|
||||
{:props (db/tjson props)}
|
||||
{:id profile-id}
|
||||
{::db/return-keys false})
|
||||
|
||||
nil))
|
||||
@@ -61,8 +61,8 @@
|
||||
:is-active is-active
|
||||
:password password
|
||||
:props {}}]
|
||||
(->> (cmd.auth/create-profile! conn params)
|
||||
(cmd.auth/create-profile-rels! conn)))))))
|
||||
(->> (cmd.auth/create-profile system params)
|
||||
(cmd.auth/create-profile-rels conn)))))))
|
||||
|
||||
(defmethod exec-command "update-profile"
|
||||
[{:keys [fullname email password is-active]}]
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
[app.db.sql :as-alias sql]
|
||||
[app.features.fdata :as fdata]
|
||||
[app.features.file-snapshots :as fsnap]
|
||||
[app.http.session :as session]
|
||||
[app.loggers.audit :as audit]
|
||||
[app.main :as main]
|
||||
[app.msgbus :as mbus]
|
||||
@@ -843,10 +844,33 @@
|
||||
:deleted-at deleted-at
|
||||
:id id})))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; SSO
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn add-sso-config
|
||||
[& {:keys [base-uri client-id client-secret domain]}]
|
||||
|
||||
(assert (and (string? base-uri) (str/starts-with? base-uri "http")) "expected a valid base-uri")
|
||||
(assert (string? client-id) "expected a valid client-id")
|
||||
(assert (string? client-secret) "expected a valid client-secret")
|
||||
(assert (string? domain) "expected a valid domain")
|
||||
(db/insert! main/system :sso-provider
|
||||
{:id (uuid/next)
|
||||
:type "oidc"
|
||||
:client-id client-id
|
||||
:client-secret client-secret
|
||||
:domain domain
|
||||
:base-uri base-uri}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; MISC
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn decode-session-token
|
||||
[token]
|
||||
(session/decode-token main/system token))
|
||||
|
||||
(defn instrument-var
|
||||
[var]
|
||||
(alter-var-root var (fn [f]
|
||||
|
||||
@@ -218,6 +218,9 @@
|
||||
(when (or (nil? revn) (= revn (:revn file)))
|
||||
file)))
|
||||
|
||||
;; FIXME: we should skip files that does not match the revn on the
|
||||
;; props and add proper schema for this task props
|
||||
|
||||
(defn- process-file!
|
||||
[cfg {:keys [file-id] :as props}]
|
||||
(if-let [file (get-file cfg props)]
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"A maintenance task that is responsible of properly scheduling the
|
||||
file-gc task for all files that matches the eligibility threshold."
|
||||
(:require
|
||||
[app.common.logging :as l]
|
||||
[app.common.time :as ct]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
@@ -21,25 +22,24 @@
|
||||
f.modified_at
|
||||
FROM file AS f
|
||||
WHERE f.has_media_trimmed IS false
|
||||
AND f.modified_at < now() - ?::interval
|
||||
AND f.modified_at < ?
|
||||
AND f.deleted_at IS NULL
|
||||
ORDER BY f.modified_at DESC
|
||||
FOR UPDATE OF f
|
||||
SKIP LOCKED")
|
||||
|
||||
(defn- get-candidates
|
||||
[{:keys [::db/conn ::min-age] :as cfg}]
|
||||
(let [min-age (db/interval min-age)]
|
||||
(db/plan conn [sql:get-candidates min-age] {:fetch-size 10})))
|
||||
|
||||
(defn- schedule!
|
||||
[cfg]
|
||||
[{:keys [::db/conn] :as cfg} threshold]
|
||||
(let [total (reduce (fn [total {:keys [id modified-at revn]}]
|
||||
(let [params {:file-id id :modified-at modified-at :revn revn}]
|
||||
(let [params {:file-id id :revn revn}]
|
||||
(l/trc :hint "schedule"
|
||||
:file-id (str id)
|
||||
:revn revn
|
||||
:modified-at (ct/format-inst modified-at))
|
||||
(wrk/submit! (assoc cfg ::wrk/params params))
|
||||
(inc total)))
|
||||
0
|
||||
(get-candidates cfg))]
|
||||
(db/plan conn [sql:get-candidates threshold] {:fetch-size 10}))]
|
||||
{:processed total}))
|
||||
|
||||
(defmethod ig/assert-key ::handler
|
||||
@@ -53,12 +53,12 @@
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ cfg]
|
||||
(fn [{:keys [props] :as task}]
|
||||
(let [min-age (ct/duration (or (:min-age props) (::min-age cfg)))]
|
||||
(let [threshold (-> (ct/duration (or (:min-age props) (::min-age cfg)))
|
||||
(ct/in-past))]
|
||||
(-> cfg
|
||||
(assoc ::db/rollback (:rollback? props))
|
||||
(assoc ::min-age min-age)
|
||||
(assoc ::wrk/task :file-gc)
|
||||
(assoc ::wrk/priority 10)
|
||||
(assoc ::wrk/mark-retries 0)
|
||||
(assoc ::wrk/delay 1000)
|
||||
(db/tx-run! schedule!)))))
|
||||
(assoc ::wrk/delay 10000)
|
||||
(db/tx-run! schedule! threshold)))))
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[app.common.exceptions :as ex]
|
||||
[selmer.parser :as sp]))
|
||||
|
||||
;; (sp/cache-off!)
|
||||
(sp/cache-off!)
|
||||
|
||||
(defn render
|
||||
[path context]
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(def ^:private sql:insert-new-task
|
||||
"insert into task (id, name, props, queue, label, priority, max_retries, scheduled_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, now() + ?)
|
||||
"insert into task (id, name, props, queue, label, priority, max_retries, created_at, modified_at, scheduled_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
returning id")
|
||||
|
||||
(def ^:private
|
||||
@@ -88,7 +88,7 @@
|
||||
AND queue=?
|
||||
AND label=?
|
||||
AND status = 'new'
|
||||
AND scheduled_at > now()")
|
||||
AND scheduled_at > ?")
|
||||
|
||||
(def ^:private schema:options
|
||||
[:map {:title "submit-options"}
|
||||
@@ -111,17 +111,19 @@
|
||||
|
||||
(check-options! options)
|
||||
|
||||
(let [duration (ct/duration delay)
|
||||
interval (db/interval duration)
|
||||
props (db/tjson params)
|
||||
id (uuid/next)
|
||||
tenant (cf/get :tenant)
|
||||
task (d/name task)
|
||||
queue (str/ffmt "%:%" tenant (d/name queue))
|
||||
conn (db/get-connectable options)
|
||||
deleted (when dedupe
|
||||
(-> (db/exec-one! conn [sql:remove-not-started-tasks task queue label])
|
||||
:next.jdbc/update-count))]
|
||||
(let [delay (ct/duration delay)
|
||||
now (ct/now)
|
||||
scheduled-at (-> (ct/plus now delay)
|
||||
(ct/truncate :millisecond))
|
||||
props (db/tjson params)
|
||||
id (uuid/next)
|
||||
tenant (cf/get :tenant)
|
||||
task (d/name task)
|
||||
queue (str/ffmt "%:%" tenant (d/name queue))
|
||||
conn (db/get-connectable options)
|
||||
deleted (when dedupe
|
||||
(-> (db/exec-one! conn [sql:remove-not-started-tasks task queue label now])
|
||||
(db/get-update-count)))]
|
||||
|
||||
(l/trc :hint "submit task"
|
||||
:name task
|
||||
@@ -129,11 +131,13 @@
|
||||
:queue queue
|
||||
:label label
|
||||
:dedupe (boolean dedupe)
|
||||
:delay (ct/format-duration duration)
|
||||
:delay (ct/format-duration delay)
|
||||
:replace (or deleted 0))
|
||||
|
||||
(db/exec-one! conn [sql:insert-new-task id task props queue
|
||||
label priority max-retries interval])
|
||||
label priority max-retries
|
||||
now now scheduled-at])
|
||||
|
||||
id))
|
||||
|
||||
(defn invoke!
|
||||
|
||||
@@ -158,7 +158,9 @@
|
||||
(inst-ms (:scheduled-at task)))
|
||||
(l/wrn :hint "skiping task, rescheduled"
|
||||
:task-id task-id
|
||||
:runner-id id)
|
||||
:runner-id id
|
||||
:scheduled-at (ct/format-inst (:scheduled-at task))
|
||||
:expected-scheduled-at (ct/format-inst scheduled-at))
|
||||
|
||||
:else
|
||||
(let [result (run-task cfg task)]
|
||||
@@ -179,7 +181,8 @@
|
||||
{:error explain
|
||||
:status "retry"
|
||||
:modified-at now
|
||||
:scheduled-at (ct/plus now delay)
|
||||
:scheduled-at (-> (ct/plus now delay)
|
||||
(ct/truncate :millisecond))
|
||||
:retry-num nretry}
|
||||
{:id (:id task)})
|
||||
nil))
|
||||
|
||||
@@ -104,13 +104,8 @@
|
||||
(assoc-in [:app.rpc/methods :app.setup/templates] templates)
|
||||
(dissoc :app.srepl/server
|
||||
:app.http/server
|
||||
:app.http/router
|
||||
:app.auth.oidc.providers/google
|
||||
:app.auth.oidc.providers/gitlab
|
||||
:app.auth.oidc.providers/github
|
||||
:app.auth.oidc.providers/generic
|
||||
:app.http/route
|
||||
:app.setup/templates
|
||||
:app.auth.oidc/routes
|
||||
:app.http.oauth/handler
|
||||
:app.notifications/handler
|
||||
:app.loggers.mattermost/reporter
|
||||
@@ -182,10 +177,10 @@
|
||||
:is-demo false}
|
||||
params)]
|
||||
(db/run! system
|
||||
(fn [{:keys [::db/conn]}]
|
||||
(fn [{:keys [::db/conn] :as cfg}]
|
||||
(->> params
|
||||
(cmd.auth/create-profile! conn)
|
||||
(cmd.auth/create-profile-rels! conn)))))))
|
||||
(cmd.auth/create-profile cfg)
|
||||
(cmd.auth/create-profile-rels conn)))))))
|
||||
|
||||
(defn create-project*
|
||||
([i params] (create-project* *system* i params))
|
||||
@@ -549,6 +544,44 @@
|
||||
(io/copy r sw)
|
||||
(.toString sw))))
|
||||
|
||||
(defn parse-sse
|
||||
[content]
|
||||
(let [state
|
||||
(reduce (fn [{:keys [events data event id] :as state} line]
|
||||
(cond
|
||||
;; empty line → dispatch event if we have data
|
||||
(str/blank? line)
|
||||
(if (seq data)
|
||||
(-> state
|
||||
(update :events conj {:event (or event "message")
|
||||
:data (-> (str/join "\n" data))})
|
||||
(assoc :data [] :event nil))
|
||||
state)
|
||||
|
||||
;; comment line (starts with :)
|
||||
(str/starts-with? line ":")
|
||||
state
|
||||
|
||||
:else
|
||||
(let [[field raw-value] (str/split line #":" 2)
|
||||
value (some-> raw-value (str/replace #"^ " ""))]
|
||||
(case field
|
||||
"data" (update state :data conj (or value ""))
|
||||
"event" (assoc state :event value)
|
||||
;; ignore retry and unknown fields
|
||||
state))))
|
||||
{:events [] :data [] :event nil}
|
||||
(str/split content #"\r?\n"))
|
||||
|
||||
;; handle unterminated last event (no trailing blank line)
|
||||
state (if (seq (:data state))
|
||||
(update state :events conj
|
||||
{:event (or (:event state) "message")
|
||||
:data (str/join "\n" (:data state))})
|
||||
state)]
|
||||
|
||||
(:events state)))
|
||||
|
||||
(defn consume-sse
|
||||
[callback]
|
||||
(let [{:keys [::yres/status ::yres/body ::yres/headers] :as response} (callback {})
|
||||
@@ -558,12 +591,9 @@
|
||||
(try
|
||||
(px/exec! :virtual #(rcp/write-body-to-stream body nil output))
|
||||
(into []
|
||||
(map (fn [event]
|
||||
(let [[item1 item2] (re-seq #"(.*): (.*)\n?" event)]
|
||||
|
||||
[(keyword (nth item1 2))
|
||||
(tr/decode-str (nth item2 2))])))
|
||||
(-> (slurp' input)
|
||||
(str/split "\n\n")))
|
||||
(map (fn [{:keys [event data]}]
|
||||
[(keyword event)
|
||||
(tr/decode-str data)]))
|
||||
(parse-sse (slurp' input)))
|
||||
(finally
|
||||
(.close input)))))
|
||||
|
||||
@@ -22,17 +22,6 @@
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
|
||||
(t/deftest authenticate-method
|
||||
(let [profile (th/create-profile* 1)
|
||||
token (#'sess/gen-token th/*system* {:profile-id (:id profile)})
|
||||
request {:params {:token token}}
|
||||
response (#'mgmt/authenticate th/*system* request)]
|
||||
|
||||
(t/is (= 200 (::yres/status response)))
|
||||
(t/is (= "authentication" (-> response ::yres/body :iss)))
|
||||
(t/is (= (:id profile) (-> response ::yres/body :uid)))))
|
||||
|
||||
(t/deftest get-customer-method
|
||||
(let [profile (th/create-profile* 1)
|
||||
request {:params {:id (:id profile)}}
|
||||
@@ -89,7 +78,3 @@
|
||||
|
||||
(let [subs' (-> response ::yres/body :subscription)]
|
||||
(t/is (= subs' subs))))))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns backend-tests.http-middleware-access-token-test
|
||||
(:require
|
||||
[app.db :as db]
|
||||
[app.http.access-token]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.access-token]
|
||||
[app.tokens :as tokens]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[mockery.core :refer [with-mocks]]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest soft-auth-middleware
|
||||
(let [profile (th/create-profile* 1)
|
||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
|
||||
|
||||
request (volatile! nil)
|
||||
handler (#'app.http.access-token/wrap-soft-auth
|
||||
(fn [req] (vreset! request req))
|
||||
th/*system*)]
|
||||
|
||||
(with-mocks [m1 {:target 'app.http.access-token/get-token
|
||||
:return nil}]
|
||||
(handler {})
|
||||
(t/is (= {} @request)))
|
||||
|
||||
(with-mocks [m1 {:target 'app.http.access-token/get-token
|
||||
:return (:token token)}]
|
||||
(handler {})
|
||||
|
||||
(let [token-id (get @request :app.http.access-token/id)]
|
||||
(t/is (= token-id (:id token)))))))
|
||||
|
||||
(t/deftest authz-middleware
|
||||
(let [profile (th/create-profile* 1)
|
||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
|
||||
request (volatile! {})
|
||||
handler (#'app.http.access-token/wrap-authz
|
||||
(fn [req] (vreset! request req))
|
||||
th/*system*)]
|
||||
|
||||
(handler nil)
|
||||
(t/is (nil? @request))
|
||||
|
||||
(handler {:app.http.access-token/id (:id token)})
|
||||
(t/is (= #{} (:app.http.access-token/perms @request)))
|
||||
(t/is (= (:id profile) (:app.http.access-token/profile-id @request)))))
|
||||
|
||||
135
backend/test/backend_tests/http_middleware_test.clj
Normal file
@@ -0,0 +1,135 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC
|
||||
|
||||
(ns backend-tests.http-middleware-test
|
||||
(:require
|
||||
[app.common.time :as ct]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.http.access-token]
|
||||
[app.http.middleware :as mw]
|
||||
[app.http.session :as session]
|
||||
[app.main :as-alias main]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.rpc.commands.access-token]
|
||||
[app.tokens :as tokens]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.test :as t]
|
||||
[mockery.core :refer [with-mocks]]
|
||||
[yetti.request :as yreq]
|
||||
[yetti.response :as yres]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(defrecord DummyRequest [headers cookies]
|
||||
yreq/IRequestCookies
|
||||
(get-cookie [_ name]
|
||||
{:value (get cookies name)})
|
||||
|
||||
yreq/IRequest
|
||||
(get-header [_ name]
|
||||
(get headers name)))
|
||||
|
||||
(t/deftest auth-middleware-1
|
||||
(let [request (volatile! nil)
|
||||
handler (#'app.http.middleware/wrap-auth
|
||||
(fn [req] (vreset! request req))
|
||||
{})]
|
||||
|
||||
(handler (->DummyRequest {} {}))
|
||||
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (->DummyRequest {"authorization" "Token aaaa"} {}))
|
||||
|
||||
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
||||
(t/is (= :token token-type))
|
||||
(t/is (= "aaaa" token))
|
||||
(t/is (nil? claims)))))
|
||||
|
||||
(t/deftest auth-middleware-2
|
||||
(let [request (volatile! nil)
|
||||
handler (#'app.http.middleware/wrap-auth
|
||||
(fn [req] (vreset! request req))
|
||||
{})]
|
||||
|
||||
(handler (->DummyRequest {} {}))
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {}))
|
||||
|
||||
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
||||
(t/is (= :bearer token-type))
|
||||
(t/is (= "aaaa" token))
|
||||
(t/is (nil? claims)))))
|
||||
|
||||
(t/deftest auth-middleware-3
|
||||
(let [request (volatile! nil)
|
||||
handler (#'app.http.middleware/wrap-auth
|
||||
(fn [req] (vreset! request req))
|
||||
{})]
|
||||
|
||||
(handler (->DummyRequest {} {}))
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (->DummyRequest {} {"auth-token" "foobar"}))
|
||||
|
||||
(let [{:keys [token claims] token-type :type} (get @request ::http/auth-data)]
|
||||
(t/is (= :cookie token-type))
|
||||
(t/is (= "foobar" token))
|
||||
(t/is (nil? claims)))))
|
||||
|
||||
(t/deftest shared-key-auth
|
||||
(let [handler (#'app.http.middleware/wrap-shared-key-auth
|
||||
(fn [req] {::yres/status 200})
|
||||
"secret-key")]
|
||||
|
||||
(let [response (handler (->DummyRequest {} {}))]
|
||||
(t/is (= 403 (::yres/status response))))
|
||||
|
||||
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key2"} {}))]
|
||||
(t/is (= 403 (::yres/status response))))
|
||||
|
||||
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
|
||||
(t/is (= 200 (::yres/status response))))))
|
||||
|
||||
(t/deftest access-token-authz
|
||||
(let [profile (th/create-profile* 1)
|
||||
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
|
||||
handler (#'app.http.access-token/wrap-authz identity th/*system*)]
|
||||
|
||||
(let [response (handler nil)]
|
||||
(t/is (nil? response)))
|
||||
|
||||
(let [response (handler {::http/auth-data {:type :token :token "foobar" :claims {:tid (:id token)}}})]
|
||||
(t/is (= #{} (:app.http.access-token/perms response)))
|
||||
(t/is (= (:id profile) (:app.http.access-token/profile-id response))))))
|
||||
|
||||
(t/deftest session-authz
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
profile (th/create-profile* 1)
|
||||
handler (-> (fn [req] req)
|
||||
(#'session/wrap-authz {::session/manager manager})
|
||||
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
||||
:cookie (partial session/decode-token cfg)}))
|
||||
|
||||
session (->> (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
|
||||
response (handler (->DummyRequest {} {"auth-token" (:token session)}))
|
||||
|
||||
{:keys [token claims] token-type :type}
|
||||
(get response ::http/auth-data)]
|
||||
|
||||
(t/is (= :cookie token-type))
|
||||
(t/is (= (:token session) token))
|
||||
(t/is (= "authentication" (:iss claims)))
|
||||
(t/is (= "penpot" (:aud claims)))
|
||||
(t/is (= (:id session) (:sid claims)))
|
||||
(t/is (= (:id profile) (:uid claims)))))
|
||||
@@ -23,7 +23,7 @@
|
||||
(smt/check!
|
||||
(smt/for [context (->> sg/int
|
||||
(sg/fmap (fn [_]
|
||||
(rpc.doc/prepare-openapi-context (::rpc/methods th/*system*)))))]
|
||||
(#'rpc.doc/openapi-context (::rpc/methods th/*system*)))))]
|
||||
(try
|
||||
(json/encode context)
|
||||
true
|
||||
|
||||
@@ -1048,6 +1048,12 @@
|
||||
(into [elem])
|
||||
(into (subvec without-elem insert-pos)))))))
|
||||
|
||||
(defn invert-map
|
||||
"Returns a map with keys and values swapped.
|
||||
If the input map has duplicate values, later entries overwrite earlier ones."
|
||||
[m]
|
||||
(into {} (map (fn [[k v]] [v k]) m)))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; String Functions
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -485,6 +485,13 @@
|
||||
(commit-change change1)
|
||||
(commit-change change2))))
|
||||
|
||||
(defn add-tokens-lib
|
||||
[state tokens-lib]
|
||||
(-> state
|
||||
(commit-change
|
||||
{:type :set-tokens-lib
|
||||
:tokens-lib tokens-lib})))
|
||||
|
||||
(defn delete-shape
|
||||
[file id]
|
||||
(commit-change
|
||||
|
||||
@@ -371,7 +371,7 @@
|
||||
[:set-tokens-lib
|
||||
[:map {:title "SetTokensLib"}
|
||||
[:type [:= :set-tokens-lib]]
|
||||
[:tokens-lib ::sm/any]]] ;; TODO: we should define a plain object schema for tokens-lib
|
||||
[:tokens-lib ctob/schema:tokens-lib]]]
|
||||
|
||||
[:set-token
|
||||
[:map {:title "SetTokenChange"}
|
||||
|
||||
@@ -72,9 +72,11 @@
|
||||
(= :bool (dm/get-prop shape :type))))
|
||||
|
||||
(defn text-shape?
|
||||
[shape]
|
||||
(and (some? shape)
|
||||
(= :text (dm/get-prop shape :type))))
|
||||
([shape]
|
||||
(and (some? shape)
|
||||
(= :text (dm/get-prop shape :type))))
|
||||
([objects id]
|
||||
(text-shape? (get objects id))))
|
||||
|
||||
(defn rect-shape?
|
||||
[shape]
|
||||
|
||||
@@ -1357,38 +1357,6 @@
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(defmethod migrate-data "0004-clean-shadow-color"
|
||||
[data _]
|
||||
(let [decode-color (sm/decoder types.color/schema:color sm/json-transformer)
|
||||
|
||||
clean-shadow-color
|
||||
(fn [color]
|
||||
(let [ref-id (get color :id)
|
||||
ref-file (get color :file-id)]
|
||||
(-> (d/without-qualified color)
|
||||
(select-keys [:opacity :color :gradient :image :ref-id :ref-file])
|
||||
(cond-> ref-id
|
||||
(assoc :ref-id ref-id))
|
||||
(cond-> ref-file
|
||||
(assoc :ref-file ref-file))
|
||||
(decode-color))))
|
||||
|
||||
clean-shadow
|
||||
(fn [shadow]
|
||||
(update shadow :color clean-shadow-color))
|
||||
|
||||
update-object
|
||||
(fn [object]
|
||||
(d/update-when object :shadow #(mapv clean-shadow %)))
|
||||
|
||||
update-container
|
||||
(fn [container]
|
||||
(d/update-when container :objects d/update-vals update-object))]
|
||||
|
||||
(-> data
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(defmethod migrate-data "0005-deprecate-image-type"
|
||||
[data _]
|
||||
(letfn [(update-object [object]
|
||||
@@ -1697,6 +1665,45 @@
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(defmethod migrate-data "0015-clean-shadow-color"
|
||||
[data _]
|
||||
(let [decode-shadow-color
|
||||
(sm/decoder ctss/schema:color sm/json-transformer)
|
||||
|
||||
clean-shadow-color
|
||||
(fn [color]
|
||||
(let [ref-id (get color :id)
|
||||
ref-file (get color :file-id)]
|
||||
(-> (d/without-qualified color)
|
||||
(select-keys ctss/color-attrs)
|
||||
(cond-> ref-id
|
||||
(assoc :ref-id ref-id))
|
||||
(cond-> ref-file
|
||||
(assoc :ref-file ref-file))
|
||||
(decode-shadow-color)
|
||||
(d/without-nils))))
|
||||
|
||||
clean-shadow
|
||||
(fn [shadow]
|
||||
(update shadow :color clean-shadow-color))
|
||||
|
||||
clean-xform
|
||||
(comp
|
||||
(keep clean-shadow)
|
||||
(filter ctss/valid-shadow?))
|
||||
|
||||
update-object
|
||||
(fn [object]
|
||||
(d/update-when object :shadow #(into [] clean-xform %)))
|
||||
|
||||
update-container
|
||||
(fn [container]
|
||||
(d/update-when container :objects d/update-vals update-object))]
|
||||
|
||||
(-> data
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
;; Copy fills from position-data to text nodes when all text nodes lack fills,
|
||||
;; all position-data have fills, and the counts match
|
||||
(defmethod migrate-data "0016-copy-fills-from-position-data-to-text-node"
|
||||
@@ -1818,7 +1825,6 @@
|
||||
"0002-clean-shape-interactions"
|
||||
"0003-fix-root-shape"
|
||||
"0003-convert-path-content-v2"
|
||||
"0004-clean-shadow-color"
|
||||
"0005-deprecate-image-type"
|
||||
"0006-fix-old-texts-fills"
|
||||
"0008-fix-library-colors-v4"
|
||||
@@ -1832,4 +1838,5 @@
|
||||
"0014-fix-tokens-lib-duplicate-ids"
|
||||
"0014-clear-components-nil-objects"
|
||||
"0015-fix-text-attrs-blank-strings"
|
||||
"0015-clean-shadow-color"
|
||||
"0016-copy-fills-from-position-data-to-text-node"]))
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
:login-with-ldap
|
||||
;; Uses any generic authentication provider that implements OIDC protocol as credentials.
|
||||
:login-with-oidc
|
||||
;; Allows registration with Open ID
|
||||
;; Enables custom SSO flow
|
||||
:login-with-custom-sso
|
||||
;; Allows registration with OIDC (takes effect only when general `registration` is disabled)
|
||||
:oidc-registration
|
||||
;; This logs to console the invitation tokens. It's useful in case the SMTP is not configured.
|
||||
:log-invitation-tokens})
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
(dm/export gtr/inverse-transform-matrix)
|
||||
(dm/export gtr/transform-rect)
|
||||
(dm/export gtr/calculate-geometry)
|
||||
(dm/export gtr/calculate-selrect)
|
||||
(dm/export gtr/update-group-selrect)
|
||||
(dm/export gtr/update-mask-selrect)
|
||||
(dm/export gtr/apply-transform)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[app.common.files.changes-builder :as pcb]
|
||||
[app.common.types.tokens-lib :as ctob]))
|
||||
|
||||
(defn generate-update-active-sets
|
||||
(defn- generate-update-active-sets
|
||||
"Copy the active sets from the currently active themes and move them
|
||||
to the hidden token theme and update the theme with
|
||||
`update-theme-fn`.
|
||||
@@ -28,12 +28,45 @@
|
||||
(pcb/set-token-theme (ctob/get-id hidden-theme)
|
||||
hidden-theme'))))
|
||||
|
||||
(defn generate-set-enabled-token-set
|
||||
"Enable or disable a token set at `set-name` in `tokens-lib` without modifying a user theme."
|
||||
[changes tokens-lib set-name enabled?]
|
||||
(if enabled?
|
||||
(generate-update-active-sets changes tokens-lib #(ctob/enable-set % set-name))
|
||||
(generate-update-active-sets changes tokens-lib #(ctob/disable-set % set-name))))
|
||||
|
||||
(defn generate-toggle-token-set
|
||||
"Toggle a token set at `set-name` in `tokens-lib` without modifying a
|
||||
user theme."
|
||||
"Toggle a token set at `set-name` in `tokens-lib` without modifying a user theme."
|
||||
[changes tokens-lib set-name]
|
||||
(generate-update-active-sets changes tokens-lib #(ctob/toggle-set % set-name)))
|
||||
|
||||
(defn- generate-update-active-token-theme
|
||||
"Change the active state of a theme in `tokens-lib`. If after the change there is
|
||||
any active theme other than the hidden one, deactivate the hidden theme."
|
||||
[changes tokens-lib update-fn]
|
||||
(let [active-token-themes (some-> tokens-lib
|
||||
(update-fn)
|
||||
(ctob/get-active-theme-paths))
|
||||
active-token-themes' (if (= active-token-themes #{ctob/hidden-theme-path})
|
||||
active-token-themes
|
||||
(disj active-token-themes ctob/hidden-theme-path))]
|
||||
(pcb/set-active-token-themes changes active-token-themes')))
|
||||
|
||||
(defn generate-set-active-token-theme
|
||||
"Activate or deactivate a token theme in `tokens-lib`."
|
||||
[changes tokens-lib id active?]
|
||||
(if active?
|
||||
(generate-update-active-token-theme changes tokens-lib
|
||||
#(ctob/activate-theme % id))
|
||||
(generate-update-active-token-theme changes tokens-lib
|
||||
#(ctob/deactivate-theme % id))))
|
||||
|
||||
(defn generate-toggle-token-theme
|
||||
"Toggle the active state of a token theme in `tokens-lib`."
|
||||
[changes tokens-lib id]
|
||||
(generate-update-active-token-theme changes tokens-lib
|
||||
#(ctob/toggle-theme-active % id)))
|
||||
|
||||
(defn toggle-token-set-group
|
||||
"Toggle a token set group at `group-path` in `tokens-lib` for a `tokens-lib-theme`."
|
||||
[group-path tokens-lib tokens-lib-theme]
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
(defn type
|
||||
[s]
|
||||
(m/-type s))
|
||||
(m/type s default-options))
|
||||
|
||||
(defn properties
|
||||
[s]
|
||||
@@ -46,6 +46,10 @@
|
||||
[s]
|
||||
(m/type-properties s))
|
||||
|
||||
(defn children
|
||||
[s]
|
||||
(m/children s default-options))
|
||||
|
||||
(defn schema
|
||||
[s]
|
||||
(if (schema? s)
|
||||
@@ -127,9 +131,19 @@
|
||||
|
||||
(defn keys
|
||||
"Given a map schema, return all keys as set"
|
||||
[schema]
|
||||
(->> (entries schema)
|
||||
(into #{} xf:map-key)))
|
||||
[schema']
|
||||
(let [schema' (m/schema schema' default-options)]
|
||||
(case (m/type schema')
|
||||
:map
|
||||
(->> (entries schema')
|
||||
(into #{} xf:map-key))
|
||||
|
||||
:merge
|
||||
(->> (m/children schema')
|
||||
(mapcat m/entries)
|
||||
(into #{} xf:map-key))
|
||||
|
||||
(throw (ex-info "not supported schema type" {:type (m/type schema')})))))
|
||||
|
||||
(defn update-properties
|
||||
[s f & args]
|
||||
|
||||
@@ -732,89 +732,89 @@
|
||||
[shape scale-text-content value]
|
||||
(update shape :content scale-text-content value))
|
||||
|
||||
(defn scale-text-content
|
||||
[content value]
|
||||
(->> content
|
||||
(txt/transform-nodes txt/is-text-node? (partial transform-text-node value))
|
||||
(txt/transform-nodes txt/is-paragraph-node? (partial transform-paragraph-node value))))
|
||||
|
||||
(defn apply-scale-content
|
||||
[shape value]
|
||||
;; Scale can only be positive
|
||||
(let [value (mth/abs value)]
|
||||
(cond-> shape
|
||||
(cfh/text-shape? shape)
|
||||
(update-text-content scale-text-content value)
|
||||
|
||||
:always
|
||||
(gsc/update-corners-scale value)
|
||||
|
||||
(d/not-empty? (:strokes shape))
|
||||
(gss/update-strokes-width value)
|
||||
|
||||
(d/not-empty? (:shadow shape))
|
||||
(gse/update-shadows-scale value)
|
||||
|
||||
(some? (:blur shape))
|
||||
(gse/update-blur-scale value)
|
||||
|
||||
(ctl/flex-layout? shape)
|
||||
(ctl/update-flex-scale value)
|
||||
|
||||
(ctl/grid-layout? shape)
|
||||
(ctl/update-grid-scale value)
|
||||
|
||||
:always
|
||||
(ctl/update-flex-child value))))
|
||||
|
||||
(defn remove-children-set
|
||||
[shapes children-to-remove]
|
||||
(let [remove? (set children-to-remove)]
|
||||
(d/removev remove? shapes)))
|
||||
|
||||
(defn apply-modifier
|
||||
[shape operation]
|
||||
(let [type (dm/get-prop operation :type)]
|
||||
(case type
|
||||
:rotation
|
||||
(let [rotation (dm/get-prop operation :value)]
|
||||
(update shape :rotation #(mod (+ (or % 0) rotation) 360)))
|
||||
|
||||
:add-children
|
||||
(let [value (dm/get-prop operation :value)
|
||||
index (dm/get-prop operation :index)
|
||||
|
||||
shape
|
||||
(if (some? index)
|
||||
(update shape :shapes
|
||||
(fn [shapes]
|
||||
(if (vector? shapes)
|
||||
(d/insert-at-index shapes index value)
|
||||
(d/concat-vec shapes value))))
|
||||
(update shape :shapes d/concat-vec value))]
|
||||
|
||||
;; Remove duplication
|
||||
(update shape :shapes #(into [] (apply d/ordered-set %))))
|
||||
|
||||
:remove-children
|
||||
(let [value (dm/get-prop operation :value)]
|
||||
(update shape :shapes remove-children-set value))
|
||||
|
||||
:scale-content
|
||||
(let [value (dm/get-prop operation :value)]
|
||||
(apply-scale-content shape value))
|
||||
|
||||
:change-property
|
||||
(let [property (dm/get-prop operation :property)
|
||||
value (dm/get-prop operation :value)]
|
||||
(assoc shape property value))
|
||||
|
||||
;; :default => no change to shape
|
||||
shape)))
|
||||
|
||||
(defn apply-structure-modifiers
|
||||
"Apply structure changes to a shape"
|
||||
[shape modifiers]
|
||||
(letfn [(scale-text-content
|
||||
[content value]
|
||||
(->> content
|
||||
(txt/transform-nodes txt/is-text-node? (partial transform-text-node value))
|
||||
(txt/transform-nodes txt/is-paragraph-node? (partial transform-paragraph-node value))))
|
||||
|
||||
(apply-scale-content
|
||||
[shape value]
|
||||
;; Scale can only be positive
|
||||
(let [value (mth/abs value)]
|
||||
(cond-> shape
|
||||
(cfh/text-shape? shape)
|
||||
(update-text-content scale-text-content value)
|
||||
|
||||
:always
|
||||
(gsc/update-corners-scale value)
|
||||
|
||||
(d/not-empty? (:strokes shape))
|
||||
(gss/update-strokes-width value)
|
||||
|
||||
(d/not-empty? (:shadow shape))
|
||||
(gse/update-shadows-scale value)
|
||||
|
||||
(some? (:blur shape))
|
||||
(gse/update-blur-scale value)
|
||||
|
||||
(ctl/flex-layout? shape)
|
||||
(ctl/update-flex-scale value)
|
||||
|
||||
(ctl/grid-layout? shape)
|
||||
(ctl/update-grid-scale value)
|
||||
|
||||
:always
|
||||
(ctl/update-flex-child value))))]
|
||||
|
||||
(let [remove-children
|
||||
(fn [shapes children-to-remove]
|
||||
(let [remove? (set children-to-remove)]
|
||||
(d/removev remove? shapes)))
|
||||
|
||||
apply-modifier
|
||||
(fn [shape operation]
|
||||
(let [type (dm/get-prop operation :type)]
|
||||
(case type
|
||||
:rotation
|
||||
(let [rotation (dm/get-prop operation :value)]
|
||||
(update shape :rotation #(mod (+ (or % 0) rotation) 360)))
|
||||
|
||||
:add-children
|
||||
(let [value (dm/get-prop operation :value)
|
||||
index (dm/get-prop operation :index)
|
||||
|
||||
shape
|
||||
(if (some? index)
|
||||
(update shape :shapes
|
||||
(fn [shapes]
|
||||
(if (vector? shapes)
|
||||
(d/insert-at-index shapes index value)
|
||||
(d/concat-vec shapes value))))
|
||||
(update shape :shapes d/concat-vec value))]
|
||||
|
||||
;; Remove duplication
|
||||
(update shape :shapes #(into [] (apply d/ordered-set %))))
|
||||
|
||||
:remove-children
|
||||
(let [value (dm/get-prop operation :value)]
|
||||
(update shape :shapes remove-children value))
|
||||
|
||||
:scale-content
|
||||
(let [value (dm/get-prop operation :value)]
|
||||
(apply-scale-content shape value))
|
||||
|
||||
:change-property
|
||||
(let [property (dm/get-prop operation :property)
|
||||
value (dm/get-prop operation :value)]
|
||||
(assoc shape property value))
|
||||
|
||||
;; :default => no change to shape
|
||||
shape)))]
|
||||
|
||||
(as-> shape $
|
||||
(reduce apply-modifier $ (dm/get-prop modifiers :structure-parent))
|
||||
(reduce apply-modifier $ (dm/get-prop modifiers :structure-child))))))
|
||||
(as-> shape $
|
||||
(reduce apply-modifier $ (dm/get-prop modifiers :structure-parent))
|
||||
(reduce apply-modifier $ (dm/get-prop modifiers :structure-child))))
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
|
||||
(def styles #{:drop-shadow :inner-shadow})
|
||||
|
||||
(def schema:color
|
||||
[:merge {:title "ShadowColor"}
|
||||
ctc/schema:color-attrs
|
||||
ctc/schema:plain-color])
|
||||
|
||||
(def color-attrs
|
||||
(sm/keys schema:color))
|
||||
|
||||
(def schema:shadow
|
||||
[:map {:title "Shadow"}
|
||||
[:id [:maybe ::sm/uuid]]
|
||||
@@ -20,7 +28,7 @@
|
||||
[:blur ::sm/safe-number]
|
||||
[:spread ::sm/safe-number]
|
||||
[:hidden :boolean]
|
||||
[:color ctc/schema:color]])
|
||||
[:color schema:color]])
|
||||
|
||||
(def check-shadow
|
||||
(sm/check-fn schema:shadow))
|
||||
|
||||
@@ -310,6 +310,10 @@
|
||||
schema:text-decoration
|
||||
schema:dimensions])
|
||||
|
||||
(defn token-attr?
|
||||
[attr]
|
||||
(contains? all-keys attr))
|
||||
|
||||
(defn shape-attr->token-attrs
|
||||
([shape-attr] (shape-attr->token-attrs shape-attr nil))
|
||||
([shape-attr changed-sub-attr]
|
||||
@@ -403,15 +407,15 @@
|
||||
:text text-attributes
|
||||
nil))
|
||||
|
||||
(defn appliable-attrs
|
||||
(defn appliable-attrs-for-shape
|
||||
"Returns intersection of shape `attributes` for `shape-type`."
|
||||
[attributes shape-type is-layout]
|
||||
(set/intersection attributes (shape-type->attributes shape-type is-layout)))
|
||||
|
||||
(defn any-appliable-attr?
|
||||
(defn any-appliable-attr-for-shape?
|
||||
"Checks if `token-type` supports given shape `attributes`."
|
||||
[attributes token-type is-layout]
|
||||
(seq (appliable-attrs attributes token-type is-layout)))
|
||||
(d/not-empty? (appliable-attrs-for-shape attributes token-type is-layout)))
|
||||
|
||||
;; Token attrs that are set inside content blocks of text shapes, instead
|
||||
;; at the shape level.
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
(ns app.common.types.tokens-lib
|
||||
(:require
|
||||
#?(:clj [app.common.fressian :as fres])
|
||||
#?(:clj [clojure.data.json :as json])
|
||||
#?(:clj [clojure.data.json :as c.json])
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.json :as json]
|
||||
[app.common.path-names :as cpn]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.generators :as sg]
|
||||
@@ -198,8 +199,8 @@
|
||||
:tokens tokens})
|
||||
|
||||
#?@(:clj
|
||||
[json/JSONWriter
|
||||
(-write [this writter options] (json/-write (datafy this) writter options))])
|
||||
[c.json/JSONWriter
|
||||
(-write [this writter options] (c.json/-write (datafy this) writter options))])
|
||||
|
||||
INamedItem
|
||||
(get-id [_]
|
||||
@@ -758,7 +759,7 @@
|
||||
(theme-active? [_ id] "predicate if token theme is active")
|
||||
(activate-theme [_ id] "adds theme from the active-themes")
|
||||
(deactivate-theme [_ id] "removes theme from the active-themes")
|
||||
(toggle-theme-active? [_ id] "toggles theme in the active-themes")
|
||||
(toggle-theme-active [_ id] "toggles theme in the active-themes")
|
||||
(get-hidden-theme [_] "get the hidden temporary theme"))
|
||||
|
||||
(def schema:token-themes
|
||||
@@ -901,6 +902,7 @@
|
||||
(delete-token [_ set-id token-id] "delete a token from a set")
|
||||
(toggle-set-in-theme [_ theme-id set-name] "toggle a set used / not used in a theme")
|
||||
(get-active-themes-set-names [_] "set of set names that are active in the the active themes")
|
||||
(token-set-active? [_ set-name] "if a set is active in any of the active themes")
|
||||
(sets-at-path-all-active? [_ group-path] "compute active state for child sets at `group-path`.
|
||||
Will return a value that matches this schema:
|
||||
`:none` None of the nested sets are active
|
||||
@@ -911,6 +913,7 @@ Will return a value that matches this schema:
|
||||
(get-tokens [_ set-id] "return a map of tokens in the set, indexed by token-name"))
|
||||
|
||||
(declare parse-multi-set-dtcg-json)
|
||||
(declare read-multi-set-dtcg)
|
||||
(declare export-dtcg-json)
|
||||
|
||||
(deftype TokensLib [sets themes active-themes]
|
||||
@@ -922,23 +925,23 @@ Will return a value that matches this schema:
|
||||
:active-themes active-themes})
|
||||
|
||||
#?@(:clj
|
||||
[json/JSONWriter
|
||||
(-write [this writter options] (json/-write (export-dtcg-json this) writter options))])
|
||||
[c.json/JSONWriter
|
||||
(-write [this writter options] (c.json/-write (export-dtcg-json this) writter options))])
|
||||
|
||||
ITokenSets
|
||||
; Naming conventions:
|
||||
; (TODO: this will disappear after refactoring the internal structure of TokensLib).
|
||||
; Set name: the complete name as a string, without prefix \"some-group/some-subgroup/some-set\".
|
||||
; Set final name or fname: the last part of the name \"some-set\".
|
||||
; Set path: the groups part of the name, as a vector [\"some-group\" \"some-subgroup\"].
|
||||
; Set path str: the set path as a string \"some-group/some-subgroup\".
|
||||
; Set full path: the path including the fname, as a vector [\"some-group\", \"some-subgroup\", \"some-set\"].
|
||||
; Set full path str: the set full path as a string \"some-group/some-subgroup/some-set\".
|
||||
|
||||
; Set prefix: the two-characters prefix added to a full path item \"G-\" / \"S-\".
|
||||
; Prefixed set path or ppath: a path wit added prefixes [\"G-some-group\", \"G-some-subgroup\"].
|
||||
; Prefixed set full path or pfpath: a full path wit prefixes [\"G-some-group\", \"G-some-subgroup\", \"S-some-set\"].
|
||||
; Prefixed set final name or pfname: a final name with prefix \"S-some-set\".
|
||||
;; Naming conventions:
|
||||
;; (TODO: this will disappear after refactoring the internal structure of TokensLib).
|
||||
;; Set name: the complete name as a string, without prefix \"some-group/some-subgroup/some-set\".
|
||||
;; Set final name or fname: the last part of the name \"some-set\".
|
||||
;; Set path: the groups part of the name, as a vector [\"some-group\" \"some-subgroup\"].
|
||||
;; Set path str: the set path as a string \"some-group/some-subgroup\".
|
||||
;; Set full path: the path including the fname, as a vector [\"some-group\", \"some-subgroup\", \"some-set\"].
|
||||
;; Set full path str: the set full path as a string \"some-group/some-subgroup/some-set\".
|
||||
;
|
||||
;; Set prefix: the two-characters prefix added to a full path item \"G-\" / \"S-\".
|
||||
;; Prefixed set path or ppath: a path wit added prefixes [\"G-some-group\", \"G-some-subgroup\"].
|
||||
;; Prefixed set full path or pfpath: a full path wit prefixes [\"G-some-group\", \"G-some-subgroup\", \"S-some-set\"].
|
||||
;; Prefixed set final name or pfname: a final name with prefix \"S-some-set\".
|
||||
(add-set [_ token-set]
|
||||
(assert (token-set? token-set) "expected valid token-set")
|
||||
(let [path (get-set-prefixed-path token-set)]
|
||||
@@ -1206,7 +1209,7 @@ Will return a value that matches this schema:
|
||||
(when-let [theme (get-theme this id)]
|
||||
(contains? active-themes (get-theme-path theme))))
|
||||
|
||||
(toggle-theme-active? [this id]
|
||||
(toggle-theme-active [this id]
|
||||
(if (theme-active? this id)
|
||||
(deactivate-theme this id)
|
||||
(activate-theme this id)))
|
||||
@@ -1270,6 +1273,10 @@ Will return a value that matches this schema:
|
||||
(mapcat :sets)
|
||||
(get-active-themes this)))
|
||||
|
||||
(token-set-active? [this set-name]
|
||||
(let [set-names (get-active-themes-set-names this)]
|
||||
(contains? set-names set-name)))
|
||||
|
||||
(sets-at-path-all-active? [this group-path]
|
||||
(let [active-set-names (get-active-themes-set-names this)
|
||||
prefixed-path-str (set-group-path->set-group-prefixed-path-str group-path)]
|
||||
@@ -1404,7 +1411,11 @@ Will return a value that matches this schema:
|
||||
;; function that is declared but not defined; so we need to pass
|
||||
;; an anonymous function and delegate the resolution to runtime
|
||||
{:encode/json #(export-dtcg-json %)
|
||||
:decode/json #(parse-multi-set-dtcg-json %)}}))
|
||||
:decode/json #(read-multi-set-dtcg %)
|
||||
;; FIXME: add better, more reallistic generator
|
||||
:gen/gen (->> (sg/small-int)
|
||||
(sg/fmap (fn [_]
|
||||
(make-tokens-lib))))}}))
|
||||
|
||||
(defn duplicate-set
|
||||
"Make a new set with a unique name, copying data from the given set in the lib."
|
||||
@@ -1448,18 +1459,23 @@ Will return a value that matches this schema:
|
||||
["value" :map]
|
||||
["type" :string]]]))
|
||||
|
||||
(def ^:private schema:dtcg-node
|
||||
[:schema {:registry
|
||||
{::simple-value
|
||||
[:or :string :int :double]
|
||||
::value
|
||||
[:or
|
||||
[:ref ::simple-value]
|
||||
[:vector ::simple-value]
|
||||
[:map-of :string [:or
|
||||
[:ref ::simple-value]
|
||||
[:vector ::simple-value]]]]}}
|
||||
[:map
|
||||
["$type" :string]
|
||||
["$value" [:ref ::value]]]])
|
||||
|
||||
(def ^:private dtcg-node?
|
||||
(sm/validator
|
||||
[:or
|
||||
[:map
|
||||
["$value" :string]
|
||||
["$type" :string]]
|
||||
[:map
|
||||
["$value" [:sequential [:map ["$type" :string]]]]
|
||||
["$type" :string]]
|
||||
[:map
|
||||
["$value" :map]
|
||||
["$type" :string]]]))
|
||||
(sm/validator schema:dtcg-node))
|
||||
|
||||
(defn- get-json-format
|
||||
"Searches through decoded token file and returns:
|
||||
@@ -1646,6 +1662,43 @@ Will return a value that matches this schema:
|
||||
(assert (= (get-json-format decoded-json-tokens) :json-format/legacy) "expected a legacy format for `decoded-json-tokens`")
|
||||
(parse-single-set-dtcg-json set-name (legacy-json->dtcg-json decoded-json-tokens)))
|
||||
|
||||
(def ^:private schema:multi-set-dtcg
|
||||
"Schema for penpot multi-set dtcg json decoded data/
|
||||
|
||||
Mainly used for validate the structure of the incoming data before
|
||||
proceed to parse it to our internal data structures."
|
||||
[:schema {:registry
|
||||
{::node
|
||||
[:or
|
||||
[:map-of :string [:ref ::node]]
|
||||
schema:dtcg-node]}}
|
||||
[:map
|
||||
["$themes" {:optional true}
|
||||
[:vector
|
||||
[:map {:title "Theme"}
|
||||
["id" {:optional true} :string]
|
||||
["name" :string]
|
||||
["description" :string]
|
||||
["isSource" :boolean]
|
||||
["selectedTokenSets"
|
||||
[:map-of :string [:enum "enabled" "disabled"]]]]]]
|
||||
["$metadata" {:optional true}
|
||||
[:map {:title "Metadata"}
|
||||
["tokenSetOrder" {:optional true} [:vector :string]]
|
||||
["activeThemes" {:optional true} [:vector :string]]
|
||||
["activeSets" {:optional true} [:vector :string]]]]
|
||||
|
||||
[:malli.core/default
|
||||
[:map-of :string [:ref ::node]]]]])
|
||||
|
||||
(def ^:private check-multi-set-dtcg-data
|
||||
(sm/check-fn schema:multi-set-dtcg))
|
||||
|
||||
(def ^:private decode-multi-set-dtcg-data
|
||||
(sm/decoder schema:multi-set-dtcg
|
||||
sm/json-transformer))
|
||||
|
||||
;; FIXME: remove `-json` suffix
|
||||
(defn parse-multi-set-dtcg-json
|
||||
"Parse a decoded json file with multi sets in DTCG format into a TokensLib."
|
||||
[decoded-json]
|
||||
@@ -1685,10 +1738,10 @@ Will return a value that matches this schema:
|
||||
(uuid/next))
|
||||
:name (get theme "name")
|
||||
:group (get theme "group")
|
||||
:is-source (get theme "is-source")
|
||||
:is-source (or (get theme "isSource")
|
||||
;; NOTE: backward compatibility
|
||||
(get theme "is-source"))
|
||||
:external-id (get theme "id")
|
||||
:modified-at (some-> (get theme "modified-at")
|
||||
(ct/inst))
|
||||
:sets (into #{}
|
||||
(comp (map key)
|
||||
xf-normalize-set-name
|
||||
@@ -1736,6 +1789,23 @@ Will return a value that matches this schema:
|
||||
|
||||
library))
|
||||
|
||||
(defn read-multi-set-dtcg
|
||||
"Read penpot multi-set dctg tokens. Accepts string or JSON decoded
|
||||
data (without any case transformation). Used as schema decoder and
|
||||
in the SDK."
|
||||
[data]
|
||||
(let [data (if (string? data)
|
||||
(json/decode data :key-fn identity)
|
||||
data)
|
||||
data #?(:cljs (if (object? data)
|
||||
(json/->clj data :key-fn identity)
|
||||
data)
|
||||
:clj data)
|
||||
|
||||
data (decode-multi-set-dtcg-data data)]
|
||||
(-> (check-multi-set-dtcg-data data)
|
||||
(parse-multi-set-dtcg-json))))
|
||||
|
||||
(defn- parse-multi-set-legacy-json
|
||||
"Parse a decoded json file with multi sets in legacy format into a TokensLib."
|
||||
[decoded-json]
|
||||
@@ -1748,6 +1818,7 @@ Will return a value that matches this schema:
|
||||
(parse-multi-set-dtcg-json (merge other-data
|
||||
dtcg-sets-data))))
|
||||
|
||||
;; FIXME: remove `-json` suffix
|
||||
(defn parse-decoded-json
|
||||
"Guess the format and content type of the decoded json file and parse it into a TokensLib.
|
||||
The `file-name` is used to determine the set name when the json file contains a single set."
|
||||
@@ -1817,15 +1888,15 @@ Will return a value that matches this schema:
|
||||
(filter #(and (instance? TokenTheme %)
|
||||
(not (hidden-theme? %))))
|
||||
(map (fn [token-theme]
|
||||
(let [theme-map (->> token-theme
|
||||
(into {})
|
||||
walk/stringify-keys)]
|
||||
(-> theme-map
|
||||
(set/rename-keys {"sets" "selectedTokenSets"
|
||||
"external-id" "id"})
|
||||
(update "selectedTokenSets" (fn [sets]
|
||||
(->> (for [s sets] [s "enabled"])
|
||||
(into {})))))))))
|
||||
;; NOTE: this probaly can be implemented as type method
|
||||
(d/without-nils
|
||||
{"id" (:external-id token-theme)
|
||||
"name" (:name token-theme)
|
||||
"group" (:group token-theme)
|
||||
"description" (:description token-theme)
|
||||
"isSource" (:is-source token-theme)
|
||||
"selectedTokenSets" (reduce #(assoc %1 %2 "enabled") {} (:sets token-theme))}))))
|
||||
|
||||
themes
|
||||
(->> (get-theme-tree tokens-lib)
|
||||
(tree-seq d/ordered-map? vals)
|
||||
@@ -1835,29 +1906,34 @@ Will return a value that matches this schema:
|
||||
active-themes
|
||||
(-> (get-active-theme-paths tokens-lib)
|
||||
(disj hidden-theme-path))]
|
||||
{:themes themes
|
||||
:active-themes active-themes}))
|
||||
[themes active-themes]))
|
||||
|
||||
(defn export-dtcg-multi-file
|
||||
"Convert a TokensLib into a plain clojure map, suitable to be encoded as a multi json files each encoded in DTCG format."
|
||||
[tokens-lib]
|
||||
(let [{:keys [themes active-themes]} (dtcg-export-themes tokens-lib)
|
||||
sets (->> (get-sets tokens-lib)
|
||||
(map (fn [token-set]
|
||||
(let [name (get-name token-set)
|
||||
tokens (get-tokens- token-set)]
|
||||
[(str name ".json") (tokens-tree tokens :update-token-fn token->dtcg-token)])))
|
||||
(into {}))]
|
||||
(let [[themes active-themes]
|
||||
(dtcg-export-themes tokens-lib)
|
||||
|
||||
sets
|
||||
(->> (get-sets tokens-lib)
|
||||
(map (fn [token-set]
|
||||
(let [name (get-name token-set)
|
||||
tokens (get-tokens- token-set)]
|
||||
[(str name ".json") (tokens-tree tokens :update-token-fn token->dtcg-token)])))
|
||||
(into {}))]
|
||||
|
||||
(-> sets
|
||||
(assoc "$themes.json" themes)
|
||||
(assoc "$metadata.json" {"tokenSetOrder" (get-set-names tokens-lib)
|
||||
"activeThemes" active-themes
|
||||
"activeSets" (get-active-themes-set-names tokens-lib)}))))
|
||||
(assoc "$metadata.json"
|
||||
{"tokenSetOrder" (get-set-names tokens-lib)
|
||||
"activeThemes" active-themes
|
||||
"activeSets" (get-active-themes-set-names tokens-lib)}))))
|
||||
|
||||
(defn export-dtcg-json
|
||||
"Convert a TokensLib into a plain clojure map, suitable to be encoded as a multi sets json string in DTCG format."
|
||||
[tokens-lib]
|
||||
(let [{:keys [themes active-themes]} (dtcg-export-themes tokens-lib)
|
||||
(let [[themes active-themes]
|
||||
(dtcg-export-themes tokens-lib)
|
||||
|
||||
name-set-tuples
|
||||
(->> (get-set-tree tokens-lib)
|
||||
|
||||
@@ -310,3 +310,17 @@
|
||||
the real name of the shape joined by the properties values separated by '/'"
|
||||
[variant]
|
||||
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
|
||||
|
||||
(defn find-boolean-pair
|
||||
"Given a vector, return the map from 'bool-values' that contains both as keys.
|
||||
Returns nil if none match."
|
||||
[v]
|
||||
(let [bool-values [{"on" true "off" false}
|
||||
{"yes" true "no" false}
|
||||
{"true" true "false" false}]]
|
||||
(when (= (count v) 2)
|
||||
(some (fn [b]
|
||||
(when (and (contains? b (first v))
|
||||
(contains? b (last v)))
|
||||
b))
|
||||
bool-values))))
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:refer-clojure :exclude [uri?])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[cuerdas.core :as str]
|
||||
[lambdaisland.uri :as u]
|
||||
[lambdaisland.uri.normalize :as un])
|
||||
#?(:clj
|
||||
@@ -58,6 +59,14 @@
|
||||
(map (fn [[k v]] [(key-fn k) (value-fn v)]))))
|
||||
(u/map->query-string))))
|
||||
|
||||
(defn ensure-path-slash
|
||||
[u]
|
||||
(update (uri u) :path
|
||||
(fn [path]
|
||||
(if (str/ends-with? path "/")
|
||||
path
|
||||
(str path "/")))))
|
||||
|
||||
#?(:clj
|
||||
(defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer]
|
||||
(.write writer "#")
|
||||
|
||||
@@ -1440,8 +1440,7 @@
|
||||
result (ctob/export-dtcg-json tokens-lib)
|
||||
expected {"$themes" [{"description" ""
|
||||
"group" "group-1"
|
||||
"is-source" false
|
||||
"modified-at" now
|
||||
"isSource" false
|
||||
"id" "test-id-00"
|
||||
"name" "theme-1"
|
||||
"selectedTokenSets" {"core" "enabled"}}]
|
||||
@@ -1558,12 +1557,11 @@
|
||||
:external-id "test-id-01"
|
||||
:modified-at now
|
||||
:sets #{"core"}))
|
||||
(ctob/toggle-theme-active? (thi/id :theme-1)))
|
||||
(ctob/toggle-theme-active (thi/id :theme-1)))
|
||||
result (ctob/export-dtcg-json tokens-lib)
|
||||
expected {"$themes" [{"description" ""
|
||||
"group" "group-1"
|
||||
"is-source" false
|
||||
"modified-at" now
|
||||
"isSource" false
|
||||
"id" "test-id-01"
|
||||
"name" "theme-1"
|
||||
"selectedTokenSets" {"core" "enabled"}}]
|
||||
@@ -1612,12 +1610,11 @@
|
||||
:external-id "test-id-01"
|
||||
:modified-at now
|
||||
:sets #{"some/set"}))
|
||||
(ctob/toggle-theme-active? (thi/id :theme-1)))
|
||||
(ctob/toggle-theme-active (thi/id :theme-1)))
|
||||
result (ctob/export-dtcg-multi-file tokens-lib)
|
||||
expected {"$themes.json" [{"description" ""
|
||||
"group" "group-1"
|
||||
"is-source" false
|
||||
"modified-at" now
|
||||
"isSource" false
|
||||
"id" "test-id-01"
|
||||
"name" "theme-1"
|
||||
"selectedTokenSets" {"some/set" "enabled"}}]
|
||||
|
||||
@@ -159,3 +159,13 @@
|
||||
|
||||
(t/testing "update-number-in-repeated-prop-names"
|
||||
(t/is (= (ctv/update-number-in-repeated-prop-names props) numbered-props)))))
|
||||
|
||||
|
||||
(t/deftest find-boolean-pair
|
||||
(t/testing "find-boolean-pair"
|
||||
(t/is (= (ctv/find-boolean-pair ["off" "on"]) {"on" true "off" false}))
|
||||
(t/is (= (ctv/find-boolean-pair ["on" "off"]) {"on" true "off" false}))
|
||||
(t/is (= (ctv/find-boolean-pair ["off" "on" "other"]) nil))
|
||||
(t/is (= (ctv/find-boolean-pair ["yes" "no"]) {"yes" true "no" false}))
|
||||
(t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false}))
|
||||
(t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil))))
|
||||
|
||||
@@ -5,7 +5,7 @@ ENV LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8 \
|
||||
NODE_VERSION=v22.21.1 \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
PATH=/opt/node/bin:$PATH
|
||||
PATH=/opt/node/bin:/opt/imagick/bin:$PATH
|
||||
|
||||
RUN set -ex; \
|
||||
useradd -U -M -u 1001 -s /bin/false -d /opt/penpot penpot; \
|
||||
@@ -62,6 +62,22 @@ RUN set -ex; \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
\
|
||||
libgomp1 \
|
||||
libheif1 \
|
||||
libjpeg-turbo8 \
|
||||
liblcms2-2 \
|
||||
libopenexr-3-1-30 \
|
||||
libopenjp2-7 \
|
||||
libpng16-16 \
|
||||
librsvg2-2 \
|
||||
libtiff6 \
|
||||
libwebp7 \
|
||||
libwebpdemux2 \
|
||||
libwebpmux3 \
|
||||
libxml2 \
|
||||
libzip4t64 \
|
||||
libzstd1 \
|
||||
; \
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
@@ -91,6 +107,7 @@ RUN set -eux; \
|
||||
|
||||
ARG BUNDLE_PATH="./bundle-exporter/"
|
||||
COPY --chown=penpot:penpot $BUNDLE_PATH /opt/penpot/exporter/
|
||||
COPY --from=penpotapp/imagemagick:7.1.2-0 /opt/imagick /opt/imagick
|
||||
|
||||
WORKDIR /opt/penpot/exporter
|
||||
USER penpot:penpot
|
||||
|
||||
27
frontend/playwright/data/get-teams-inspect.json
Normal file
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"plugins/runtime",
|
||||
"design-tokens/v1",
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
},
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true
|
||||
},
|
||||
"~:name": "Default",
|
||||
"~:modified-at": "~m1743598498553",
|
||||
"~:id": "~u5116481d-f4e1-80c0-8005-f8e885bdc14d",
|
||||
"~:created-at": "~m1743598498553",
|
||||
"~:is-default": true
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"fdata/path-data",
|
||||
"plugins/runtime",
|
||||
"design-tokens/v1",
|
||||
"variants/v1",
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/objects-map",
|
||||
"render-wasm/v1",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
},
|
||||
"~:team-id": "~u6bd7c17d-4f59-815e-8006-5c1f6882469a",
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "group_with_text_shadows",
|
||||
"~:revn": 31,
|
||||
"~:modified-at": "~m1762430368134",
|
||||
"~:vern": 0,
|
||||
"~:id": "~u58c5cc60-d124-81bd-8007-0f30f1ac452a",
|
||||
"~:is-shared": false,
|
||||
"~:migrations": {
|
||||
"~#ordered-set": [
|
||||
"legacy-2",
|
||||
"legacy-3",
|
||||
"legacy-5",
|
||||
"legacy-6",
|
||||
"legacy-7",
|
||||
"legacy-8",
|
||||
"legacy-9",
|
||||
"legacy-10",
|
||||
"legacy-11",
|
||||
"legacy-12",
|
||||
"legacy-13",
|
||||
"legacy-14",
|
||||
"legacy-16",
|
||||
"legacy-17",
|
||||
"legacy-18",
|
||||
"legacy-19",
|
||||
"legacy-25",
|
||||
"legacy-26",
|
||||
"legacy-27",
|
||||
"legacy-28",
|
||||
"legacy-29",
|
||||
"legacy-31",
|
||||
"legacy-32",
|
||||
"legacy-33",
|
||||
"legacy-34",
|
||||
"legacy-36",
|
||||
"legacy-37",
|
||||
"legacy-38",
|
||||
"legacy-39",
|
||||
"legacy-40",
|
||||
"legacy-41",
|
||||
"legacy-42",
|
||||
"legacy-43",
|
||||
"legacy-44",
|
||||
"legacy-45",
|
||||
"legacy-46",
|
||||
"legacy-47",
|
||||
"legacy-48",
|
||||
"legacy-49",
|
||||
"legacy-50",
|
||||
"legacy-51",
|
||||
"legacy-52",
|
||||
"legacy-53",
|
||||
"legacy-54",
|
||||
"legacy-55",
|
||||
"legacy-56",
|
||||
"legacy-57",
|
||||
"legacy-59",
|
||||
"legacy-62",
|
||||
"legacy-65",
|
||||
"legacy-66",
|
||||
"legacy-67",
|
||||
"0001-remove-tokens-from-groups",
|
||||
"0002-normalize-bool-content-v2",
|
||||
"0002-clean-shape-interactions",
|
||||
"0003-fix-root-shape",
|
||||
"0003-convert-path-content-v2",
|
||||
"0004-clean-shadow-color",
|
||||
"0005-deprecate-image-type",
|
||||
"0006-fix-old-texts-fills",
|
||||
"0008-fix-library-colors-v4",
|
||||
"0009-clean-library-colors",
|
||||
"0009-add-partial-text-touched-flags",
|
||||
"0010-fix-swap-slots-pointing-non-existent-shapes",
|
||||
"0011-fix-invalid-text-touched-flags",
|
||||
"0012-fix-position-data",
|
||||
"0013-fix-component-path",
|
||||
"0013-clear-invalid-strokes-and-fills",
|
||||
"0014-fix-tokens-lib-duplicate-ids",
|
||||
"0014-clear-components-nil-objects",
|
||||
"0015-fix-text-attrs-blank-strings",
|
||||
"0016-copy-fills-from-position-data-to-text-node"
|
||||
]
|
||||
},
|
||||
"~:version": 67,
|
||||
"~:project-id": "~u6bd7c17d-4f59-815e-8006-5c1f68846e43",
|
||||
"~:created-at": "~m1762273747633",
|
||||
"~:backend": "legacy-db",
|
||||
"~:data": {
|
||||
"~:pages": [
|
||||
"~u58c5cc60-d124-81bd-8007-0f30f1ac452b"
|
||||
],
|
||||
"~:pages-index": {
|
||||
"~u58c5cc60-d124-81bd-8007-0f30f1ac452b": {
|
||||
"~:objects": {
|
||||
"~#penpot/objects-map/v2": {
|
||||
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~u457f223c-eff4-8043-8007-1186366cf83f\"]]]",
|
||||
"~u457f223c-eff4-8043-8007-1186366cf83f": "[\"~#shape\",[\"^ \",\"~:y\",1127.9999542236328,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",3,\"~:name\",\"Text shadow\",\"~:width\",937.0000171528263,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",182,\"~:y\",1127.9999542236328]],[\"^:\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",1127.9999542236328]],[\"^:\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",1434.9999937907205]],[\"^:\",[\"^ \",\"~:x\",182,\"~:y\",1434.9999937907205]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf83f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",182,\"~:blocked\",false,\"~:proportion\",1,\"~:shadow\",[[\"^ \",\"~:color\",[\"^ \",\"^I\",\"#d276ff\",\"~:opacity\",1],\"~:spread\",1,\"~:offset-y\",4,\"~:style\",\"~:drop-shadow\",\"~:blur\",0,\"~:hidden\",false,\"^B\",\"~u376e6303-a232-8017-8004-908433a7d495\",\"~:offset-x\",4]],\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",182,\"~:y\",1127.9999542236328,\"^6\",937.0000171528263,\"~:height\",307.0000395670877,\"~:x1\",182,\"~:y1\",1127.9999542236328,\"~:x2\",1119.0000171528263,\"~:y2\",1434.9999937907205]],\"~:fills\",[],\"~:flip-x\",false,\"^T\",307.0000395670877,\"~:flip-y\",false,\"~:shapes\",[\"~u457f223c-eff4-8043-8007-1186366cf840\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~u457f223c-eff4-8043-8007-1186366cf842\"]]]",
|
||||
"~u22590301-48da-807a-8007-0f30f2c3c7a3": "[\"~#shape\",[\"^ \",\"~:y\",565.9999791979773,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",3,\"~:name\",\"Text shadow\",\"~:width\",937.0000171528263,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",182,\"~:y\",565.9999791979773]],[\"^:\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",565.9999791979773]],[\"^:\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",873.0000187650649]],[\"^:\",[\"^ \",\"~:x\",182,\"~:y\",873.0000187650649]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",182,\"~:blocked\",false,\"~:proportion\",1,\"~:shadow\",[[\"^ \",\"~:color\",[\"^ \",\"^I\",\"#166ada\",\"~:opacity\",1],\"~:spread\",40,\"~:offset-y\",4,\"~:style\",\"~:drop-shadow\",\"~:blur\",50,\"~:hidden\",false,\"^B\",\"~u376e6303-a232-8017-8004-908433a7d495\",\"~:offset-x\",4]],\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",182,\"~:y\",565.9999791979773,\"^6\",937.0000171528263,\"~:height\",307.0000395670876,\"~:x1\",182,\"~:y1\",565.9999791979773,\"~:x2\",1119.0000171528263,\"~:y2\",873.0000187650649]],\"~:fills\",[],\"~:flip-x\",false,\"^T\",307.0000395670876,\"~:flip-y\",false,\"~:shapes\",[\"~u22590301-48da-807a-8007-0f30f2c3c7a4\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~udbebc08f-fd4a-800a-8007-11852f2de796\"]]]",
|
||||
"~u22590301-48da-807a-8007-0f30f2c3c7a7": "[\"~#shape\",[\"^ \",\"~:y\",782.9999720950955,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-height\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"7hmohksim0\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"\",\"^;\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"2fc3qdybqwr\",\"~:font-size\",\"35\",\"~:font-weight\",\"500\",\"~:typography-ref-file\",null,\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#5E18AF\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is the text body\"]],\"^<\",null,\"^=\",\"none\",\"~:text-align\",\"left\",\"^>\",\"gfont-karla\",\"^8\",\"29emf8fbblr\",\"^?\",\"0\",\"^@\",\"500\",\"^A\",null,\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^B\",\"500\",\"^C\",\"none\",\"^D\",\"0\",\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"^H\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Example of UI design, flex + grid layouts, prototyping, light + dark mode colors, typographies and components.\",\"~:width\",702.99999999999,\"^7\",\"^I\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",782.9999720950955]],[\"^Q\",[\"^ \",\"~:x\",1118.9999879599347,\"~:y\",782.9999720950955]],[\"^Q\",[\"^ \",\"~:x\",1118.9999879599347,\"~:y\",824.9999708433805]],[\"^Q\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",824.9999708433805]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a7\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:position-data\",[[\"^ \",\"~:y\",824.1999486982821,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"35px\",\"^@\",\"500\",\"~:y1\",0.4000000059604645,\"^O\",662.6312866210938,\"^C\",\"none solid rgb(94, 24, 175)\",\"^D\",\"normal\",\"~:x\",416.00003051766544,\"~:x1\",0,\"~:y2\",41.19999924302101,\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"~:x2\",662.6312866210938,\"~:direction\",\"ltr\",\"^H\",\"Karla\",\"~:height\",40.79999923706055,\"^I\",\"Example of UI design, flex + grid layouts, \"],[\"^ \",\"~:y\",866.199954032898,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"35px\",\"^@\",\"500\",\"^[\",42.400001525878906,\"^O\",615.8500366210938,\"^C\",\"none solid rgb(94, 24, 175)\",\"^D\",\"normal\",\"~:x\",416.00003051766544,\"^10\",0,\"^11\",83.20000457763672,\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"^12\",615.8500366210938,\"^13\",\"ltr\",\"^H\",\"Karla\",\"^14\",40.80000305175781,\"^I\",\"prototyping, light + dark mode colors, \"],[\"^ \",\"~:y\",908.199954032898,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"35px\",\"^@\",\"500\",\"^[\",84.4000015258789,\"^O\",503.13751220703125,\"^C\",\"none solid rgb(94, 24, 175)\",\"^D\",\"normal\",\"~:x\",416.00003051766544,\"^10\",0,\"^11\",125.20000457763672,\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"^12\",503.13751220703125,\"^13\",\"ltr\",\"^H\",\"Karla\",\"^14\",40.80000305175781,\"^I\",\"typographies and components.\"]],\"~:frame-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:strokes\",[],\"~:x\",415.99998795994475,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",782.9999720950955,\"^O\",702.99999999999,\"^14\",41.999998748285066,\"^10\",415.99998795994475,\"^[\",782.9999720950955,\"^12\",1118.9999879599347,\"^11\",824.9999708433805]],\"^E\",[],\"~:flip-x\",null,\"^14\",41.999998748285066,\"~:flip-y\",null]]",
|
||||
"~u22590301-48da-807a-8007-0f30f2c3c7a6": "[\"~#shape\",[\"^ \",\"~:y\",840.0000141561163,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"dxn5loqivn\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"11x47j94xkq\",\"~:font-size\",\"35\",\"~:font-weight\",\"500\",\"~:typography-ref-file\",null,\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#5E18AF\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is just another text at the bottom\"]],\"^<\",null,\"^=\",\"none\",\"~:text-align\",\"left\",\"^>\",\"gfont-karla\",\"^8\",\"1rfpnyrk4it\",\"^?\",\"35\",\"^@\",\"500\",\"^A\",null,\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^B\",\"500\",\"^C\",\"none\",\"^D\",\"0\",\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"^H\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Version 1. June 2024\",\"~:width\",702.99999999999,\"^7\",\"^I\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",415.9999879599491,\"~:y\",840.0000141561163]],[\"^Q\",[\"^ \",\"~:x\",1118.999987959939,\"~:y\",840.0000141561163]],[\"^Q\",[\"^ \",\"~:x\",1118.999987959939,\"~:y\",873.000022023929]],[\"^Q\",[\"^ \",\"~:x\",415.9999879599491,\"~:y\",873.000022023929]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:hidden\",false,\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a6\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:position-data\",[[\"^ \",\"~:y\",881.199900329113,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"35px\",\"^@\",\"500\",\"~:y1\",0.4000000059604645,\"^O\",323.5187683105469,\"^C\",\"none solid rgb(94, 24, 175)\",\"^D\",\"normal\",\"~:x\",416.00003051766544,\"~:x1\",0,\"~:y2\",41.19999924302101,\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"~:x2\",323.5187683105469,\"~:direction\",\"ltr\",\"^H\",\"Karla\",\"~:height\",40.79999923706055,\"^I\",\"Version 1. June 2024\"]],\"~:frame-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:strokes\",[],\"~:x\",415.9999879599491,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",415.9999879599491,\"~:y\",840.0000141561163,\"^O\",702.99999999999,\"^15\",33.000007867812656,\"^11\",415.9999879599491,\"^10\",840.0000141561163,\"^13\",1118.999987959939,\"^12\",873.000022023929]],\"^E\",[],\"~:flip-x\",null,\"^15\",33.000007867812656,\"~:flip-y\",null]]",
|
||||
"~u22590301-48da-807a-8007-0f30f2c3c7a5": "[\"~#shape\",[\"^ \",\"~:y\",782.9999810452875,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",-2.842170943040401E-14,\"~:p2\",0,\"~:p3\",-2.842170943040401E-14,\"~:p4\",0],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"description\",\"~:layout-align-items\",\"~:start\",\"~:width\",703.0000076883839,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",416.0000094644423,\"~:y\",782.9999810452875]],[\"^J\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",782.9999810452875]],[\"^J\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",873.0000080957647]],[\"^J\",[\"^ \",\"~:x\",416.0000094644423,\"~:y\",873.0000080957647]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",15,\"~:column-gap\",15],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:layout-item-v-sizing\",\"^M\",\"~:layout-justify-content\",\"^C\",\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",416.00000946444237,\"~:blocked\",false,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",416.00000946444237,\"~:y\",782.9999810452875,\"^D\",703.0000076883839,\"~:height\",90.00002705047712,\"~:x1\",416.00000946444237,\"~:y1\",782.9999810452875,\"~:x2\",1119.0000171528263,\"~:y2\",873.0000080957647]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",90.00002705047712,\"~:flip-y\",null,\"~:shapes\",[\"~u22590301-48da-807a-8007-0f30f2c3c7a6\",\"~u22590301-48da-807a-8007-0f30f2c3c7a7\"]]]",
|
||||
"~u22590301-48da-807a-8007-0f30f2c3c7a4": "[\"~#shape\",[\"^ \",\"~:y\",565.9999637007554,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"8gvslj04p9\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"2eqbmpbto3f\",\"~:font-size\",\"60\",\"~:font-weight\",\"700\",\"~:typography-ref-file\",null,\"~:font-variant-id\",\"700\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#805ad5\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is the title\"]],\"^<\",null,\"^=\",\"none\",\"~:text-align\",\"left\",\"^>\",\"gfont-karla\",\"^8\",\"yl00fqu977\",\"^?\",\"60\",\"^@\",\"700\",\"^A\",null,\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^B\",\"700\",\"^C\",\"none\",\"^D\",\"0\",\"^E\",[[\"^ \",\"^F\",\"#805ad5\",\"^G\",1]],\"^H\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Sales dashboard example\",\"~:width\",428.000002264963,\"^7\",\"^I\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",386.99999294062314,\"~:y\",565.9999637007555]],[\"^Q\",[\"^ \",\"~:x\",814.9999952055861,\"~:y\",565.9999637007555]],[\"^Q\",[\"^ \",\"~:x\",814.9999952055861,\"~:y\",638.0000023244937]],[\"^Q\",[\"^ \",\"~:x\",386.99999294062314,\"~:y\",638.0000023244937]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:hidden\",false,\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a4\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~:position-data\",[[\"^ \",\"~:y\",601.1998026371002,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"60px\",\"^@\",\"700\",\"~:y1\",0.800000011920929,\"^O\",736.3812866210938,\"^C\",\"none solid rgb(128, 90, 213)\",\"^D\",\"normal\",\"~:x\",306.9999465942383,\"~:x1\",0,\"~:y2\",71.20000153779984,\"^E\",[[\"^ \",\"^F\",\"#805ad5\",\"^G\",1]],\"~:x2\",736.3812866210938,\"~:direction\",\"ltr\",\"^H\",\"Karla\",\"~:height\",70.4000015258789,\"^I\",\"Sales dashboard example\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",386.9999929406232,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",386.9999929406232,\"~:y\",565.9999637007554,\"^O\",428.000002264963,\"^11\",72.00003862373819,\"^Y\",386.9999929406232,\"^X\",565.9999637007554,\"^[\",814.9999952055862,\"^Z\",638.0000023244936]],\"^E\",[],\"~:flip-x\",null,\"^11\",72.00003862373819,\"~:flip-y\",null]]",
|
||||
"~udbebc08f-fd4a-800a-8007-11852f2de796": "[\"~#shape\",[\"^ \",\"~:y\",704.9999632537276,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",123,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",182,\"~:y\",704.9999632537276]],[\"^<\",[\"^ \",\"~:x\",305,\"~:y\",704.9999632537276]],[\"^<\",[\"^ \",\"~:x\",305,\"~:y\",758.9999020993622]],[\"^<\",[\"^ \",\"~:x\",182,\"~:y\",758.9999020993622]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:scale\",\"~:constraints-h\",\"^B\",\"~:r1\",0,\"~:id\",\"~udbebc08f-fd4a-800a-8007-11852f2de796\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",182,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",182,\"~:y\",704.9999632537276,\"^8\",123,\"~:height\",53.999938845634574,\"~:x1\",182,\"~:y1\",704.9999632537276,\"~:x2\",305,\"~:y2\",758.9999020993622]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#476fe7\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",53.999938845634574,\"~:flip-y\",null]]",
|
||||
"~u457f223c-eff4-8043-8007-1186366cf841": "[\"~#shape\",[\"^ \",\"~:y\",1344.9999721046204,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",-2.842170943040401E-14,\"~:p2\",0,\"~:p3\",-2.842170943040401E-14,\"~:p4\",0],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"description\",\"~:layout-align-items\",\"~:start\",\"~:width\",703.0000076883839,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",416.0000094644423,\"~:y\",1344.9999721046204]],[\"^J\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",1344.9999721046204]],[\"^J\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",1434.9999937907205]],[\"^J\",[\"^ \",\"~:x\",416.0000094644423,\"~:y\",1434.9999937907205]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",15,\"~:column-gap\",15],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:layout-item-v-sizing\",\"^M\",\"~:layout-justify-content\",\"^C\",\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf83f\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",416.0000094644424,\"~:blocked\",false,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",416.0000094644424,\"~:y\",1344.9999721046204,\"^D\",703.0000076883839,\"~:height\",90.00002168610013,\"~:x1\",416.0000094644424,\"~:y1\",1344.9999721046204,\"~:x2\",1119.0000171528263,\"~:y2\",1434.9999937907205]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",90.00002168610013,\"~:flip-y\",null,\"~:shapes\",[\"~u457f223c-eff4-8043-8007-1186366cf843\",\"~u457f223c-eff4-8043-8007-1186366cf844\"]]]",
|
||||
"~u457f223c-eff4-8043-8007-1186366cf840": "[\"~#shape\",[\"^ \",\"~:y\",1127.9999542236328,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"8gvslj04p9\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"normal\",\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"2eqbmpbto3f\",\"~:font-size\",\"60\",\"~:font-weight\",\"700\",\"~:font-variant-id\",\"700\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#805ad5\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is the title\"]],\"^<\",\"none\",\"~:text-align\",\"left\",\"^=\",\"gfont-karla\",\"^8\",\"yl00fqu977\",\"^>\",\"60\",\"^?\",\"700\",\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^@\",\"700\",\"^A\",\"none\",\"^B\",\"0\",\"^C\",[[\"^ \",\"^D\",\"#805ad5\",\"^E\",1]],\"^F\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Sales dashboard example\",\"~:width\",428.000002264963,\"^7\",\"^G\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",386.9999929406231,\"~:y\",1127.9999542236328]],[\"^O\",[\"^ \",\"~:x\",814.999995205586,\"~:y\",1127.9999542236328]],[\"^O\",[\"^ \",\"~:x\",814.999995205586,\"~:y\",1199.9999928474213]],[\"^O\",[\"^ \",\"~:x\",386.9999929406231,\"~:y\",1199.9999928474213]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:hidden\",false,\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf840\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf83f\",\"~:position-data\",[[\"^ \",\"~:y\",1163.1997547745723,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"60px\",\"^?\",\"700\",\"~:y1\",0.800000011920929,\"^M\",736.3812866210938,\"^A\",\"none solid rgb(128, 90, 213)\",\"^B\",\"normal\",\"~:x\",306.9999465942383,\"~:x1\",0,\"~:y2\",71.20000153779984,\"^C\",[[\"^ \",\"^D\",\"#805ad5\",\"^E\",1]],\"~:x2\",736.3812866210938,\"~:direction\",\"ltr\",\"^F\",\"Karla\",\"~:height\",70.4000015258789,\"^G\",\"Sales dashboard example\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",386.9999929406232,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",386.9999929406232,\"~:y\",1127.9999542236328,\"^M\",428.000002264963,\"^[\",72.00003862378844,\"^W\",386.9999929406232,\"^V\",1127.9999542236328,\"^Y\",814.9999952055862,\"^X\",1199.9999928474213]],\"^C\",[],\"~:flip-x\",null,\"^[\",72.00003862378844,\"~:flip-y\",null]]",
|
||||
"~u457f223c-eff4-8043-8007-1186366cf843": "[\"~#shape\",[\"^ \",\"~:y\",1401.999989181772,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"dxn5loqivn\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"normal\",\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"11x47j94xkq\",\"~:font-size\",\"35\",\"~:font-weight\",\"500\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#5E18AF\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is just another text at the bottom\"]],\"^<\",\"none\",\"~:text-align\",\"left\",\"^=\",\"gfont-karla\",\"^8\",\"1rfpnyrk4it\",\"^>\",\"35\",\"^?\",\"500\",\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^@\",\"500\",\"^A\",\"none\",\"^B\",\"0\",\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"^F\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Version 1. June 2024\",\"~:width\",702.99999999999,\"^7\",\"^G\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",415.99998795994907,\"~:y\",1401.999989181772]],[\"^O\",[\"^ \",\"~:x\",1118.999987959939,\"~:y\",1401.999989181772]],[\"^O\",[\"^ \",\"~:x\",1118.999987959939,\"~:y\",1434.9999970495846]],[\"^O\",[\"^ \",\"~:x\",415.99998795994907,\"~:y\",1434.9999970495846]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:hidden\",false,\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf843\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:position-data\",[[\"^ \",\"~:y\",1443.1998753547687,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"35px\",\"^?\",\"500\",\"~:y1\",0.4000000059604645,\"^M\",323.5187683105469,\"^A\",\"none solid rgb(94, 24, 175)\",\"^B\",\"normal\",\"~:x\",416.00003051766544,\"~:x1\",0,\"~:y2\",41.19999924302101,\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"~:x2\",323.5187683105469,\"~:direction\",\"ltr\",\"^F\",\"Karla\",\"~:height\",40.79999923706055,\"^G\",\"Version 1. June 2024\"]],\"~:frame-id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:strokes\",[],\"~:x\",415.99998795994907,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",415.99998795994907,\"~:y\",1401.999989181772,\"^M\",702.99999999999,\"^13\",33.000007867812656,\"^[\",415.99998795994907,\"^Z\",1401.999989181772,\"^11\",1118.999987959939,\"^10\",1434.9999970495846]],\"^C\",[],\"~:flip-x\",null,\"^13\",33.000007867812656,\"~:flip-y\",null]]",
|
||||
"~u457f223c-eff4-8043-8007-1186366cf842": "[\"~#shape\",[\"^ \",\"~:y\",1266.9999993145393,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",123,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",182,\"~:y\",1266.9999993145393]],[\"^<\",[\"^ \",\"~:x\",305,\"~:y\",1266.9999993145393]],[\"^<\",[\"^ \",\"~:x\",305,\"~:y\",1320.999938160174]],[\"^<\",[\"^ \",\"~:x\",182,\"~:y\",1320.999938160174]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:scale\",\"~:constraints-h\",\"^B\",\"~:r1\",0,\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf842\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf83f\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",182,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",182,\"~:y\",1266.9999993145393,\"^8\",123,\"~:height\",53.99993884563446,\"~:x1\",182,\"~:y1\",1266.9999993145393,\"~:x2\",305,\"~:y2\",1320.9999381601738]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#476fe7\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",53.99993884563446,\"~:flip-y\",null]]",
|
||||
"~u457f223c-eff4-8043-8007-1186366cf844": "[\"~#shape\",[\"^ \",\"~:y\",1345.0000691910636,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-height\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"7hmohksim0\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"\",\"^;\",\"normal\",\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"2fc3qdybqwr\",\"~:font-size\",\"35\",\"~:font-weight\",\"500\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#5E18AF\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is the text body\"]],\"^<\",\"none\",\"~:text-align\",\"left\",\"^=\",\"gfont-karla\",\"^8\",\"29emf8fbblr\",\"^>\",\"0\",\"^?\",\"500\",\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^@\",\"500\",\"^A\",\"none\",\"^B\",\"0\",\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"^F\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Example of UI design, flex + grid layouts, prototyping, light + dark mode colors, typographies and components.\",\"~:width\",702.99999999999,\"^7\",\"^G\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",1345.0000691910636]],[\"^O\",[\"^ \",\"~:x\",1118.9999879599347,\"~:y\",1345.0000691910636]],[\"^O\",[\"^ \",\"~:x\",1118.9999879599347,\"~:y\",1387.0000679393486]],[\"^O\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",1387.0000679393486]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf844\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:position-data\",[[\"^ \",\"~:y\",1386.2000457942502,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"35px\",\"^?\",\"500\",\"~:y1\",0.4000000059604645,\"^M\",662.6312866210938,\"^A\",\"none solid rgb(94, 24, 175)\",\"^B\",\"normal\",\"~:x\",416.00003051766544,\"~:x1\",0,\"~:y2\",41.19999924302101,\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"~:x2\",662.6312866210938,\"~:direction\",\"ltr\",\"^F\",\"Karla\",\"~:height\",40.79999923706055,\"^G\",\"Example of UI design, flex + grid layouts, \"],[\"^ \",\"~:y\",1428.200051128866,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"35px\",\"^?\",\"500\",\"^Y\",42.400001525878906,\"^M\",615.8500366210938,\"^A\",\"none solid rgb(94, 24, 175)\",\"^B\",\"normal\",\"~:x\",416.00003051766544,\"^Z\",0,\"^[\",83.20000457763672,\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"^10\",615.8500366210938,\"^11\",\"ltr\",\"^F\",\"Karla\",\"^12\",40.80000305175781,\"^G\",\"prototyping, light + dark mode colors, \"],[\"^ \",\"~:y\",1470.200051128866,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"35px\",\"^?\",\"500\",\"^Y\",84.4000015258789,\"^M\",503.13751220703125,\"^A\",\"none solid rgb(94, 24, 175)\",\"^B\",\"normal\",\"~:x\",416.00003051766544,\"^Z\",0,\"^[\",125.20000457763672,\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"^10\",503.13751220703125,\"^11\",\"ltr\",\"^F\",\"Karla\",\"^12\",40.80000305175781,\"^G\",\"typographies and components.\"]],\"~:frame-id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:strokes\",[],\"~:x\",415.99998795994475,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",1345.0000691910636,\"^M\",702.99999999999,\"^12\",41.99999874828518,\"^Z\",415.99998795994475,\"^Y\",1345.0000691910636,\"^10\",1118.9999879599347,\"^[\",1387.0000679393488]],\"^C\",[],\"~:flip-x\",null,\"^12\",41.99999874828518,\"~:flip-y\",null]]"
|
||||
}
|
||||
},
|
||||
"~:id": "~u58c5cc60-d124-81bd-8007-0f30f1ac452b",
|
||||
"~:name": "Page 1"
|
||||
}
|
||||
},
|
||||
"~:id": "~u58c5cc60-d124-81bd-8007-0f30f1ac452a",
|
||||
"~:options": {
|
||||
"~:components-v2": true,
|
||||
"~:base-font-size": "16px"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
{
|
||||
"~:features": {
|
||||
"~#set": [
|
||||
"fdata/path-data",
|
||||
"plugins/runtime",
|
||||
"design-tokens/v1",
|
||||
"variants/v1",
|
||||
"layout/grid",
|
||||
"styles/v2",
|
||||
"fdata/pointer-map",
|
||||
"fdata/objects-map",
|
||||
"render-wasm/v1",
|
||||
"components/v2",
|
||||
"fdata/shape-data-type"
|
||||
]
|
||||
},
|
||||
"~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724",
|
||||
"~:permissions": {
|
||||
"~:type": "~:membership",
|
||||
"~:is-owner": true,
|
||||
"~:is-admin": true,
|
||||
"~:can-edit": true,
|
||||
"~:can-read": true,
|
||||
"~:is-logged": true
|
||||
},
|
||||
"~:has-media-trimmed": false,
|
||||
"~:comment-thread-seqn": 0,
|
||||
"~:name": "New File 1",
|
||||
"~:revn": 1,
|
||||
"~:modified-at": "~m1762943590499",
|
||||
"~:vern": 0,
|
||||
"~:id": "~ub4133204-a015-80ed-8007-192a65398b0c",
|
||||
"~:is-shared": false,
|
||||
"~:migrations": {
|
||||
"~#ordered-set": [
|
||||
"legacy-2",
|
||||
"legacy-3",
|
||||
"legacy-5",
|
||||
"legacy-6",
|
||||
"legacy-7",
|
||||
"legacy-8",
|
||||
"legacy-9",
|
||||
"legacy-10",
|
||||
"legacy-11",
|
||||
"legacy-12",
|
||||
"legacy-13",
|
||||
"legacy-14",
|
||||
"legacy-16",
|
||||
"legacy-17",
|
||||
"legacy-18",
|
||||
"legacy-19",
|
||||
"legacy-25",
|
||||
"legacy-26",
|
||||
"legacy-27",
|
||||
"legacy-28",
|
||||
"legacy-29",
|
||||
"legacy-31",
|
||||
"legacy-32",
|
||||
"legacy-33",
|
||||
"legacy-34",
|
||||
"legacy-36",
|
||||
"legacy-37",
|
||||
"legacy-38",
|
||||
"legacy-39",
|
||||
"legacy-40",
|
||||
"legacy-41",
|
||||
"legacy-42",
|
||||
"legacy-43",
|
||||
"legacy-44",
|
||||
"legacy-45",
|
||||
"legacy-46",
|
||||
"legacy-47",
|
||||
"legacy-48",
|
||||
"legacy-49",
|
||||
"legacy-50",
|
||||
"legacy-51",
|
||||
"legacy-52",
|
||||
"legacy-53",
|
||||
"legacy-54",
|
||||
"legacy-55",
|
||||
"legacy-56",
|
||||
"legacy-57",
|
||||
"legacy-59",
|
||||
"legacy-62",
|
||||
"legacy-65",
|
||||
"legacy-66",
|
||||
"legacy-67",
|
||||
"0001-remove-tokens-from-groups",
|
||||
"0002-normalize-bool-content-v2",
|
||||
"0002-clean-shape-interactions",
|
||||
"0003-fix-root-shape",
|
||||
"0003-convert-path-content-v2",
|
||||
"0005-deprecate-image-type",
|
||||
"0006-fix-old-texts-fills",
|
||||
"0008-fix-library-colors-v4",
|
||||
"0009-clean-library-colors",
|
||||
"0009-add-partial-text-touched-flags",
|
||||
"0010-fix-swap-slots-pointing-non-existent-shapes",
|
||||
"0011-fix-invalid-text-touched-flags",
|
||||
"0012-fix-position-data",
|
||||
"0013-fix-component-path",
|
||||
"0013-clear-invalid-strokes-and-fills",
|
||||
"0014-fix-tokens-lib-duplicate-ids",
|
||||
"0014-clear-components-nil-objects",
|
||||
"0015-fix-text-attrs-blank-strings",
|
||||
"0015-clean-shadow-color",
|
||||
"0016-copy-fills-from-position-data-to-text-node"
|
||||
]
|
||||
},
|
||||
"~:version": 67,
|
||||
"~:project-id": "~ueba8fa2e-4140-8084-8005-448635da32b4",
|
||||
"~:created-at": "~m1762943119590",
|
||||
"~:backend": "legacy-db",
|
||||
"~:data": {
|
||||
"~:pages": [
|
||||
"~ub4133204-a015-80ed-8007-192a65398b0d"
|
||||
],
|
||||
"~:pages-index": {
|
||||
"~ub4133204-a015-80ed-8007-192a65398b0d": {
|
||||
"~:objects": {
|
||||
"~u00000000-0000-0000-0000-000000000000": {
|
||||
"~#shape": {
|
||||
"~:y": 0,
|
||||
"~:hide-fill-on-export": false,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:name": "Root Frame",
|
||||
"~:width": 0.01,
|
||||
"~:type": "~:frame",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0,
|
||||
"~:y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.01,
|
||||
"~:y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0.01,
|
||||
"~:y": 0.01
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 0,
|
||||
"~:y": 0.01
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:r2": 0,
|
||||
"~:proportion-lock": false,
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:r3": 0,
|
||||
"~:r1": 0,
|
||||
"~:id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:strokes": [],
|
||||
"~:x": 0,
|
||||
"~:proportion": 1,
|
||||
"~:r4": 0,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 0,
|
||||
"~:y": 0,
|
||||
"~:width": 0.01,
|
||||
"~:height": 0.01,
|
||||
"~:x1": 0,
|
||||
"~:y1": 0,
|
||||
"~:x2": 0.01,
|
||||
"~:y2": 0.01
|
||||
}
|
||||
},
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#FFFFFF",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:flip-x": null,
|
||||
"~:height": 0.01,
|
||||
"~:flip-y": null,
|
||||
"~:shapes": [
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5ab7",
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5ab9",
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5abb",
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5abc"
|
||||
]
|
||||
}
|
||||
},
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5ab7": {
|
||||
"~#shape": {
|
||||
"~:y": 492.000000032425,
|
||||
"~:hide-fill-on-export": false,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:grow-type": "~:fixed",
|
||||
"~:hide-in-viewer": false,
|
||||
"~:name": "Board",
|
||||
"~:width": 348,
|
||||
"~:type": "~:frame",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 354,
|
||||
"~:y": 492.000000032425
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 702,
|
||||
"~:y": 492.000000032425
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 702,
|
||||
"~:y": 829.000000032425
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 354,
|
||||
"~:y": 829.000000032425
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:r2": 0,
|
||||
"~:show-content": false,
|
||||
"~:proportion-lock": false,
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:r3": 0,
|
||||
"~:r1": 0,
|
||||
"~:id": "~u8d80d76b-68f7-803d-8007-192c2e0a5ab7",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:strokes": [],
|
||||
"~:x": 354,
|
||||
"~:proportion": 1,
|
||||
"~:r4": 0,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 354,
|
||||
"~:y": 492.000000032425,
|
||||
"~:width": 348,
|
||||
"~:height": 337,
|
||||
"~:x1": 354,
|
||||
"~:y1": 492.000000032425,
|
||||
"~:x2": 702,
|
||||
"~:y2": 829.000000032425
|
||||
}
|
||||
},
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#abf22a",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:flip-x": null,
|
||||
"~:height": 337,
|
||||
"~:flip-y": null,
|
||||
"~:shapes": [
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5ab8"
|
||||
]
|
||||
}
|
||||
},
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5ab8": {
|
||||
"~#shape": {
|
||||
"~:y": 532.999984773636,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:grow-type": "~:fixed",
|
||||
"~:hide-in-viewer": false,
|
||||
"~:name": "Rectangle",
|
||||
"~:width": 200,
|
||||
"~:type": "~:rect",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 421,
|
||||
"~:y": 532.999984773636
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 621,
|
||||
"~:y": 532.999984773636
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 621,
|
||||
"~:y": 712.999984773636
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 421,
|
||||
"~:y": 712.999984773636
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:r2": 0,
|
||||
"~:proportion-lock": false,
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:r3": 0,
|
||||
"~:r1": 0,
|
||||
"~:id": "~u8d80d76b-68f7-803d-8007-192c2e0a5ab8",
|
||||
"~:parent-id": "~u8d80d76b-68f7-803d-8007-192c2e0a5ab7",
|
||||
"~:frame-id": "~u8d80d76b-68f7-803d-8007-192c2e0a5ab7",
|
||||
"~:strokes": [],
|
||||
"~:x": 421,
|
||||
"~:proportion": 1,
|
||||
"~:shadow": [
|
||||
{
|
||||
"~:id": "~u005d085d-63c2-80b6-8007-18ffe15ad03b",
|
||||
"~:style": "~:drop-shadow",
|
||||
"~:color": {
|
||||
"~:color": "#f40000",
|
||||
"~:opacity": 1
|
||||
},
|
||||
"~:offset-x": 50,
|
||||
"~:offset-y": 50,
|
||||
"~:blur": 100,
|
||||
"~:spread": 0,
|
||||
"~:hidden": false
|
||||
}
|
||||
],
|
||||
"~:r4": 0,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 421,
|
||||
"~:y": 532.999984773636,
|
||||
"~:width": 200,
|
||||
"~:height": 180,
|
||||
"~:x1": 421,
|
||||
"~:y1": 532.999984773636,
|
||||
"~:x2": 621,
|
||||
"~:y2": 712.999984773636
|
||||
}
|
||||
},
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#003ef9",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:flip-x": null,
|
||||
"~:height": 180,
|
||||
"~:flip-y": null
|
||||
}
|
||||
},
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5ab9": {
|
||||
"~#shape": {
|
||||
"~:y": 491.999999162674,
|
||||
"~:hide-fill-on-export": false,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:grow-type": "~:fixed",
|
||||
"~:hide-in-viewer": false,
|
||||
"~:name": "Board",
|
||||
"~:width": 348,
|
||||
"~:type": "~:frame",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 958.999967498779,
|
||||
"~:y": 491.999999162674
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 1306.99996749878,
|
||||
"~:y": 491.999999162674
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 1306.99996749878,
|
||||
"~:y": 828.999999162674
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 958.999967498779,
|
||||
"~:y": 828.999999162674
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:r2": 0,
|
||||
"~:show-content": true,
|
||||
"~:proportion-lock": false,
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:r3": 0,
|
||||
"~:r1": 0,
|
||||
"~:id": "~u8d80d76b-68f7-803d-8007-192c2e0a5ab9",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:strokes": [],
|
||||
"~:x": 958.999967498779,
|
||||
"~:proportion": 1,
|
||||
"~:r4": 0,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 958.999967498779,
|
||||
"~:y": 491.999999162674,
|
||||
"~:width": 348,
|
||||
"~:height": 337,
|
||||
"~:x1": 958.999967498779,
|
||||
"~:y1": 491.999999162674,
|
||||
"~:x2": 1306.99996749878,
|
||||
"~:y2": 828.999999162674
|
||||
}
|
||||
},
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#abf22a",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:flip-x": null,
|
||||
"~:height": 337,
|
||||
"~:flip-y": null,
|
||||
"~:shapes": [
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5aba"
|
||||
]
|
||||
}
|
||||
},
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5aba": {
|
||||
"~#shape": {
|
||||
"~:y": 532.999999162674,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:grow-type": "~:fixed",
|
||||
"~:hide-in-viewer": false,
|
||||
"~:name": "Rectangle",
|
||||
"~:width": 200,
|
||||
"~:type": "~:rect",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 1025.99998275757,
|
||||
"~:y": 532.999999162674
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 1225.99998275757,
|
||||
"~:y": 532.999999162674
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 1225.99998275757,
|
||||
"~:y": 712.999999162674
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 1025.99998275757,
|
||||
"~:y": 712.999999162674
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:r2": 0,
|
||||
"~:proportion-lock": false,
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:r3": 0,
|
||||
"~:r1": 0,
|
||||
"~:id": "~u8d80d76b-68f7-803d-8007-192c2e0a5aba",
|
||||
"~:parent-id": "~u8d80d76b-68f7-803d-8007-192c2e0a5ab9",
|
||||
"~:frame-id": "~u8d80d76b-68f7-803d-8007-192c2e0a5ab9",
|
||||
"~:strokes": [],
|
||||
"~:x": 1025.99998275757,
|
||||
"~:proportion": 1,
|
||||
"~:shadow": [
|
||||
{
|
||||
"~:id": "~u005d085d-63c2-80b6-8007-18ffe15ad03b",
|
||||
"~:style": "~:drop-shadow",
|
||||
"~:color": {
|
||||
"~:color": "#f40000",
|
||||
"~:opacity": 1
|
||||
},
|
||||
"~:offset-x": 50,
|
||||
"~:offset-y": 50,
|
||||
"~:blur": 100,
|
||||
"~:spread": 0,
|
||||
"~:hidden": false
|
||||
}
|
||||
],
|
||||
"~:r4": 0,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 1025.99998275757,
|
||||
"~:y": 532.999999162674,
|
||||
"~:width": 200,
|
||||
"~:height": 180,
|
||||
"~:x1": 1025.99998275757,
|
||||
"~:y1": 532.999999162674,
|
||||
"~:x2": 1225.99998275757,
|
||||
"~:y2": 712.999999162674
|
||||
}
|
||||
},
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#003ef9",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:flip-x": null,
|
||||
"~:height": 180,
|
||||
"~:flip-y": null
|
||||
}
|
||||
},
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5abb": {
|
||||
"~#shape": {
|
||||
"~:y": 450.000000032425,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:grow-type": "~:fixed",
|
||||
"~:content": {
|
||||
"~:type": "root",
|
||||
"~:key": "hjocz4oksb",
|
||||
"~:children": [
|
||||
{
|
||||
"~:type": "paragraph-set",
|
||||
"~:children": [
|
||||
{
|
||||
"~:line-height": "1.2",
|
||||
"~:font-style": "normal",
|
||||
"~:children": [
|
||||
{
|
||||
"~:line-height": "1.2",
|
||||
"~:font-style": "normal",
|
||||
"~:text-transform": "none",
|
||||
"~:font-id": "sourcesanspro",
|
||||
"~:key": "9xn5ujq7hr",
|
||||
"~:font-size": "14",
|
||||
"~:font-weight": "400",
|
||||
"~:font-variant-id": "regular",
|
||||
"~:text-decoration": "none",
|
||||
"~:letter-spacing": "0",
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#000000",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:font-family": "sourcesanspro",
|
||||
"~:text": "CLIPPING"
|
||||
}
|
||||
],
|
||||
"~:text-transform": "none",
|
||||
"~:text-align": "left",
|
||||
"~:font-id": "sourcesanspro",
|
||||
"~:key": "2x8x661yyn",
|
||||
"~:font-size": "14",
|
||||
"~:font-weight": "400",
|
||||
"~:text-direction": "ltr",
|
||||
"~:type": "paragraph",
|
||||
"~:font-variant-id": "regular",
|
||||
"~:text-decoration": "none",
|
||||
"~:letter-spacing": "0",
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#000000",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:font-family": "sourcesanspro"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"~:vertical-align": "top"
|
||||
},
|
||||
"~:hide-in-viewer": false,
|
||||
"~:name": "CLIPPING",
|
||||
"~:width": 194,
|
||||
"~:type": "~:text",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 359,
|
||||
"~:y": 450.000000032425
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 553,
|
||||
"~:y": 450.000000032425
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 553,
|
||||
"~:y": 475.000000032425
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 359,
|
||||
"~:y": 475.000000032425
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:id": "~u8d80d76b-68f7-803d-8007-192c2e0a5abb",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:strokes": [],
|
||||
"~:x": 359,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 359,
|
||||
"~:y": 450.000000032425,
|
||||
"~:width": 194,
|
||||
"~:height": 25,
|
||||
"~:x1": 359,
|
||||
"~:y1": 450.000000032425,
|
||||
"~:x2": 553,
|
||||
"~:y2": 475.000000032425
|
||||
}
|
||||
},
|
||||
"~:fills": [],
|
||||
"~:flip-x": null,
|
||||
"~:height": 25,
|
||||
"~:flip-y": null
|
||||
}
|
||||
},
|
||||
"~u8d80d76b-68f7-803d-8007-192c2e0a5abc": {
|
||||
"~#shape": {
|
||||
"~:y": 450,
|
||||
"~:transform": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:rotation": 0,
|
||||
"~:grow-type": "~:fixed",
|
||||
"~:content": {
|
||||
"~:type": "root",
|
||||
"~:key": "hjocz4oksb",
|
||||
"~:children": [
|
||||
{
|
||||
"~:type": "paragraph-set",
|
||||
"~:children": [
|
||||
{
|
||||
"~:line-height": "1.2",
|
||||
"~:font-style": "normal",
|
||||
"~:children": [
|
||||
{
|
||||
"~:line-height": "",
|
||||
"~:font-style": "normal",
|
||||
"~:text-transform": "none",
|
||||
"~:font-id": "sourcesanspro",
|
||||
"~:key": "9xn5ujq7hr",
|
||||
"~:font-size": "14",
|
||||
"~:font-weight": "400",
|
||||
"~:font-variant-id": "regular",
|
||||
"~:text-decoration": "none",
|
||||
"~:letter-spacing": "0",
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#000000",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:font-family": "sourcesanspro",
|
||||
"~:text": "NO CLIPPING"
|
||||
}
|
||||
],
|
||||
"~:text-transform": "none",
|
||||
"~:text-align": "left",
|
||||
"~:font-id": "sourcesanspro",
|
||||
"~:key": "2x8x661yyn",
|
||||
"~:font-size": "0",
|
||||
"~:font-weight": "400",
|
||||
"~:text-direction": "ltr",
|
||||
"~:type": "paragraph",
|
||||
"~:font-variant-id": "regular",
|
||||
"~:text-decoration": "none",
|
||||
"~:letter-spacing": "0",
|
||||
"~:fills": [
|
||||
{
|
||||
"~:fill-color": "#000000",
|
||||
"~:fill-opacity": 1
|
||||
}
|
||||
],
|
||||
"~:font-family": "sourcesanspro"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"~:vertical-align": "top"
|
||||
},
|
||||
"~:hide-in-viewer": false,
|
||||
"~:name": "CLIPPING",
|
||||
"~:width": 194,
|
||||
"~:type": "~:text",
|
||||
"~:points": [
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 958.999966363907,
|
||||
"~:y": 450
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 1152.99996636391,
|
||||
"~:y": 450
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 1152.99996636391,
|
||||
"~:y": 475
|
||||
}
|
||||
},
|
||||
{
|
||||
"~#point": {
|
||||
"~:x": 958.999966363907,
|
||||
"~:y": 475
|
||||
}
|
||||
}
|
||||
],
|
||||
"~:transform-inverse": {
|
||||
"~#matrix": {
|
||||
"~:a": 1,
|
||||
"~:b": 0,
|
||||
"~:c": 0,
|
||||
"~:d": 1,
|
||||
"~:e": 0,
|
||||
"~:f": 0
|
||||
}
|
||||
},
|
||||
"~:id": "~u8d80d76b-68f7-803d-8007-192c2e0a5abc",
|
||||
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||
"~:strokes": [],
|
||||
"~:x": 958.999966363907,
|
||||
"~:selrect": {
|
||||
"~#rect": {
|
||||
"~:x": 958.999966363907,
|
||||
"~:y": 450,
|
||||
"~:width": 194,
|
||||
"~:height": 25,
|
||||
"~:x1": 958.999966363907,
|
||||
"~:y1": 450,
|
||||
"~:x2": 1152.99996636391,
|
||||
"~:y2": 475
|
||||
}
|
||||
},
|
||||
"~:fills": [],
|
||||
"~:flip-x": null,
|
||||
"~:height": 25,
|
||||
"~:flip-y": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"~:id": "~ub4133204-a015-80ed-8007-192a65398b0d",
|
||||
"~:name": "Page 1"
|
||||
}
|
||||
},
|
||||
"~:id": "~ub4133204-a015-80ed-8007-192a65398b0c",
|
||||
"~:options": {
|
||||
"~:components-v2": true,
|
||||
"~:base-font-size": "16px"
|
||||
}
|
||||
}
|
||||
}
|
||||
6006
frontend/playwright/data/workspace/get-file-inspect-tab.json
Normal file
@@ -9,7 +9,7 @@ export class BasePage {
|
||||
);
|
||||
}
|
||||
|
||||
const url = typeof path === "string" ? `**/api/rpc/command/${path}` : path;
|
||||
const url = typeof path === "string" ? `**/api/main/methods/${path}` : path;
|
||||
const interceptConfig = {
|
||||
status: 200,
|
||||
contentType: "application/transit+json",
|
||||
@@ -23,11 +23,18 @@ export class BasePage {
|
||||
);
|
||||
}
|
||||
|
||||
static async mockFileMediaAsset(page, assetId, assetFilename, options) {
|
||||
static async mockFileMediaAsset(
|
||||
page,
|
||||
assetId,
|
||||
assetFilename,
|
||||
assetThumbnailFilename,
|
||||
options,
|
||||
) {
|
||||
const ids = Array.isArray(assetId) ? assetId : [assetId];
|
||||
|
||||
for (const id of ids) {
|
||||
const url = `**/assets/by-file-media-id/${id}`;
|
||||
const thumbnailUrl = `${url}/thumbnail`;
|
||||
|
||||
await page.route(url, (route) =>
|
||||
route.fulfill({
|
||||
@@ -36,6 +43,16 @@ export class BasePage {
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
|
||||
if (assetThumbnailFilename) {
|
||||
await page.route(thumbnailUrl, (route) =>
|
||||
route.fulfill({
|
||||
path: `playwright/data/${assetThumbnailFilename}`,
|
||||
status: 200,
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,22 +72,6 @@ export class BasePage {
|
||||
}
|
||||
}
|
||||
|
||||
static async mockFileMediaAsset(page, assetId, assetFilename, options) {
|
||||
const ids = Array.isArray(assetId) ? assetId : [assetId];
|
||||
|
||||
for (const id of ids) {
|
||||
const url = `**/assets/by-file-media-id/${id}`;
|
||||
|
||||
await page.route(url, (route) =>
|
||||
route.fulfill({
|
||||
path: `playwright/data/${assetFilename}`,
|
||||
status: 200,
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static async mockConfigFlags(page, flags) {
|
||||
const url = "**/js/config.js?ts=*";
|
||||
return await page.route(url, (route) =>
|
||||
@@ -100,11 +101,17 @@ export class BasePage {
|
||||
return BasePage.mockConfigFlags(this.page, flags);
|
||||
}
|
||||
|
||||
async mockFileMediaAsset(assetId, assetFilename, options) {
|
||||
async mockFileMediaAsset(
|
||||
assetId,
|
||||
assetFilename,
|
||||
assetThumbnailFilename,
|
||||
options,
|
||||
) {
|
||||
return BasePage.mockFileMediaAsset(
|
||||
this.page,
|
||||
assetId,
|
||||
assetFilename,
|
||||
assetThumbnailFilename,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BasePage } from "./BasePage";
|
||||
export class LoginPage extends BasePage {
|
||||
constructor(page) {
|
||||
super(page);
|
||||
this.loginButton = page.getByRole("button", { name: "Login" });
|
||||
this.loginButton = page.getByRole("button", { name: "Continue" });
|
||||
this.password = page.getByLabel("Password");
|
||||
this.userName = page.getByLabel("Email");
|
||||
this.invalidCredentialsError = page.getByText(
|
||||
|
||||
@@ -36,6 +36,7 @@ test("Renders a file with solid, gradient and image fills", async ({
|
||||
"1ebcea38-f1bf-8101-8006-4c8f579da49c",
|
||||
],
|
||||
"render-wasm/assets/penguins.jpg",
|
||||
"render-wasm/assets/pattern-thumbnail.png", // FIXME: get real thumbnail
|
||||
);
|
||||
await workspace.mockGetFile("render-wasm/get-file-shapes-fills.json");
|
||||
|
||||
@@ -58,6 +59,7 @@ test("Renders a file with strokes", async ({ page }) => {
|
||||
"202c1104-9385-81d3-8006-507560ce29e3",
|
||||
],
|
||||
"render-wasm/assets/penguins.jpg",
|
||||
"render-wasm/assets/pattern-thumbnail.png", // FIXME: get real thumbnail
|
||||
);
|
||||
await workspace.mockGetFile("render-wasm/get-file-shapes-strokes.json");
|
||||
|
||||
@@ -88,6 +90,11 @@ test("Renders a file with shapes with multiple fills", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-multiple-fills.json");
|
||||
await workspace.mockFileMediaAsset(
|
||||
["c0939f58-37bc-805d-8006-51cda84a405a"],
|
||||
"render-wasm/assets/penguins.jpg",
|
||||
"render-wasm/assets/pattern-thumbnail.png", // FIXME: get real thumbnail
|
||||
);
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "c0939f58-37bc-805d-8006-51cd3a51c255",
|
||||
@@ -127,6 +134,7 @@ test("Renders shapes with exif rotated images fills and strokes", async ({
|
||||
"27270c45-35b4-80f3-8006-63a3ea82557f",
|
||||
],
|
||||
"render-wasm/assets/landscape.jpg",
|
||||
"render-wasm/assets/pattern-thumbnail.png", // FIXME: get real thumbnail
|
||||
);
|
||||
await workspace.mockGetFile(
|
||||
"render-wasm/get-file-shapes-exif-rotated-fills.json",
|
||||
@@ -170,6 +178,15 @@ test("Renders a file with blurs applied to any kind of shape", async ({
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-blurs.json");
|
||||
await workspace.mockFileMediaAsset(
|
||||
[
|
||||
"aa0a383a-7553-808a-8006-ae13a3c575eb",
|
||||
"aa0a383a-7553-808a-8006-ae13c84d6e3a",
|
||||
"aa0a383a-7553-808a-8006-ae131157fc26",
|
||||
],
|
||||
"render-wasm/assets/pattern.png",
|
||||
"render-wasm/assets/pattern-thumbnail.png", // FIXME: get real thumbnail
|
||||
);
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "aa0a383a-7553-808a-8006-ae1237b52cf9",
|
||||
@@ -212,10 +229,7 @@ test("Renders a file with a closed path shape with multiple segments using strok
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
|
||||
test("Renders a file with paths and svg attrs", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("Renders a file with paths and svg attrs", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-svg-attrs.json");
|
||||
@@ -234,7 +248,9 @@ test("Renders a file with nested frames with inherited blur", async ({
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-frame-with-nested-blur.json");
|
||||
await workspace.mockGetFile(
|
||||
"render-wasm/get-file-frame-with-nested-blur.json",
|
||||
);
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "58c5cc60-d124-81bd-8007-0ee4e5030609",
|
||||
@@ -244,3 +260,19 @@ test("Renders a file with nested frames with inherited blur", async ({
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders a clipped frame with a large blur drop shadow", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-large-blur-shadow.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "b4133204-a015-80ed-8007-192a65398b0c",
|
||||
pageId: "b4133204-a015-80ed-8007-192a65398b0d",
|
||||
});
|
||||
await workspace.waitForFirstRender();
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 335 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 44 KiB |
@@ -141,6 +141,7 @@ test("Renders a file with texts with images", async ({ page }) => {
|
||||
"4f89252d-ebbc-813e-8006-8699e4170e18",
|
||||
],
|
||||
"render-wasm/assets/pattern.png",
|
||||
"render-wasm/assets/pattern-thumbnail.png",
|
||||
);
|
||||
await mockGetEmojiFont(workspace);
|
||||
await mockGetJapaneseFont(workspace);
|
||||
@@ -179,6 +180,7 @@ test("Renders a file with text decoration", async ({ page }) => {
|
||||
await workspace.mockFileMediaAsset(
|
||||
["d6c33e7b-7b64-80f3-8006-78509a3a2d21"],
|
||||
"render-wasm/assets/pattern.png",
|
||||
"render-wasm/assets/pattern-thumbnail.png",
|
||||
);
|
||||
await mockGetEmojiFont(workspace);
|
||||
await mockGetJapaneseFont(workspace);
|
||||
@@ -281,14 +283,10 @@ test("Renders a file with different text shadows combinations", async ({
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders a file with multiple text shadows in order", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("Renders a file with multiple text shadows in order", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile(
|
||||
"render-wasm/get-file-text-shadows-order.json",
|
||||
);
|
||||
await workspace.mockGetFile("render-wasm/get-file-text-shadows-order.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "48ffa82f-6950-81b5-8006-e49a2a39657f",
|
||||
@@ -337,7 +335,9 @@ test("Renders a file with texts with with text spans of different sizes", async
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-text-spans-different-sizes.json");
|
||||
await workspace.mockGetFile(
|
||||
"render-wasm/get-file-text-spans-different-sizes.json",
|
||||
);
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "a0b1a70e-0d02-8082-8006-ff6d160f15ce",
|
||||
@@ -347,9 +347,25 @@ test("Renders a file with texts with with text spans of different sizes", async
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders a file with texts with tabs", async ({
|
||||
test("Renders a file with texts with paragraphs and breaking lines", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile(
|
||||
"render-wasm/get-file-text-paragraph-new-lines.json",
|
||||
);
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "a5f238bd-dd8a-8164-8007-1bc3481eaf05",
|
||||
pageId: "a5f238bd-dd8a-8164-8007-1bc3481eaf06",
|
||||
});
|
||||
await workspace.waitForFirstRender();
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
// TODO: enable this test once we use the wasm renderer in the new editor
|
||||
test.skip("Renders a file with texts with tabs", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-text-tabs.json");
|
||||
@@ -367,9 +383,8 @@ test("Renders a file with texts with tabs", async ({
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders a file with texts with empty lines", async ({
|
||||
page,
|
||||
}) => {
|
||||
// TODO: enable this test once we use the wasm renderer in the new editor
|
||||
test.skip("Renders a file with texts with empty lines", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-empty-lines.json");
|
||||
@@ -387,6 +402,41 @@ test("Renders a file with texts with empty lines", async ({
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
// TODO: enable this test once we use the wasm renderer in the new editor
|
||||
test.skip("Renders a file with texts with breaking words", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-empty-lines.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "58c5cc60-d124-81bd-8007-0ecbaf9da983",
|
||||
pageId: "15222a7a-d3bc-80f1-8007-0d8e166e650f",
|
||||
});
|
||||
|
||||
await workspace.waitForFirstRender({ hideUI: false });
|
||||
await workspace.clickLeafLayer("text-with-empty-lines-3");
|
||||
await workspace.hideUI();
|
||||
await workspace.page.keyboard.press("Enter");
|
||||
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test("Renders a file with group with text with inherited shadows", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
await workspace.mockGetFile("render-wasm/get-file-group-with-shadows.json");
|
||||
|
||||
await workspace.goToWorkspace({
|
||||
id: "58c5cc60-d124-81bd-8007-0f30f1ac452a",
|
||||
pageId: "58c5cc60-d124-81bd-8007-0f30f1ac452b",
|
||||
});
|
||||
|
||||
await workspace.waitForFirstRender();
|
||||
await expect(workspace.canvas).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test.skip("Updates text alignment edition - part 1", async ({ page }) => {
|
||||
const workspace = new WasmWorkspacePage(page);
|
||||
await workspace.setupEmptyFile();
|
||||
|
||||
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 104 KiB |
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("Has title", async ({ page }) => {
|
||||
await page.route("**/api/rpc/command/get-profile", (route) => {
|
||||
await page.route("**/api/main/methods/get-profile", (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/transit+json",
|
||||
|
||||
722
frontend/playwright/ui/specs/inspect-tab.spec.js
Normal file
@@ -0,0 +1,722 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { WorkspacePage } from "../pages/WorkspacePage";
|
||||
|
||||
const flags = ["enable-inspect-styles"];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await WorkspacePage.init(page);
|
||||
});
|
||||
|
||||
const setupFile = async (workspacePage) => {
|
||||
await workspacePage.setupEmptyFile();
|
||||
await workspacePage.mockConfigFlags(flags);
|
||||
await workspacePage.mockRPC(
|
||||
/get\-file\?/,
|
||||
"workspace/get-file-inspect-tab.json",
|
||||
);
|
||||
await workspacePage.goToWorkspace({
|
||||
fileId: "7b2da435-6186-815a-8007-0daa95d2f26d",
|
||||
pageId: "ce79274b-11ab-8088-8007-0487ad43f789",
|
||||
});
|
||||
};
|
||||
|
||||
const shapeToLayerName = {
|
||||
flex: "shape - layout - flex",
|
||||
flexElement: "shape - layout - flex - element",
|
||||
grid: "shape - layout - grid",
|
||||
gridElement: "shape - layout - grid - element",
|
||||
shadow: "shape - shadow - single",
|
||||
shadowMultiple: "shape - shadow - multiple",
|
||||
shadowComposite: "shape - shadow - composite",
|
||||
blur: "shape - blur",
|
||||
borderRadius: {
|
||||
main: "shape - borderRadius",
|
||||
individual: "shape - borderRadius - individual",
|
||||
multiple: "shape - borderRadius - multiple",
|
||||
token: "shape - borderRadius - token",
|
||||
},
|
||||
fill: {
|
||||
solid: "shape - fill - single - solid",
|
||||
gradient: "shape - fill - single - gradient",
|
||||
image: "shape - fill - single - image",
|
||||
multiple: "shape - fill - multiple",
|
||||
style: "shape - fill - style",
|
||||
token: "shape - fill - token",
|
||||
},
|
||||
stroke: {
|
||||
solid: "shape - stroke - single - solid",
|
||||
gradient: "shape - stroke - single - gradient",
|
||||
image: "shape - stroke - single - image",
|
||||
multiple: "shape - stroke - multiple",
|
||||
style: "shape - stroke - style",
|
||||
token: "shape - stroke - token",
|
||||
},
|
||||
text: {
|
||||
simple: "shape - text",
|
||||
token: "shape - text - token - simple",
|
||||
compositeToken: "shape - text - token - composite",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the shorthand CSS from a full panel property
|
||||
* @param {object} panel - The style panel locator
|
||||
*/
|
||||
const copyShorthand = async (panel) => {
|
||||
const panelShorthandButton = panel.getByRole("button", {
|
||||
name: "Copy CSS shorthand to clipboard",
|
||||
});
|
||||
await panelShorthandButton.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the CSS property from a property row by clicking its copy button
|
||||
* @param {object} panel - The style panel locator
|
||||
* @param {string} property - The property name to filter by
|
||||
*/
|
||||
const copyPropertyFromPropertyRow = async (panel, property) => {
|
||||
const propertyRow = panel
|
||||
.getByTestId("property-row")
|
||||
.filter({ hasText: property });
|
||||
const copyButton = propertyRow.getByRole("button");
|
||||
await copyButton.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the style panel by its title
|
||||
* @param {WorkspacePage} workspacePage - The workspace page instance
|
||||
* @param {string} title - The title of the panel to retrieve
|
||||
*/
|
||||
const getPanelByTitle = async (workspacePage, title) => {
|
||||
const sidebar = workspacePage.page.getByTestId("right-sidebar");
|
||||
const article = sidebar.getByRole("article");
|
||||
const panel = article.filter({ hasText: title });
|
||||
return panel;
|
||||
};
|
||||
|
||||
/**
|
||||
* Selects a layer in the layers panel
|
||||
* @param {WorkspacePage} workspacePage - The workspace page instance
|
||||
* @param {string} layerName - The name of the layer to select
|
||||
* @param {string} parentLayerName - The name of the parent layer to expand (optional)
|
||||
*/
|
||||
const selectLayer = async (workspacePage, layerName, parentLayerName) => {
|
||||
await workspacePage.clickToggableLayer("Board");
|
||||
if (parentLayerName) {
|
||||
await workspacePage.clickToggableLayer(parentLayerName);
|
||||
}
|
||||
await workspacePage.clickLeafLayer(layerName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the Inspect tab
|
||||
* @param {WorkspacePage} workspacePage - The workspace page instance
|
||||
*/
|
||||
|
||||
const openInspectTab = async (workspacePage) => {
|
||||
const inspectButton = workspacePage.page.getByRole("tab", {
|
||||
name: "Inspect",
|
||||
});
|
||||
await inspectButton.click();
|
||||
};
|
||||
|
||||
const selectColorSpace = async (workspacePage, colorSpace) => {
|
||||
const sidebar = workspacePage.page.getByTestId("right-sidebar");
|
||||
const colorSpaceSelector = sidebar.getByLabel("Select color space");
|
||||
await colorSpaceSelector.click();
|
||||
const colorSpaceOption = sidebar.getByRole("option", {
|
||||
name: colorSpace,
|
||||
});
|
||||
await colorSpaceOption.click();
|
||||
};
|
||||
|
||||
test.describe("Inspect tab - Styles", () => {
|
||||
test("Open Inspect tab", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.flex);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const switcherLabel = workspacePage.page.getByText("Layer info", {
|
||||
exact: true,
|
||||
});
|
||||
await expect(switcherLabel).toBeVisible();
|
||||
await expect(switcherLabel).toHaveText("Layer info");
|
||||
});
|
||||
test.describe("Inspect tab - Flex", () => {
|
||||
test("Shape Layout Flex ", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.flex);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Layout");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("Shape Layout Flex Element", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(
|
||||
workspacePage,
|
||||
shapeToLayerName.flexElement,
|
||||
shapeToLayerName.flex,
|
||||
);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Flex Element");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("Shape Layout Grid", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.grid);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Layout");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test.describe("Inspect tab - Shadow", () => {
|
||||
test("Shape Shadow - Single shadow", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.shadow);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Shadow");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("Shape Shadow - Multiple shadow", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.shadowMultiple);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Shadow");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
test("Shape Shadow - Composite shadow", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.shadowComposite);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Shadow");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const compositeShadowRow = propertyRow.first();
|
||||
await expect(compositeShadowRow).toBeVisible();
|
||||
|
||||
const compositeShadowTerm = compositeShadowRow.locator("dt");
|
||||
const compositeShadowDefinition = compositeShadowRow.locator("dd");
|
||||
|
||||
expect(compositeShadowTerm).toHaveText("Shadow", { exact: true });
|
||||
expect(compositeShadowDefinition).toContainText("shadowToken");
|
||||
});
|
||||
});
|
||||
|
||||
test("Shape - Blur", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.blur);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Blur");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test.describe("Inspect tab - Border radius", () => {
|
||||
test("Shape - Border radius - individual", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(
|
||||
workspacePage,
|
||||
shapeToLayerName.borderRadius.individual,
|
||||
shapeToLayerName.borderRadius.main,
|
||||
);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Size & position");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const borderStartStartRadius = propertyRow.filter({
|
||||
hasText: "Border start start radius",
|
||||
});
|
||||
await expect(borderStartStartRadius).toBeVisible();
|
||||
|
||||
const borderEndEndRadius = propertyRow.filter({
|
||||
hasText: "Border end end radius",
|
||||
});
|
||||
await expect(borderEndEndRadius).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shape - Border radius - multiple", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(
|
||||
workspacePage,
|
||||
shapeToLayerName.borderRadius.multiple,
|
||||
shapeToLayerName.borderRadius.main,
|
||||
);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Size & position");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(4);
|
||||
|
||||
const borderStartStartRadius = propertyRow.filter({
|
||||
hasText: "Border start start radius",
|
||||
});
|
||||
await expect(borderStartStartRadius).toBeVisible();
|
||||
|
||||
const borderStartEndRadius = propertyRow.filter({
|
||||
hasText: "Border start end radius",
|
||||
});
|
||||
await expect(borderStartEndRadius).toBeVisible();
|
||||
|
||||
const borderEndEndRadius = propertyRow.filter({
|
||||
hasText: "Border end end radius",
|
||||
});
|
||||
await expect(borderEndEndRadius).toBeVisible();
|
||||
|
||||
const borderEndStartRadius = propertyRow.filter({
|
||||
hasText: "Border end start radius",
|
||||
});
|
||||
await expect(borderEndStartRadius).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shape - Border radius - token", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(
|
||||
workspacePage,
|
||||
shapeToLayerName.borderRadius.token,
|
||||
shapeToLayerName.borderRadius.main,
|
||||
);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Size & position");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(4);
|
||||
|
||||
const borderStartEndRadius = propertyRow.filter({
|
||||
hasText: "Border start end radius",
|
||||
});
|
||||
await expect(borderStartEndRadius).toBeVisible();
|
||||
expect(borderStartEndRadius).toContainText("radius");
|
||||
|
||||
const borderEndStartRadius = propertyRow.filter({
|
||||
hasText: "Border end start radius",
|
||||
});
|
||||
expect(borderEndStartRadius).toContainText("radius");
|
||||
await expect(borderEndStartRadius).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Inspect tab - Fill", () => {
|
||||
test("Shape - Fill - Solid", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.fill.solid);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Fill");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("Change color space and ensure fill and shorthand changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.fill.solid);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Fill");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const backgroundRow = propertyRow.filter({
|
||||
hasText: "Background",
|
||||
});
|
||||
await expect(backgroundRow).toBeVisible();
|
||||
|
||||
// Ensure initial value and copied value are in HEX format
|
||||
expect(backgroundRow).toContainText("#0438d5 100%");
|
||||
|
||||
await copyPropertyFromPropertyRow(panel, "Background");
|
||||
|
||||
const backgroundHEX = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
expect(backgroundHEX).toContain("background: #0438d5FF;");
|
||||
|
||||
// Change color space to RGBA
|
||||
await selectColorSpace(workspacePage, "rgba");
|
||||
|
||||
// Ensure new value and copied value are in RGBA format
|
||||
expect(backgroundRow).toContainText("4, 56, 213, 1");
|
||||
|
||||
await copyPropertyFromPropertyRow(panel, "Background");
|
||||
const backgroundRGBA = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
expect(backgroundRGBA).toContain("background: rgba(4, 56, 213, 1);");
|
||||
});
|
||||
|
||||
test("Shape - Fill - Gradient", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.fill.gradient);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Fill");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("Shape - Fill - Image", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.fill.image);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Fill");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const imagePreview = panel.getByRole("img", {
|
||||
name: "Preview of the shape's fill",
|
||||
});
|
||||
await expect(imagePreview).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shape - Fill - Multiple", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.fill.multiple);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Fill");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const imagePreview = panel.getByRole("img", {
|
||||
name: "Preview of the shape's fill",
|
||||
});
|
||||
await expect(imagePreview).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shape - Fill - Token", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.fill.token);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Fill");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const fillToken = propertyRow.filter({
|
||||
hasText: "Background",
|
||||
});
|
||||
expect(fillToken).toContainText("primary");
|
||||
await expect(fillToken).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Inspect tab - Stroke", () => {
|
||||
test("Shape - Stroke - Solid", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.stroke.solid);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Stroke");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("Shape - Stroke - Gradient", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.stroke.gradient);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Stroke");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("Shape - Stroke - Image", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.stroke.image);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Stroke");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const imagePreview = panel.getByRole("img", {
|
||||
name: "Preview of the shape's fill",
|
||||
});
|
||||
await expect(imagePreview).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shape - Stroke - Multiple", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.stroke.multiple);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Stroke");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const imagePreview = panel.getByRole("img", {
|
||||
name: "Preview of the shape's fill",
|
||||
});
|
||||
await expect(imagePreview).toBeVisible();
|
||||
});
|
||||
|
||||
test("Shape - Stroke - Token", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.stroke.token);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Stroke");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const fillToken = propertyRow.filter({
|
||||
hasText: "Border color",
|
||||
});
|
||||
expect(fillToken).toContainText("primary");
|
||||
await expect(fillToken).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Inspect tab - Typography", () => {
|
||||
test("Text - simple", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.text.simple);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Text");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const textPreview = panel.getByRole("presentation");
|
||||
await expect(textPreview).toBeVisible();
|
||||
});
|
||||
|
||||
test("Text - token", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.text.token);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Text");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Test with multiple tokens
|
||||
const fontFamilyToken = propertyRow.filter({
|
||||
hasText: "Font family",
|
||||
});
|
||||
await expect(fontFamilyToken).toBeVisible();
|
||||
expect(fontFamilyToken).toContainText("font.sans");
|
||||
|
||||
const fontSizeToken = propertyRow.filter({
|
||||
hasText: "Font size",
|
||||
});
|
||||
await expect(fontSizeToken).toBeVisible();
|
||||
expect(fontSizeToken).toContainText("medium");
|
||||
|
||||
const fontWeightToken = propertyRow.filter({
|
||||
hasText: "Font weight",
|
||||
});
|
||||
await expect(fontWeightToken).toBeVisible();
|
||||
expect(fontWeightToken).toContainText("bold");
|
||||
|
||||
const textPreview = panel.getByRole("presentation");
|
||||
await expect(textPreview).toBeVisible();
|
||||
});
|
||||
test("Text - composite token", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.text.compositeToken);
|
||||
await openInspectTab(workspacePage);
|
||||
const panel = await getPanelByTitle(workspacePage, "Text");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const propertyRow = panel.getByTestId("property-row");
|
||||
const propertyRowCount = await propertyRow.count();
|
||||
|
||||
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const compositeTypographyRow = propertyRow.filter({
|
||||
hasText: "Typography",
|
||||
});
|
||||
await expect(compositeTypographyRow).toBeVisible();
|
||||
expect(compositeTypographyRow).toContainText("body");
|
||||
|
||||
const textPreview = panel.getByRole("presentation");
|
||||
await expect(textPreview).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Copy properties", () => {
|
||||
test("Copy single property", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.flex);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Layout");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
await copyPropertyFromPropertyRow(panel, "Display");
|
||||
|
||||
const shorthand = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
expect(shorthand).toBe("display: flex;");
|
||||
});
|
||||
test("Copy shorthand - multiple properties", async ({ page }) => {
|
||||
const workspacePage = new WorkspacePage(page);
|
||||
await setupFile(workspacePage);
|
||||
|
||||
await selectLayer(workspacePage, shapeToLayerName.shadow);
|
||||
await openInspectTab(workspacePage);
|
||||
|
||||
const panel = await getPanelByTitle(workspacePage, "Shadow");
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
await copyShorthand(panel);
|
||||
|
||||
const shorthand = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
expect(shorthand).toBe("box-shadow: 4px 4px 4px 0px #00000033;");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@
|
||||
"Auth related data events"
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.uuid :as uuid]
|
||||
@@ -148,9 +147,7 @@
|
||||
(defn login-with-ldap
|
||||
[params]
|
||||
|
||||
(dm/assert!
|
||||
"expected valid params"
|
||||
(sm/check schema:login-with-ldap params))
|
||||
(assert (sm/check schema:login-with-ldap params))
|
||||
|
||||
(ptk/reify ::login-with-ldap
|
||||
ptk/WatchEvent
|
||||
@@ -166,6 +163,32 @@
|
||||
(logged-in))))
|
||||
(rx/catch on-error))))))
|
||||
|
||||
(def ^:private schema:login-with-sso
|
||||
[:map {:title "login-with-sso"}
|
||||
[:provider [:or :string ::sm/uuid]]])
|
||||
|
||||
(defn login-with-sso
|
||||
"Start the SSO flow"
|
||||
[params]
|
||||
(assert (sm/check schema:login-with-sso params))
|
||||
(ptk/reify ::login-with-sso
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(->> (rp/cmd! :login-with-oidc params)
|
||||
(rx/map (fn [{:keys [redirect-uri] :as rsp}]
|
||||
(if redirect-uri
|
||||
(rt/nav-raw :uri redirect-uri)
|
||||
(ex/raise :type :internal
|
||||
:code :unexpected-response
|
||||
:hint "unexpected response from OIDC method"
|
||||
:resp (pr-str rsp)))))
|
||||
(rx/catch (fn [cause]
|
||||
(let [{:keys [type code] :as error} (ex-data cause)]
|
||||
(if (and (= type :restriction)
|
||||
(= code :provider-not-configured))
|
||||
(rx/of (ntf/error (tr "errors.auth-provider-not-configured")))
|
||||
(rx/throw cause)))))))))
|
||||
|
||||
(defn login-from-token
|
||||
"Used mainly as flow continuation after token validation."
|
||||
[{:keys [profile] :as tdata}]
|
||||
@@ -201,7 +224,7 @@
|
||||
;; --- EVENT: logout
|
||||
|
||||
(defn logged-out
|
||||
[]
|
||||
[{:keys [redirect-uri]}]
|
||||
(ptk/reify ::logged-out
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
@@ -209,12 +232,16 @@
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
(rx/merge
|
||||
;; NOTE: We need the `effect` of the current event to be
|
||||
;; executed before the redirect.
|
||||
(->> (rx/of (rt/nav :auth-login))
|
||||
(rx/observe-on :async))
|
||||
(rx/of (ws/finalize))))
|
||||
(if redirect-uri
|
||||
(->> (rx/of (rt/nav-raw :uri (str redirect-uri)))
|
||||
(rx/observe-on :async))
|
||||
|
||||
(rx/merge
|
||||
;; NOTE: We need the `effect` of the current event to be
|
||||
;; executed before the redirect.
|
||||
(->> (rx/of (rt/nav :auth-login))
|
||||
(rx/observe-on :async))
|
||||
(rx/of (ws/finalize)))))
|
||||
|
||||
ptk/EffectEvent
|
||||
(effect [_ _ _]
|
||||
@@ -235,7 +262,7 @@
|
||||
(rx/mapcat (fn [_]
|
||||
(->> (rp/cmd! :logout {:profile-id profile-id})
|
||||
(rx/delay-at-least 300)
|
||||
(rx/catch (constantly (rx/of 1))))))
|
||||
(rx/catch (constantly (rx/of nil))))))
|
||||
(rx/map logged-out))))))
|
||||
|
||||
;; --- Update Profile
|
||||
@@ -248,9 +275,7 @@
|
||||
(defn request-profile-recovery
|
||||
[data]
|
||||
|
||||
(dm/assert!
|
||||
"expected valid parameters"
|
||||
(sm/check schema:request-profile-recovery data))
|
||||
(assert (sm/check schema:request-profile-recovery data))
|
||||
|
||||
(ptk/reify ::request-profile-recovery
|
||||
ptk/WatchEvent
|
||||
@@ -273,9 +298,7 @@
|
||||
|
||||
(defn recover-profile
|
||||
[data]
|
||||
(dm/assert!
|
||||
"expected valid arguments"
|
||||
(sm/check schema:recover-profile data))
|
||||
(assert (sm/check schema:recover-profile data))
|
||||
|
||||
(ptk/reify ::recover-profile
|
||||
ptk/WatchEvent
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
(defn- persist-events
|
||||
[events]
|
||||
(if (seq events)
|
||||
(let [uri (u/join cf/public-uri "api/rpc/command/push-audit-events")
|
||||
(let [uri (u/join cf/public-uri "api/main/methods/push-audit-events")
|
||||
params {:uri uri
|
||||
:method :post
|
||||
:credentials "include"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.files.helpers :as cfh]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.types.path :as path]))
|
||||
@@ -207,3 +208,12 @@
|
||||
:projects
|
||||
(filter #(= team-id (:team-id (val %))))
|
||||
(into {}))))
|
||||
|
||||
(defn get-selrect
|
||||
[selrect-transform shape]
|
||||
(if (some? selrect-transform)
|
||||
(let [{:keys [center width height transform]} selrect-transform]
|
||||
[(gsh/center->rect center width height)
|
||||
(gmt/transform-in center transform)])
|
||||
[(dm/get-prop shape :selrect)
|
||||
(gsh/transform-matrix shape)]))
|
||||
|
||||
@@ -212,13 +212,14 @@
|
||||
;; Create a new objects only with the temporary modifications
|
||||
objects-changed
|
||||
(->> wasm-props
|
||||
(group-by first)
|
||||
(reduce
|
||||
(fn [objects [id properties]]
|
||||
(let [shape
|
||||
(->> properties
|
||||
(reduce
|
||||
(fn [shape {:keys [property value]}]
|
||||
(assoc shape property value))
|
||||
(fn [shape [_ operation]]
|
||||
(ctm/apply-modifier shape operation))
|
||||
(get objects id)))]
|
||||
(assoc objects id shape)))
|
||||
objects))]
|
||||
|
||||
@@ -56,16 +56,19 @@
|
||||
([shape]
|
||||
(resize-wasm-text-modifiers shape (:content shape)))
|
||||
|
||||
([{:keys [id points selrect] :as shape} content]
|
||||
([{:keys [id points selrect grow-type] :as shape} content]
|
||||
(wasm.api/use-shape id)
|
||||
(wasm.api/set-shape-text-content id content)
|
||||
(wasm.api/set-shape-text-images id content)
|
||||
|
||||
(let [dimension (wasm.api/get-text-dimensions)
|
||||
resize-v (gpt/point
|
||||
(/ (:width dimension) (-> selrect :width))
|
||||
(/ (:height dimension) (-> selrect :height)))
|
||||
|
||||
width-scale (if (#{:fixed :auto-height} grow-type)
|
||||
1.0
|
||||
(/ (:width dimension) (:width selrect)))
|
||||
height-scale (if (= :fixed grow-type)
|
||||
1.0
|
||||
(/ (:height dimension) (:height selrect)))
|
||||
resize-v (gpt/point width-scale height-scale)
|
||||
origin (first points)]
|
||||
|
||||
{id
|
||||
@@ -93,6 +96,16 @@
|
||||
(->> (rx/from ids)
|
||||
(rx/map resize-wasm-text)))))
|
||||
|
||||
;; -- Content helpers
|
||||
|
||||
(defn- v2-content-has-text?
|
||||
[content]
|
||||
(boolean
|
||||
(when content
|
||||
(some (fn [node]
|
||||
(not (str/blank? (:text node ""))))
|
||||
(txt/node-seq txt/is-text-node? content)))))
|
||||
|
||||
;; -- Editor
|
||||
|
||||
(defn update-editor
|
||||
@@ -945,28 +958,34 @@
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
new-shape? (nil? (:content shape))]
|
||||
(rx/of
|
||||
(dwsh/update-shapes
|
||||
[id]
|
||||
(fn [shape]
|
||||
(let [new-shape (-> shape
|
||||
(assoc :content content)
|
||||
(cond-> (and update-name? (some? name))
|
||||
(assoc :name name)))]
|
||||
new-shape))
|
||||
{:undo-group (when new-shape? id)})
|
||||
(rx/concat
|
||||
(rx/of
|
||||
(dwsh/update-shapes
|
||||
[id]
|
||||
(fn [shape]
|
||||
(let [new-shape (-> shape
|
||||
(assoc :content content)
|
||||
(cond-> (and update-name? (some? name))
|
||||
(assoc :name name)))]
|
||||
new-shape))
|
||||
{:undo-group (when new-shape? id)})
|
||||
|
||||
(if (and (not= :fixed (:grow-type shape)) finalize?)
|
||||
(dwm/apply-wasm-modifiers
|
||||
(resize-wasm-text-modifiers shape content)
|
||||
{:undo-group (when new-shape? id)})
|
||||
(if (and (not= :fixed (:grow-type shape)) finalize?)
|
||||
(dwm/apply-wasm-modifiers
|
||||
(resize-wasm-text-modifiers shape content)
|
||||
{:undo-group (when new-shape? id)})
|
||||
|
||||
(dwm/set-wasm-modifiers
|
||||
(resize-wasm-text-modifiers shape content)
|
||||
{:undo-group (when new-shape? id)}))
|
||||
(dwm/set-wasm-modifiers
|
||||
(resize-wasm-text-modifiers shape content)
|
||||
{:undo-group (when new-shape? id)})))
|
||||
|
||||
(when finalize?
|
||||
(dwt/finish-transform))))
|
||||
(rx/concat
|
||||
(when (and (not (v2-content-has-text? content)) (some? id))
|
||||
(rx/of
|
||||
(dws/deselect-shape id)
|
||||
(dwsh/delete-shapes #{id})))
|
||||
(rx/of (dwt/finish-transform))))))
|
||||
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
|
||||
(declare token-properties)
|
||||
(declare update-layout-item-margin)
|
||||
(declare all-attrs-appliable-for-token?)
|
||||
|
||||
;; Events to update the value of attributes with applied tokens ---------------------------------------------------------
|
||||
|
||||
@@ -519,7 +520,8 @@
|
||||
(or
|
||||
(and (ctsl/any-layout-immediate-child? objects shape)
|
||||
(some ctt/spacing-margin-keys attributes))
|
||||
(ctt/any-appliable-attr? attributes (:type shape) (:layout shape))))))
|
||||
(and (ctt/any-appliable-attr-for-shape? attributes (:type shape) (:layout shape))
|
||||
(all-attrs-appliable-for-token? attributes (:type token)))))))
|
||||
shape-ids (d/nilv (keys shapes) [])
|
||||
any-variant? (->> shapes vals (some ctk/is-variant?) boolean)
|
||||
|
||||
@@ -596,6 +598,7 @@
|
||||
(watch [_ state _]
|
||||
(let [objects (dsh/lookup-page-objects state)
|
||||
shapes (into [] (keep (d/getf objects)) shape-ids)
|
||||
|
||||
shapes
|
||||
(if expand-with-children
|
||||
(into []
|
||||
@@ -605,10 +608,15 @@
|
||||
[shape])))
|
||||
shapes)
|
||||
shapes)
|
||||
|
||||
{:keys [attributes all-attributes on-update-shape]}
|
||||
(get token-properties (:type token))
|
||||
|
||||
unapply-tokens?
|
||||
(cft/shapes-token-applied? token shapes (or attrs all-attributes attributes))]
|
||||
(cft/shapes-token-applied? token shapes (or attrs all-attributes attributes))
|
||||
|
||||
shape-ids (map :id shapes)]
|
||||
|
||||
(if unapply-tokens?
|
||||
(rx/of
|
||||
(unapply-token {:attributes (or attrs all-attributes attributes)
|
||||
@@ -620,7 +628,7 @@
|
||||
(apply-spacing-token {:token token
|
||||
:attr attrs
|
||||
:shapes shapes})
|
||||
(apply-token {:attributes (or attrs attributes)
|
||||
(apply-token {:attributes (if (empty? attrs) attributes attrs)
|
||||
:token token
|
||||
:shape-ids shape-ids
|
||||
:on-update-shape on-update-shape}))))))))
|
||||
@@ -808,3 +816,22 @@
|
||||
|
||||
(defn get-token-properties [token]
|
||||
(get token-properties (:type token)))
|
||||
|
||||
(defn get-update-shape-fn
|
||||
"Get the function that updates the attributes of a shape if this token is applied."
|
||||
[token]
|
||||
(when token
|
||||
(-> (get-token-properties token)
|
||||
:on-update-shape)))
|
||||
|
||||
(defn appliable-attributes-for-token
|
||||
"Get the attributes to which this token type can be applied."
|
||||
[token-type]
|
||||
(let [props (get token-properties token-type)]
|
||||
(or (:all-attributes props)
|
||||
(:attributes props))))
|
||||
|
||||
(defn all-attrs-appliable-for-token?
|
||||
"Check if any of the given attributes can be applied for the given token type."
|
||||
[attributes token-type]
|
||||
(set/subset? attributes (appliable-attributes-for-token token-type)))
|
||||
|
||||
@@ -84,7 +84,8 @@
|
||||
new-token-theme))]
|
||||
(rx/of (dch/commit-changes changes)))))))))
|
||||
|
||||
(defn update-token-theme [id token-theme]
|
||||
(defn update-token-theme
|
||||
[id token-theme]
|
||||
(ptk/reify ::update-token-theme
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
@@ -101,27 +102,38 @@
|
||||
(pcb/set-token-theme (ctob/get-id token-theme) token-theme))]
|
||||
(rx/of (dch/commit-changes changes))))))))
|
||||
|
||||
(defn toggle-token-theme-active? [id]
|
||||
(ptk/reify ::toggle-token-theme-active?
|
||||
(defn set-token-theme-active
|
||||
[id active?]
|
||||
(assert (uuid? id) "expected a uuid for `id`")
|
||||
(assert (boolean? active?) "expected a boolean for `active?`")
|
||||
(ptk/reify ::set-token-theme-active
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [data (dsh/lookup-file-data state)
|
||||
tokens-lib (get-tokens-lib state)
|
||||
changes (-> (pcb/empty-changes)
|
||||
(pcb/with-library-data data)
|
||||
(clt/generate-set-active-token-theme tokens-lib id active?))]
|
||||
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(dwtp/propagate-workspace-tokens))))))
|
||||
|
||||
(defn toggle-token-theme-active
|
||||
[id]
|
||||
(ptk/reify ::toggle-token-theme-active
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [data (dsh/lookup-file-data state)
|
||||
|
||||
tokens-lib (get-tokens-lib state)
|
||||
active-token-themes (some-> tokens-lib
|
||||
(ctob/toggle-theme-active? id)
|
||||
(ctob/get-active-theme-paths))
|
||||
active-token-themes' (if (= active-token-themes #{ctob/hidden-theme-path})
|
||||
active-token-themes
|
||||
(disj active-token-themes ctob/hidden-theme-path))
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/set-active-token-themes active-token-themes'))]
|
||||
(clt/generate-toggle-token-theme tokens-lib id))]
|
||||
(rx/of
|
||||
(dch/commit-changes changes)
|
||||
(dwtp/propagate-workspace-tokens))))))
|
||||
|
||||
(defn delete-token-theme [id]
|
||||
(defn delete-token-theme
|
||||
[id]
|
||||
(ptk/reify ::delete-token-theme
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
@@ -134,7 +146,7 @@
|
||||
(dwtp/propagate-workspace-tokens))))))
|
||||
|
||||
(defn create-token-set
|
||||
[set-name]
|
||||
[token-set]
|
||||
(ptk/reify ::create-token-set
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
@@ -145,20 +157,20 @@
|
||||
(watch [it state _]
|
||||
(let [data (dsh/lookup-file-data state)
|
||||
tokens-lib (get data :tokens-lib)
|
||||
set-name (ctob/normalize-set-name set-name)]
|
||||
(if (and tokens-lib (ctob/get-set-by-name tokens-lib set-name))
|
||||
token-set (ctob/rename token-set (ctob/normalize-set-name (ctob/get-name token-set)))]
|
||||
(if (and tokens-lib (ctob/get-set-by-name tokens-lib (ctob/get-name token-set)))
|
||||
(rx/of (ntf/show {:content (tr "errors.token-set-already-exists")
|
||||
:type :toast
|
||||
:level :error
|
||||
:timeout 9000}))
|
||||
(let [token-set (ctob/make-token-set :name set-name)
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/set-token-set (ctob/get-id token-set) token-set))]
|
||||
(let [changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/set-token-set (ctob/get-id token-set) token-set))]
|
||||
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
|
||||
(dch/commit-changes changes))))))))
|
||||
|
||||
(defn rename-token-set-group [set-group-path set-group-fname]
|
||||
(defn rename-token-set-group
|
||||
[set-group-path set-group-fname]
|
||||
(ptk/reify ::rename-token-set-group
|
||||
ptk/WatchEvent
|
||||
(watch [it _state _]
|
||||
@@ -203,6 +215,22 @@
|
||||
(rx/of (set-selected-token-set-id (ctob/get-id token-set))
|
||||
(dch/commit-changes changes))))))))
|
||||
|
||||
(defn set-enabled-token-set
|
||||
[name enabled?]
|
||||
(assert (string? name) "expected a string for `name`")
|
||||
(assert (boolean? enabled?) "expected a boolean for `enabled?`")
|
||||
(ptk/reify ::set-enabled-token-set
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(let [data (dsh/lookup-file-data state)
|
||||
tlib (get-tokens-lib state)
|
||||
changes (-> (pcb/empty-changes)
|
||||
(pcb/with-library-data data)
|
||||
(clt/generate-set-enabled-token-set tlib name enabled?))]
|
||||
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(dwtp/propagate-workspace-tokens))))))
|
||||
|
||||
(defn toggle-token-set
|
||||
[name]
|
||||
(assert (string? name) "expected a string for `name`")
|
||||
@@ -218,7 +246,8 @@
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(dwtp/propagate-workspace-tokens))))))
|
||||
|
||||
(defn toggle-token-set-group [group-path]
|
||||
(defn toggle-token-set-group
|
||||
[group-path]
|
||||
(ptk/reify ::toggle-token-set-group
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
@@ -230,7 +259,8 @@
|
||||
(dch/commit-changes changes)
|
||||
(dwtp/propagate-workspace-tokens))))))
|
||||
|
||||
(defn import-tokens-lib [lib]
|
||||
(defn import-tokens-lib
|
||||
[lib]
|
||||
(ptk/reify ::import-tokens-lib
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
@@ -265,7 +295,8 @@
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(dwtp/propagate-workspace-tokens))))))
|
||||
|
||||
(defn drop-error [{:keys [error to-path]}]
|
||||
(defn drop-error
|
||||
[{:keys [error to-path]}]
|
||||
(ptk/reify ::drop-error
|
||||
ptk/WatchEvent
|
||||
(watch [_ _ _]
|
||||
@@ -282,7 +313,8 @@
|
||||
|
||||
;; FIXME: add schema for params
|
||||
|
||||
(defn drop-token-set-group [drop-opts]
|
||||
(defn drop-token-set-group
|
||||
[drop-opts]
|
||||
(ptk/reify ::drop-token-set-group
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
@@ -344,47 +376,52 @@
|
||||
(set-selected-token-set-id (ctob/get-id token-set)))))))
|
||||
|
||||
(defn create-token
|
||||
[params]
|
||||
(let [token (ctob/make-token params)]
|
||||
(ptk/reify ::create-token
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(if-let [token-set (lookup-token-set state)]
|
||||
(let [data (dsh/lookup-file-data state)
|
||||
token-type (:type token)
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/set-token (ctob/get-id token-set)
|
||||
(:id token)
|
||||
token))]
|
||||
([token] (create-token nil token))
|
||||
([set-id token]
|
||||
(ptk/reify ::create-token
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(if-let [token-set (if set-id
|
||||
(lookup-token-set state set-id)
|
||||
(lookup-token-set state))]
|
||||
(let [data (dsh/lookup-file-data state)
|
||||
token-type (:type token)
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/set-token (ctob/get-id token-set)
|
||||
(:id token)
|
||||
token))]
|
||||
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(ptk/data-event ::ev/event {::ev/name "create-token" :type token-type})))
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(ptk/data-event ::ev/event {::ev/name "create-token" :type token-type})))
|
||||
|
||||
(rx/of (create-token-with-set token)))))))
|
||||
(rx/of (create-token-with-set token)))))))
|
||||
|
||||
(defn update-token
|
||||
[id params]
|
||||
(assert (uuid? id) "expected uuid for `id`")
|
||||
([id params] (update-token nil id params))
|
||||
([set-id id params]
|
||||
(assert (uuid? id) "expected uuid for `id`")
|
||||
|
||||
(ptk/reify ::update-token
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [token-set (lookup-token-set state)
|
||||
data (dsh/lookup-file-data state)
|
||||
token (-> (get-tokens-lib state)
|
||||
(ctob/get-token (ctob/get-id token-set) id))
|
||||
token' (->> (merge token params)
|
||||
(into {})
|
||||
(ctob/make-token))
|
||||
token-type (:type token)
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/set-token (ctob/get-id token-set)
|
||||
id
|
||||
token'))]
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(ptk/data-event ::ev/event {::ev/name "edit-token" :type token-type}))))))
|
||||
(ptk/reify ::update-token
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(let [token-set (if set-id
|
||||
(lookup-token-set state set-id)
|
||||
(lookup-token-set state))
|
||||
data (dsh/lookup-file-data state)
|
||||
token (-> (get-tokens-lib state)
|
||||
(ctob/get-token (ctob/get-id token-set) id))
|
||||
token' (->> (merge token params)
|
||||
(into {})
|
||||
(ctob/make-token))
|
||||
token-type (:type token)
|
||||
changes (-> (pcb/empty-changes it)
|
||||
(pcb/with-library-data data)
|
||||
(pcb/set-token (ctob/get-id token-set)
|
||||
id
|
||||
token'))]
|
||||
(rx/of (dch/commit-changes changes)
|
||||
(ptk/data-event ::ev/event {::ev/name "edit-token" :type token-type})))))))
|
||||
|
||||
(defn delete-token
|
||||
[set-id token-id]
|
||||
@@ -413,10 +450,11 @@
|
||||
(let [tokens (vals (ctob/get-tokens tokens-lib (ctob/get-id token-set)))
|
||||
unames (map :name tokens)
|
||||
suffix (tr "workspace.tokens.duplicate-suffix")
|
||||
copy-name (cfh/generate-unique-name (:name token) unames :suffix suffix)]
|
||||
(rx/of (create-token (assoc token
|
||||
:id (uuid/next)
|
||||
:name copy-name))))))))))
|
||||
copy-name (cfh/generate-unique-name (:name token) unames :suffix suffix)
|
||||
new-token (-> token
|
||||
(ctob/reid (uuid/next))
|
||||
(ctob/rename copy-name))]
|
||||
(rx/of (create-token new-token)))))))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; TOKEN UI OPS
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
[app.main.data.workspace :as-alias dw]
|
||||
[app.main.router :as rt]
|
||||
[app.main.store :as st]
|
||||
[app.main.worker]
|
||||
[app.util.globals :as glob]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.timers :as ts]
|
||||
@@ -94,6 +95,9 @@
|
||||
(let [data (exception->error-data error)]
|
||||
(ptk/handle-error data))))
|
||||
|
||||
;; Inject dependency to remove circular dependency
|
||||
(set! app.main.worker/on-error on-error)
|
||||
|
||||
;; Set the main potok error handler
|
||||
(reset! st/on-error on-error)
|
||||
|
||||
|
||||
@@ -154,6 +154,9 @@
|
||||
"All tokens related ephimeral state"
|
||||
(l/derived :workspace-tokens st/state))
|
||||
|
||||
(def workspace-selrect
|
||||
(l/derived :workspace-selrect st/state))
|
||||
|
||||
;; WARNING: Don't use directly from components, this is a proxy to
|
||||
;; improve performance of selected-shapes and
|
||||
(def ^:private selected-shapes-data
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
|
||||
request
|
||||
{:method method
|
||||
:uri (u/join cf/public-uri "api/rpc/command/" nid)
|
||||
:uri (u/join cf/public-uri "api/main/methods/" nid)
|
||||
:credentials "include"
|
||||
:headers {"accept" "application/transit+json,text/event-stream,*/*"
|
||||
"x-external-session-id" (cf/external-session-id)
|
||||
@@ -171,9 +171,8 @@
|
||||
(send! id params nil))
|
||||
|
||||
(defmethod cmd! :login-with-oidc
|
||||
[_ {:keys [provider] :as params}]
|
||||
(let [uri (u/join cf/public-uri "api/auth/oauth/" (d/name provider))
|
||||
params (dissoc params :provider)]
|
||||
[_ params]
|
||||
(let [uri (u/join cf/public-uri "api/auth/oidc")]
|
||||
(->> (http/send! {:method :post
|
||||
:uri uri
|
||||
:credentials "include"
|
||||
@@ -207,7 +206,7 @@
|
||||
(defmethod cmd! ::multipart-upload
|
||||
[id params]
|
||||
(->> (http/send! {:method :post
|
||||
:uri (u/join cf/public-uri "api/rpc/command/" (name id))
|
||||
:uri (u/join cf/public-uri "api/main/methods/" (name id))
|
||||
:credentials "include"
|
||||
:headers {"x-external-session-id" (cf/external-session-id)
|
||||
"x-event-origin" (::ev/origin (meta params))}
|
||||
|
||||
@@ -23,12 +23,11 @@
|
||||
[app.main.ui.notifications.context-notification :refer [context-notification]]
|
||||
[app.util.dom :as dom]
|
||||
[app.util.i18n :refer [tr]]
|
||||
[app.util.keyboard :as k]
|
||||
[app.util.storage :as s]
|
||||
[beicon.v2.core :as rx]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(def show-alt-login-buttons?
|
||||
(def ^:const show-sso-login-buttons?
|
||||
(some (partial contains? cf/flags)
|
||||
[:login-with-google
|
||||
:login-with-github
|
||||
@@ -47,53 +46,36 @@
|
||||
(st/emit! (da/create-demo-profile)))
|
||||
|
||||
(defn- store-login-redirect
|
||||
[save-login-redirect]
|
||||
[]
|
||||
(binding [s/*sync* true]
|
||||
(if (some? save-login-redirect)
|
||||
;; Save the current login raw uri for later redirect user back to
|
||||
;; the same page, we need it to be synchronous because the user is
|
||||
;; going to be redirected instantly to the oidc provider uri
|
||||
(swap! s/session assoc :login-redirect (rt/get-current-href))
|
||||
;; Clean the login redirect
|
||||
(swap! s/session dissoc :login-redirect))))
|
||||
;; Save the current login raw uri for later redirect user back to
|
||||
;; the same page, we need it to be synchronous because the user is
|
||||
;; going to be redirected instantly to the oidc provider uri
|
||||
(swap! s/session assoc :login-redirect (rt/get-current-href))))
|
||||
|
||||
(defn- login-with-oidc
|
||||
[event provider params]
|
||||
(dom/prevent-default event)
|
||||
(defn- clear-login-redirect
|
||||
[]
|
||||
(binding [s/*sync* true]
|
||||
(swap! s/session dissoc :login-redirect)))
|
||||
|
||||
(store-login-redirect (:save-login-redirect params))
|
||||
|
||||
;; FIXME: this code should be probably moved outside of the UI
|
||||
(->> (rp/cmd! :login-with-oidc (assoc params :provider provider))
|
||||
(rx/subs! (fn [{:keys [redirect-uri] :as rsp}]
|
||||
(if redirect-uri
|
||||
(st/emit! (rt/nav-raw :uri redirect-uri))
|
||||
(log/error :hint "unexpected response from OIDC method"
|
||||
:resp (pr-str rsp))))
|
||||
(fn [cause]
|
||||
(let [{:keys [type code] :as error} (ex-data cause)]
|
||||
(cond
|
||||
(and (= type :restriction)
|
||||
(= code :provider-not-configured))
|
||||
(st/emit! (ntf/error (tr "errors.auth-provider-not-configured")))
|
||||
|
||||
:else
|
||||
(st/emit! (ntf/error (tr "errors.generic")))))))))
|
||||
(defn- login-with-sso
|
||||
[provider params]
|
||||
(let [params (assoc params :provider provider)]
|
||||
(st/emit! (da/login-with-sso params))))
|
||||
|
||||
(def ^:private schema:login-form
|
||||
[:map {:title "LoginForm"}
|
||||
[:email [::sm/email {:error/code "errors.invalid-email"}]]
|
||||
[:password [:string {:min 1}]]
|
||||
[:password {:optional true} [:string {:min 1}]]
|
||||
[:invitation-token {:optional true}
|
||||
[:string {:min 1}]]])
|
||||
|
||||
(mf/defc login-form
|
||||
[{:keys [params on-success-callback on-recovery-request origin] :as props}]
|
||||
(mf/defc login-form*
|
||||
[{:keys [params handle-redirect on-success-callback on-recovery-request origin] :as props}]
|
||||
(let [initial (mf/with-memo [params] params)
|
||||
error (mf/use-state false)
|
||||
form (fm/use-form :schema schema:login-form
|
||||
:initial initial)
|
||||
|
||||
on-error
|
||||
(fn [cause]
|
||||
(let [cause (ex-data cause)]
|
||||
@@ -121,20 +103,41 @@
|
||||
:else
|
||||
(reset! error (tr "errors.generic")))))
|
||||
|
||||
show-password-field*
|
||||
(mf/use-state #(not (contains? cf/flags :login-with-custom-sso)))
|
||||
|
||||
show-password-field?
|
||||
(deref show-password-field*)
|
||||
|
||||
on-success
|
||||
(fn [data]
|
||||
(when (fn? on-success-callback)
|
||||
(on-success-callback data)))
|
||||
|
||||
on-submit
|
||||
(mf/use-callback
|
||||
(mf/use-fn
|
||||
(mf/deps show-password-field? params)
|
||||
(fn [form _event]
|
||||
(store-login-redirect (:save-login-redirect params))
|
||||
(reset! error nil)
|
||||
(let [params (with-meta (:clean-data @form)
|
||||
{:on-error on-error
|
||||
:on-success on-success})]
|
||||
(st/emit! (da/login params)))))
|
||||
|
||||
(let [data (:clean-data @form)]
|
||||
(if show-password-field?
|
||||
(let [params (-> (merge params data)
|
||||
(with-meta {:on-error on-error
|
||||
:on-success on-success}))]
|
||||
(st/emit! (da/login params)))
|
||||
|
||||
(let [params (merge params data)]
|
||||
(->> (rp/cmd! :get-sso-provider {:email (:email params)})
|
||||
(rx/map :id)
|
||||
(rx/catch (fn [cause]
|
||||
(log/error :hint "error on retrieving sso provider" :cause cause)
|
||||
(rx/of nil)))
|
||||
(rx/subs! (fn [sso-provider-id]
|
||||
(if sso-provider-id
|
||||
(let [params {:provider sso-provider-id}]
|
||||
(st/emit! (da/login-with-sso params)))
|
||||
(reset! show-password-field* true))))))))))
|
||||
|
||||
on-submit-ldap
|
||||
(mf/use-callback
|
||||
@@ -150,12 +153,15 @@
|
||||
:on-success on-success})]
|
||||
(st/emit! (da/login-with-ldap params)))))
|
||||
|
||||
default-recovery-req
|
||||
(mf/use-fn
|
||||
#(st/emit! (rt/nav :auth-recovery-request)))
|
||||
on-recovery-request
|
||||
(or on-recovery-request
|
||||
#(st/emit! (rt/nav :auth-recovery-request)))]
|
||||
|
||||
on-recovery-request (or on-recovery-request
|
||||
default-recovery-req)]
|
||||
|
||||
(mf/with-effect [handle-redirect]
|
||||
(if handle-redirect
|
||||
(store-login-redirect)
|
||||
(clear-login-redirect)))
|
||||
|
||||
[:*
|
||||
(when-let [message @error]
|
||||
@@ -165,6 +171,7 @@
|
||||
[:& fm/form {:on-submit on-submit
|
||||
:class (stl/css :login-form)
|
||||
:form form}
|
||||
|
||||
[:div {:class (stl/css :fields-row)}
|
||||
[:& fm/input
|
||||
{:name :email
|
||||
@@ -172,12 +179,14 @@
|
||||
:label (tr "auth.work-email")
|
||||
:class (stl/css :form-field)}]]
|
||||
|
||||
[:div {:class (stl/css :fields-row)}
|
||||
[:& fm/input
|
||||
{:type "password"
|
||||
:name :password
|
||||
:label (tr "auth.password")
|
||||
:class (stl/css :form-field)}]]
|
||||
(when show-password-field?
|
||||
[:div {:class (stl/css :fields-row)}
|
||||
[:& fm/input
|
||||
{:type "password"
|
||||
:name :password
|
||||
:auto-focus? true
|
||||
:label (tr "auth.password")
|
||||
:class (stl/css :form-field)}]])
|
||||
|
||||
(when (and (not= origin :viewer)
|
||||
(or (contains? cf/flags :login)
|
||||
@@ -192,7 +201,7 @@
|
||||
(when (or (contains? cf/flags :login)
|
||||
(contains? cf/flags :login-with-password))
|
||||
[:> fm/submit-button*
|
||||
{:label (tr "auth.login-submit")
|
||||
{:label (tr "labels.continue")
|
||||
:data-testid "login-submit"
|
||||
:class (stl/css :login-button)}])
|
||||
|
||||
@@ -202,12 +211,12 @@
|
||||
:class (stl/css :login-ldap-button)
|
||||
:on-click on-submit-ldap}])]]]))
|
||||
|
||||
(mf/defc login-buttons
|
||||
(mf/defc login-sso-buttons*
|
||||
[{:keys [params] :as props}]
|
||||
(let [login-with-google (mf/use-fn (mf/deps params) #(login-with-oidc % :google params))
|
||||
login-with-github (mf/use-fn (mf/deps params) #(login-with-oidc % :github params))
|
||||
login-with-gitlab (mf/use-fn (mf/deps params) #(login-with-oidc % :gitlab params))
|
||||
login-with-oidc (mf/use-fn (mf/deps params) #(login-with-oidc % :oidc params))]
|
||||
(let [login-with-google (mf/use-fn (mf/deps params) #(login-with-sso "google" params))
|
||||
login-with-github (mf/use-fn (mf/deps params) #(login-with-sso "github" params))
|
||||
login-with-gitlab (mf/use-fn (mf/deps params) #(login-with-sso "gitlab" params))
|
||||
login-with-oidc (mf/use-fn (mf/deps params) #(login-with-sso "oidc" params))]
|
||||
|
||||
[:div {:class (stl/css :auth-buttons)}
|
||||
(when (contains? cf/flags :login-with-google)
|
||||
@@ -234,32 +243,12 @@
|
||||
:label (tr "auth.login-with-oidc-submit")
|
||||
:class (stl/css :login-btn :btn-oidc-auth)}])]))
|
||||
|
||||
(mf/defc login-button-oidc
|
||||
(mf/defc login-dialog*
|
||||
[{:keys [params] :as props}]
|
||||
(let [login-oidc
|
||||
(mf/use-fn
|
||||
(mf/deps params)
|
||||
(fn [event]
|
||||
(login-with-oidc event :oidc params)))
|
||||
|
||||
handle-key-down
|
||||
(mf/use-fn
|
||||
(fn [event]
|
||||
(when (k/enter? event)
|
||||
(login-oidc event))))]
|
||||
(when (contains? cf/flags :login-with-oidc)
|
||||
[:button {:tab-index "0"
|
||||
:class (stl/css :link-entry :link-oidc)
|
||||
:on-key-down handle-key-down
|
||||
:on-click login-oidc}
|
||||
(tr "auth.login-with-oidc-submit")])))
|
||||
|
||||
(mf/defc login-methods
|
||||
[{:keys [params on-success-callback on-recovery-request origin] :as props}]
|
||||
[:*
|
||||
(when show-alt-login-buttons?
|
||||
(when show-sso-login-buttons?
|
||||
[:*
|
||||
[:& login-buttons {:params params}]
|
||||
[:> login-sso-buttons* {:params params}]
|
||||
|
||||
(when (or (contains? cf/flags :login)
|
||||
(contains? cf/flags :login-with-password)
|
||||
@@ -269,7 +258,7 @@
|
||||
(when (or (contains? cf/flags :login)
|
||||
(contains? cf/flags :login-with-password)
|
||||
(contains? cf/flags :login-with-ldap))
|
||||
[:& login-form {:params params :on-success-callback on-success-callback :on-recovery-request on-recovery-request :origin origin}])])
|
||||
[:> login-form* props])])
|
||||
|
||||
(mf/defc login-page
|
||||
[{:keys [params] :as props}]
|
||||
@@ -287,7 +276,7 @@
|
||||
(when (contains? cf/flags :demo-warning)
|
||||
[:& demo-warning])
|
||||
|
||||
[:& login-methods {:params params}]
|
||||
[:> login-dialog* {:params params}]
|
||||
|
||||
[:hr {:class (stl/css :separator)}]
|
||||
|
||||
|
||||
@@ -191,9 +191,9 @@
|
||||
{::mf/props :obj}
|
||||
[{:keys [params hide-separator on-success-callback]}]
|
||||
[:*
|
||||
(when login/show-alt-login-buttons?
|
||||
[:& login/login-buttons {:params params}])
|
||||
(when (or login/show-alt-login-buttons? (false? hide-separator))
|
||||
(when login/show-sso-login-buttons?
|
||||
[:> login/login-sso-buttons* {:params params}])
|
||||
(when (or login/show-sso-login-buttons? (false? hide-separator))
|
||||
[:hr {:class (stl/css :separator)}])
|
||||
(when (contains? cf/flags :login-with-password)
|
||||
[:& register-form {:params params :on-success-callback on-success-callback}])])
|
||||
|
||||