Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc03b4d462 | ||
|
|
ad27236e52 | ||
|
|
781231f5cb | ||
|
|
d93c690ccb | ||
|
|
09736aa4c9 | ||
|
|
f9c02926b9 | ||
|
|
bae3900537 | ||
|
|
757a5bd479 | ||
|
|
8952d70fd2 | ||
|
|
4ce459d720 | ||
|
|
c589563912 | ||
|
|
0eb3179016 | ||
|
|
d1ebf4cda2 | ||
|
|
fdb9e97572 | ||
|
|
9cd3b63eea | ||
|
|
94555c027e | ||
|
|
b283d952a8 | ||
|
|
ac84557740 | ||
|
|
43f7e49aa0 | ||
|
|
eca1d81692 | ||
|
|
d263c23a58 |
No files matched your search
@@ -20,6 +20,17 @@ Before drafting any commit, read `mem:workflow/creating-commits` end-to-end. It
|
||||
is the authoritative source for the commit message format, the emoji menu,
|
||||
subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
|
||||
|
||||
## Iron Rules (non-negotiable)
|
||||
|
||||
1. **Wrap every body line at 76 characters or fewer.** Count characters, do
|
||||
not eyeball. Exceptions: `Signed-off-by:` / `AI-assisted-by:` trailers and
|
||||
lines carrying a URL. This is the rule agents skip most often.
|
||||
2. **Subject ≤70 chars**, imperative, capitalized, no trailing period.
|
||||
3. **Blank line between subject and body.**
|
||||
4. **Run `./scripts/check-commit` and require exit code 0.** It mechanically
|
||||
checks rules 1–3. A non-zero exit is a hard blocker: fix the message and
|
||||
re-commit. Never report the commit as done with a failing checker.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Stage the files** specified by the calling context. Do not ask for
|
||||
@@ -29,12 +40,18 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
|
||||
that does not match the stated intent, **STOP** and tell the user before
|
||||
committing.
|
||||
3. Draft the message following the format in the memory doc, wrapping the body
|
||||
at 72 characters per line, and run:
|
||||
at 76 characters per line, and run:
|
||||
```bash
|
||||
git commit -m "<subject>" -m "<body>"
|
||||
```
|
||||
(or `git commit -F -` if the body has unusual characters).
|
||||
4. The `AI-assisted-by` trailer value is provided by the calling context — use
|
||||
4. **Verify the message with the checker**:
|
||||
```bash
|
||||
./scripts/check-commit
|
||||
```
|
||||
If it fails, amend the message (`git commit --amend`) until it passes. Do
|
||||
not finish with a failing checker.
|
||||
5. The `AI-assisted-by` trailer value is provided by the calling context — use
|
||||
it verbatim.
|
||||
|
||||
## Constraints
|
||||
@@ -45,3 +62,4 @@ subject/body limits, and the `AI-assisted-by` trailer. Follow it exactly.
|
||||
- Do not amend a commit you did not create in this session, unless explicitly asked.
|
||||
- Do not bypass pre-commit hooks (`--no-verify`) unless explicitly asked.
|
||||
- Do not add untracked files that were not created in this session.
|
||||
- Do not skip the `scripts/check-commit` verification step (Iron Rule 4).
|
||||
@@ -1,13 +1,11 @@
|
||||
---
|
||||
name: planner
|
||||
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps.
|
||||
description: Read-only planning and architecture analysis — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan, suggested save path and the next steps.
|
||||
---
|
||||
|
||||
# Planner
|
||||
|
||||
Read-only senior software architect role for Penpot. Produces structured
|
||||
implementation plans with task breakdowns that engineers or other agents can
|
||||
execute. Never writes or modifies code.
|
||||
Produce a plan that another engineer or agent can execute without guessing.
|
||||
|
||||
## When to Use
|
||||
|
||||
@@ -21,24 +19,7 @@ execute. Never writes or modifies code.
|
||||
- A task feels too large or vague to start.
|
||||
- Work needs to be parallelized across multiple agents or sessions.
|
||||
|
||||
Do **not** use this skill to actually implement anything — it is read-only.
|
||||
|
||||
**When NOT to use:** Single-file changes with obvious scope, or when the spec
|
||||
already contains well-defined tasks.
|
||||
|
||||
## Role
|
||||
|
||||
You help users understand the Penpot codebase, design solutions, and produce
|
||||
implementation plans that other agents or developers can execute. The plan
|
||||
tells them what to build and how to verify it, task by task.
|
||||
|
||||
The implementer reads the project's agent docs (`AGENTS.md`, project memories
|
||||
such as `mem:critical-info`, `mem:testing`, and each module's core memory)
|
||||
before working. Reference those memories instead of re-explaining tooling,
|
||||
conventions, or test design — explain in the plan only what they do not cover.
|
||||
|
||||
Do **not** suggest commit messages or commit names anywhere in your plans or
|
||||
responses — committing is the implementer's responsibility.
|
||||
Do not use for a small change with obvious scope or an existing executable plan.
|
||||
|
||||
## CRITICAL: Required Reading Before Planning
|
||||
|
||||
@@ -55,67 +36,36 @@ Before drafting any plan, work through the project's own guidance:
|
||||
|
||||
Skipping this step is the #1 cause of incorrect or incomplete plans.
|
||||
|
||||
---
|
||||
## Constraints
|
||||
|
||||
## The Planning Process
|
||||
- You are **analysis-only** — never create, edit, or delete source code. The
|
||||
only file you may write is the plan itself, and only when the command or
|
||||
user explicitly instructs you to save it.
|
||||
- You do **not** run builds, tests, linters, or any commands that modify state.
|
||||
- You do **not** create git commits or interact with version control.
|
||||
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||
`find`, `cat`, `bat`).
|
||||
- Your output is a structured plan or analysis, ready for handoff to an
|
||||
engineer agent or developer.
|
||||
|
||||
### Phase 1: Architecture Analysis
|
||||
## Planning Process
|
||||
|
||||
1. Read the spec, requirements, or feature request.
|
||||
2. Analyze the codebase architecture and identify affected modules.
|
||||
3. Read project conventions (starting with `critical-info` and module core
|
||||
memories) before drafting.
|
||||
4. Map dependencies between components (see the dependency graph in
|
||||
`critical-info`).
|
||||
5. Identify risks, edge cases, performance implications, and breaking changes.
|
||||
1. Define the problem, desired outcome, constraints, and exclusions.
|
||||
2. Trace the current behavior through the affected modules.
|
||||
3. Map dependencies and choose an implementation order that builds foundations
|
||||
before their consumers.
|
||||
4. Identify open product or architecture decisions. Resolve implementation
|
||||
details from existing conventions when they do not affect public behavior.
|
||||
5. Identify edge cases, security and data risks, performance bounds, breaking
|
||||
changes, and external dependencies.
|
||||
6. Split the work into small, ordered tasks. Prefer complete testable slices
|
||||
over unrelated layer-wide batches. Apply DRY and KISS to the proposed
|
||||
implementation.
|
||||
7. Define exact acceptance criteria and verification for every task.
|
||||
8. Add a checkpoint after every two or three tasks in a longer plan.
|
||||
9. State which tasks can run in parallel and which must remain sequential.
|
||||
|
||||
### Phase 2: Task Breakdown
|
||||
|
||||
#### Identify the Dependency Graph
|
||||
|
||||
Map what depends on what, following the monorepo's module dependency graph:
|
||||
|
||||
```
|
||||
common (shared types, schemas — no deps)
|
||||
│
|
||||
├── backend (depends common)
|
||||
│ ├── RPC handlers
|
||||
│ └── persistence / migrations
|
||||
│
|
||||
├── frontend (depends common, render-wasm)
|
||||
│ ├── UI components
|
||||
│ └── state / API integration
|
||||
│
|
||||
├── exporter (depends common)
|
||||
│
|
||||
└── render-wasm (consumed by frontend)
|
||||
```
|
||||
|
||||
Implementation order follows the dependency graph bottom-up: build shared
|
||||
foundations first, then layer consumers on top.
|
||||
|
||||
#### Slice Vertically
|
||||
|
||||
Instead of building all of common, then all of backend, then all of frontend —
|
||||
build one complete feature path at a time:
|
||||
|
||||
**Bad (horizontal slicing):**
|
||||
```
|
||||
Task 1: Build all common types
|
||||
Task 2: Build all backend handlers
|
||||
Task 3: Build all frontend components
|
||||
```
|
||||
|
||||
**Good (vertical slicing):**
|
||||
```
|
||||
Task 1: common data types + schema ← foundation
|
||||
Task 2: backend RPC handler + persistence
|
||||
Task 3: frontend UI component + API integration
|
||||
```
|
||||
|
||||
Each vertical slice delivers working, testable functionality.
|
||||
|
||||
#### Write Tasks
|
||||
## Task Format
|
||||
|
||||
Each task follows this structure:
|
||||
|
||||
@@ -152,17 +102,16 @@ implementation. Omit when the task is mechanical.
|
||||
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
|
||||
```
|
||||
|
||||
Replace "module-specific test command" with the actual commands for the module
|
||||
(e.g. `clojure -M:dev:test` for backend/common,
|
||||
`npx shadow-cljs compile test && npx karma start` for frontend, or the
|
||||
commands noted in the module's core memory).
|
||||
Use commands from `mem:testing` and affected module memories. Never substitute
|
||||
generic text such as "run the tests" when the project documents an exact
|
||||
command.
|
||||
|
||||
When possible, design each task with TDD in mind: acceptance criteria double
|
||||
as a test list, and the natural first step of the task is writing those tests
|
||||
before the implementation. Some tasks resist this (config, migrations, pure
|
||||
wiring) — for those, keep the usual verification steps.
|
||||
When possible, design each task with TDD in mind: acceptance criteria double as a test
|
||||
list, and the natural first step of the task is writing those tests before the
|
||||
implementation. Some tasks resist this (config, migrations, pure wiring) — for those, keep
|
||||
the usual verification steps.
|
||||
|
||||
#### Estimate Scope
|
||||
## Task Sizing
|
||||
|
||||
| Size | Files | Scope | Example |
|
||||
|------|-------|-------|---------|
|
||||
@@ -172,16 +121,11 @@ wiring) — for those, keep the usual verification steps.
|
||||
| **L** | 5-8 | Multi-component feature | Search with filtering and pagination |
|
||||
| **XL** | 8+ | **Too large — break it down further** | — |
|
||||
|
||||
If a task is XL, it should be broken into smaller tasks. Agents perform best
|
||||
on S and M tasks.
|
||||
Split a task when it contains independent outcomes, spans unrelated systems, or cannot be
|
||||
completed and verified in one focused session (if a task is XL, it should be broken into
|
||||
smaller tasks; agents perform best on S and M tasks).
|
||||
|
||||
**When to break a task down further:**
|
||||
- It would take more than one focused session
|
||||
- You cannot describe the acceptance criteria in 3 or fewer bullet points
|
||||
- It touches two or more independent subsystems
|
||||
- You find yourself writing "and" in the task title (a sign it is two tasks)
|
||||
|
||||
#### Order and Checkpoints
|
||||
## Task order and checkpoints
|
||||
|
||||
Arrange tasks so that:
|
||||
|
||||
@@ -197,153 +141,43 @@ Add explicit checkpoints with the relevant module commands:
|
||||
- [ ] Relevant tests pass (module-specific command).
|
||||
- [ ] The relevant build or compilation passes, if applicable.
|
||||
- [ ] The core flow works end-to-end.
|
||||
- [ ] Review with human before proceeding.
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Analyze the codebase architecture and identify affected modules.
|
||||
- Read project conventions before drafting (start with `critical-info` and
|
||||
affected module core memories).
|
||||
- Break down complex features or bugs into atomic, actionable steps.
|
||||
- Propose solutions with clear rationale, trade-offs, and sequencing.
|
||||
- Identify risks, edge cases, performance implications, and breaking changes.
|
||||
- Apply DRY and KISS principles to the proposed implementation.
|
||||
- Define a testing strategy aligned with each affected module's tooling.
|
||||
- Every task must have acceptance criteria and verification steps.
|
||||
- Checkpoints must exist after every 2-3 tasks.
|
||||
|
||||
## Constraints
|
||||
|
||||
- You are **analysis-only** — never create, edit, or delete source code. The
|
||||
only file you may write is the plan itself, and only when the command or
|
||||
user explicitly instructs you to save it.
|
||||
- You do **not** run builds, tests, linters, or any commands that modify state.
|
||||
- You do **not** create git commits or interact with version control.
|
||||
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
|
||||
`find`, `cat`, `bat`).
|
||||
- Your output is a structured plan or analysis, ready for handoff to an
|
||||
engineer agent or developer.
|
||||
|
||||
## Output Format
|
||||
|
||||
The plan is always delivered in the response so the user sees it regardless
|
||||
of which agent is running the skill. By default you never write the plan file;
|
||||
announce the path instead. Write the file only when the command or user
|
||||
explicitly instructs you to save it — and then only that file.
|
||||
of which agent is running the skill. File writes follow `Constraints` —
|
||||
by default announce the path instead of writing.
|
||||
|
||||
Announce the suggested save path:
|
||||
|
||||
```
|
||||
.agents/plans/YYYY-MM-DD-<plan-one-line-title>.md
|
||||
```
|
||||
|
||||
Use today's date in the user's local timezone. The `<plan-one-line-title>`
|
||||
slug is lowercase, hyphen-separated, and a short summary of the task
|
||||
(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly
|
||||
provides a target file path, announce that path instead of the default.
|
||||
Announce the save path `.agents/plans/YYYY-MM-DD-<slug>.md` (today's date,
|
||||
lowercase hyphen-separated slug, e.g. `2026-09-10-add-batch-get-profiles`;
|
||||
an explicit user path wins).
|
||||
|
||||
End the response by suggesting the next steps: `/review-plan` to get a second
|
||||
opinion on the plan and `/implement-plan` to execute it.
|
||||
|
||||
### Plan Document Template
|
||||
### Plan Structure
|
||||
|
||||
Use this document shape:
|
||||
|
||||
```markdown
|
||||
# Plan: [Feature/Project Name]
|
||||
# Plan: Title
|
||||
|
||||
## Context
|
||||
[One paragraph: what is the problem or feature request? Why is it needed?]
|
||||
|
||||
## Affected Modules
|
||||
[Which modules of the monorepo are involved? Reference module paths and any
|
||||
`mem:` memories that were consulted.]
|
||||
|
||||
## Architecture Decisions
|
||||
- [Key decision 1 and rationale]
|
||||
- [Key decision 2 and rationale]
|
||||
|
||||
## Risks & Considerations
|
||||
[Edge cases, performance implications, breaking changes, migration concerns,
|
||||
security implications.]
|
||||
|
||||
## Risks and Considerations
|
||||
## Approach
|
||||
[A short strategy summary: 3-5 sentences describing the overall approach and
|
||||
the shape of the dependency graph (what depends on what, what gets built
|
||||
first). High-level only — the task-by-task detail lives in the Task List.]
|
||||
|
||||
## Task List
|
||||
|
||||
Each task uses the full task structure defined in
|
||||
[Write Tasks](#write-tasks) — description, rationale, acceptance criteria,
|
||||
verification, dependencies, files, estimated scope, and optional code sketch.
|
||||
Never reduce a task to a one-line checkbox; the plan must be self-contained
|
||||
and executable without other context.
|
||||
|
||||
Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks
|
||||
into phases, milestones, or sprints; ordering and dependencies are already
|
||||
captured per task. Insert a checkpoint after every 2-3 tasks.
|
||||
|
||||
## Task 1: [Short descriptive title]
|
||||
|
||||
**Description:** [What this task accomplishes.]
|
||||
|
||||
**Rationale:** [Why this approach over the alternatives.]
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] [Specific, testable condition]
|
||||
|
||||
**Verification:**
|
||||
- [ ] Relevant tests pass (module-specific command).
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files likely touched:**
|
||||
- `path/to/file`
|
||||
|
||||
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
|
||||
|
||||
**Code sketch (optional):** [Short contract-level example, only if the shape
|
||||
is non-obvious.]
|
||||
|
||||
## Task 2: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
## Task 3: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
### Checkpoint: After Tasks 1-3
|
||||
- [ ] Relevant tests pass (module-specific command).
|
||||
- [ ] The relevant build or compilation passes, if applicable.
|
||||
- [ ] The core flow works end-to-end.
|
||||
- [ ] Review with human before proceeding.
|
||||
|
||||
## Task 4: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
## Task 5: [Short descriptive title]
|
||||
|
||||
[Same structure as Task 1.]
|
||||
|
||||
## Verification & Testing
|
||||
[How to verify each task and the whole plan: the project's real test, lint,
|
||||
build, and run commands (extracted during Required Reading), coverage
|
||||
expectations, and manual checks. Consult each module's core memory for the
|
||||
exact commands.]
|
||||
|
||||
## Parallelization Opportunities
|
||||
- **Safe to parallelize:** Independent feature slices across separate
|
||||
modules, tests for already-implemented features, documentation
|
||||
- **Must be sequential:** Shared common schema changes, database migrations
|
||||
- **Needs coordination:** Features that share a contract (define the contract
|
||||
first, then parallelize)
|
||||
|
||||
## Verification and Testing
|
||||
## Parallelization
|
||||
## Open Questions
|
||||
- [Question needing human input]
|
||||
```
|
||||
|
||||
Omit empty sections only when they do not apply. Every implementation task
|
||||
still requires acceptance criteria, verification, dependencies, likely files,
|
||||
and scope.
|
||||
|
||||
When the plan is purely analytical (e.g. a code review or feasibility study
|
||||
with no implementation), skip the **Approach** and **Task List** sections and
|
||||
lead with **Findings** instead, keeping the rest of the structure.
|
||||
@@ -357,15 +191,6 @@ lead with **Findings** instead, keeping the rest of the structure.
|
||||
| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. |
|
||||
| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Delivering prose without a task breakdown
|
||||
- Tasks that say "implement the feature" without acceptance criteria
|
||||
- No verification steps in the plan
|
||||
- All tasks are XL-sized
|
||||
- No checkpoints between tasks
|
||||
- Dependency order isn't considered
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before delivering the plan, confirm:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: _ADHOC
|
||||
|
||||
run-name: >-
|
||||
_ADHOC (${{ inputs.gh_ref }}${{ inputs.nitrate_ref != '' && format(' / nitrate:{0}', inputs.nitrate_ref) || '' }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Branch/ref to build in penpot/penpot'
|
||||
type: string
|
||||
required: true
|
||||
nitrate_ref:
|
||||
description: 'Branch/ref to build admin-console in penpot/penpot-nitrate (defaults to gh_ref)'
|
||||
type: string
|
||||
required: false
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ inputs.gh_ref }}
|
||||
force: ${{ inputs.force }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ inputs.gh_ref }}
|
||||
force: ${{ inputs.force }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ inputs.nitrate_ref || inputs.gh_ref }}
|
||||
force: ${{ inputs.force }}
|
||||
@@ -9,6 +9,11 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this version already exists in S3'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -16,20 +21,31 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this version already exists in S3'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
|
||||
# caller's workflow, which put this workflow and the other reusable one called
|
||||
# by the same caller into a single shared group, and left a manual dispatch of
|
||||
# the same ref in a group of its own, free to race on the same artifacts.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||
group: build-bundle-${{ inputs.gh_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── 1. Decide whether there is anything to build ───────────────────────
|
||||
check:
|
||||
name: Check current bundle
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
sha: ${{ steps.vars.outputs.sha }}
|
||||
commit_title: ${{ steps.vars.outputs.commit_title }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
@@ -44,10 +60,12 @@ jobs:
|
||||
run: |
|
||||
echo "gh_ref=${{ inputs.gh_ref || github.ref_name }}" >> $GITHUB_OUTPUT
|
||||
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
|
||||
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The uploaded zip carries its version as S3 metadata. If the
|
||||
# existing object was already built from this same commit, the
|
||||
# whole build job is skipped.
|
||||
# whole build job is skipped. `force` bypasses this check entirely.
|
||||
- name: Check if this bundle is already built
|
||||
id: check
|
||||
env:
|
||||
@@ -55,6 +73,16 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
{
|
||||
echo "### 🔁 Bundle build forced"
|
||||
echo ""
|
||||
echo "\`force: true\` — skipping the S3 version check."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EXISTING_VERSION=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "penpot-${{ steps.vars.outputs.gh_ref }}.zip" \
|
||||
@@ -75,7 +103,7 @@ jobs:
|
||||
# ── 2. Build and upload, only when needed ──────────────────────────────
|
||||
build:
|
||||
name: Build and Upload Penpot Bundle
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 90
|
||||
needs: check
|
||||
if: needs.check.outputs.exists == 'false'
|
||||
@@ -113,10 +141,20 @@ jobs:
|
||||
s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.check.outputs.gh_ref }}.zip \
|
||||
--metadata bundle-version=${{ needs.check.outputs.bundle_version }}
|
||||
|
||||
- name: Write step summary
|
||||
run: |
|
||||
{
|
||||
echo "### ✅ Bundle built"
|
||||
echo ""
|
||||
echo "- Version: \`${{ needs.check.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
|
||||
echo "- Commit: [\`${{ needs.check.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.check.outputs.sha }}) — ${{ needs.check.outputs.commit_title }}"
|
||||
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ── 3. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 5
|
||||
needs: [check, build]
|
||||
if: failure()
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
name: _DEVELOP
|
||||
|
||||
run-name: >-
|
||||
_DEVELOP (develop @ ${{ github.sha }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
schedule:
|
||||
- cron: '16 5-20 * * 1-5'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -18,9 +32,11 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-admin-console-docker:
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "develop"
|
||||
force: ${{ inputs.force || false }}
|
||||
@@ -13,6 +13,11 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -24,6 +29,11 @@ on:
|
||||
type: string
|
||||
required: false
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
ORG_WORKFLOW_TOKEN:
|
||||
description: 'Token with Actions write access on penpot-nitrate'
|
||||
@@ -47,6 +57,7 @@ jobs:
|
||||
|
||||
gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$DISPATCH_REF" \
|
||||
-f gh_ref="$GH_REF" \
|
||||
-f force="${{ inputs.force }}" \
|
||||
-f caller_run_id="$DISTINCT_ID" \
|
||||
-f caller_run_url="$CALLER_URL"
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and push DevEnv Docker image
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-standar-runner
|
||||
|
||||
steps:
|
||||
- name: Set common environment variables
|
||||
|
||||
@@ -8,6 +8,11 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this sha is already promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
gh_ref:
|
||||
@@ -15,9 +20,18 @@ on:
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if this sha is already promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
|
||||
# caller's workflow, which put this workflow and the other reusable one called
|
||||
# by the same caller into a single shared group, and left a manual dispatch of
|
||||
# the same ref in a group of its own, free to race on the same artifacts.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.gh_ref }}
|
||||
group: build-docker-${{ inputs.gh_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -32,12 +46,13 @@ jobs:
|
||||
# ── 1. Resolve the build key and check the whole set at once ───────────
|
||||
prepare:
|
||||
name: Prepare
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
gh_ref: ${{ steps.vars.outputs.gh_ref }}
|
||||
bundle_version: ${{ steps.vars.outputs.bundle_version }}
|
||||
sha: ${{ steps.vars.outputs.sha }}
|
||||
commit_title: ${{ steps.vars.outputs.commit_title }}
|
||||
exists: ${{ steps.check.outputs.exists }}
|
||||
|
||||
steps:
|
||||
@@ -56,6 +71,7 @@ jobs:
|
||||
GH_REF="${{ inputs.gh_ref || github.ref_name }}"
|
||||
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
|
||||
echo "sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
BUNDLE_VERSION=$(aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
@@ -67,7 +83,8 @@ jobs:
|
||||
# The image set is a single block, so a single set-level check is
|
||||
# enough: `promote` drops a marker object in S3 only after every
|
||||
# image was built AND every branch tag was moved. Marker present
|
||||
# means there is nothing at all to do for this commit.
|
||||
# means there is nothing at all to do for this commit. `force`
|
||||
# bypasses this check entirely.
|
||||
- name: Check if this image set is already built
|
||||
id: check
|
||||
env:
|
||||
@@ -75,6 +92,21 @@ jobs:
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
|
||||
run: |
|
||||
if [ "${{ inputs.force }}" = "true" ]; then
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
mkdir -p "$BUNDLE_CACHE"
|
||||
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
|
||||
ZIP="$BUNDLE_CACHE/penpot-${{ steps.vars.outputs.bundle_version }}.zip"
|
||||
aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.zip" "$ZIP.$$.tmp"
|
||||
mv "$ZIP.$$.tmp" "$ZIP"
|
||||
{
|
||||
echo "### 🔁 Image set build forced"
|
||||
echo ""
|
||||
echo "\`force: true\` — skipping the S3 marker check."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if aws s3api head-object \
|
||||
--bucket ${{ secrets.S3_BUCKET }} \
|
||||
--key "markers/images-sha-${{ steps.vars.outputs.sha }}" \
|
||||
@@ -103,7 +135,7 @@ jobs:
|
||||
# ── 2. One build per image, in parallel, only when needed ──────────────
|
||||
build:
|
||||
name: Build ${{ matrix.image }}
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 60
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.exists == 'false'
|
||||
@@ -134,7 +166,7 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
# To avoid the “429 Too Many Requests” error when downloading
|
||||
# To avoid the "429 Too Many Requests" error when downloading
|
||||
# images from DockerHub for unregistered users.
|
||||
# https://docs.docker.com/docker-hub/usage/
|
||||
- name: Login to DockerHub Registry
|
||||
@@ -216,7 +248,7 @@ jobs:
|
||||
# the S3 marker guarantees the branch tags were already moved.
|
||||
promote:
|
||||
name: Promote image set
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 10
|
||||
needs: [prepare, build]
|
||||
|
||||
@@ -254,16 +286,23 @@ jobs:
|
||||
run: |
|
||||
echo "${{ github.run_id }}" | aws s3 cp - \
|
||||
"s3://${{ secrets.S3_BUCKET }}/markers/images-sha-${{ needs.prepare.outputs.sha }}"
|
||||
|
||||
- name: Write step summary
|
||||
run: |
|
||||
{
|
||||
echo "### ✅ Image set promoted"
|
||||
echo ""
|
||||
echo "- Version: \`${{ needs.prepare.outputs.bundle_version }}\` (\`git describe --tags --always\`)"
|
||||
echo "- Commit: [\`${{ needs.prepare.outputs.sha }}\`](https://github.com/${{ github.repository }}/commit/${{ needs.prepare.outputs.sha }}) — ${{ needs.prepare.outputs.commit_title }}"
|
||||
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo ""
|
||||
echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`sha-${{ needs.prepare.outputs.sha }}\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ── 4. Single failure notification for the whole workflow ─────────────
|
||||
notify:
|
||||
name: Notify failure
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-standar-runner
|
||||
timeout-minutes: 5
|
||||
needs: [prepare, build, promote]
|
||||
if: failure()
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
name: _STAGING
|
||||
|
||||
run-name: >-
|
||||
_STAGING (staging)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
schedule:
|
||||
- cron: '36 5-20 * * 1-5'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -18,9 +32,11 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-admin-console-docker:
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "staging"
|
||||
force: ${{ inputs.force || false }}
|
||||
@@ -1,17 +1,33 @@
|
||||
name: _TAG
|
||||
|
||||
run-name: >-
|
||||
_TAG (${{ github.ref_name }} @ ${{ github.sha }})
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Rebuild and overwrite even if already built/promoted (manual re-releases only)'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
# Keyed by ref and never cancelling: pushing 2.17.2 shortly after 2.17.2-RC1
|
||||
# must not abort the release already in flight.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
@@ -19,11 +35,21 @@ jobs:
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
build-docker-admin-console:
|
||||
uses: ./.github/workflows/build-docker-admin-console.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: ${{ github.ref_name }}
|
||||
force: ${{ inputs.force || false }}
|
||||
|
||||
notify:
|
||||
name: Notifications
|
||||
runs-on: ubuntu-24.04
|
||||
needs: build-docker
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-docker
|
||||
- build-docker-admin-console
|
||||
steps:
|
||||
- name: Notify Mattermost
|
||||
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
|
||||
@@ -37,7 +63,9 @@ jobs:
|
||||
|
||||
publish-final-tag:
|
||||
if: ${{ !contains(github.ref_name, '-RC') && !contains(github.ref_name, '-alpha') && !contains(github.ref_name, '-beta') && contains(github.ref_name, '.') }}
|
||||
needs: build-docker
|
||||
needs:
|
||||
- build-docker
|
||||
- build-docker-admin-console
|
||||
uses: ./.github/workflows/release.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: _TMP TOKENS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '46 5-20 * * 1-5'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-bundle:
|
||||
uses: ./.github/workflows/build-bundle.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "hiru-tokens-in-libs"
|
||||
|
||||
build-docker:
|
||||
needs: build-bundle
|
||||
uses: ./.github/workflows/build-docker.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
gh_ref: "hiru-tokens-in-libs"
|
||||
@@ -34,7 +34,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: penpot-runner-01
|
||||
runs-on: penpot-standar-runner
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -19,7 +19,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.vars.outputs.gh_ref }}
|
||||
release_notes: ${{ steps.extract_release_notes.outputs.release_notes }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
test-backend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Backend Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
test-common:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Common Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
composable-test-suite:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Run composable test suite (mocked backend)"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
test-exporter:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Exporter Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
test-frontend:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Frontend Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -5,11 +5,37 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
gh_ref:
|
||||
description: 'Name of the branch or ref'
|
||||
type: string
|
||||
required: true
|
||||
default: 'develop'
|
||||
|
||||
shards:
|
||||
description: 'Shard layout (JSON array)'
|
||||
type: choice
|
||||
required: true
|
||||
default: '[1, 2, 3, 4]'
|
||||
options:
|
||||
- '[1, 2, 3, 4]'
|
||||
- '[1, 2, 3, 4, 5, 6]'
|
||||
- '[1, 2]'
|
||||
- '[1]'
|
||||
|
||||
workers:
|
||||
description: 'Playwright workers per shard'
|
||||
type: string
|
||||
required: true
|
||||
default: '2'
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
- '.github/workflows/tests-integration.yml'
|
||||
|
||||
types:
|
||||
- opened
|
||||
@@ -25,25 +51,41 @@ on:
|
||||
- 'frontend/**'
|
||||
- 'common/**'
|
||||
- 'render-wasm/**'
|
||||
- '.github/workflows/tests-integration.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Build Integration Bundle"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
outputs:
|
||||
bundle_key: ${{ steps.vars.outputs.bundle_key }}
|
||||
|
||||
steps:
|
||||
# An empty `ref` makes checkout fall back to its default (the PR merge
|
||||
# ref on pull_request, the pushed ref on push).
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
# The cache key must come from the SHA actually checked out: on a manual
|
||||
# run `github.sha` points at the dispatching ref, not at `gh_ref`.
|
||||
- name: Extract cache key
|
||||
id: vars
|
||||
run: |
|
||||
echo "bundle_key=integration-bundle-$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Bundle
|
||||
working-directory: ./frontend
|
||||
@@ -53,58 +95,142 @@ jobs:
|
||||
- name: Store Bundle Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
key: ${{ steps.vars.outputs.bundle_key }}
|
||||
path: frontend/resources/public
|
||||
|
||||
test-integration:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Integration Tests"
|
||||
runs-on: penpot-runner-02
|
||||
name: "Integration Tests (${{ matrix.shard }})"
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 40
|
||||
|
||||
needs: build-integration
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4]') }}
|
||||
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
- /var/cache/github-runner/ms-playwright:/ms-playwright
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
key: ${{ needs.build-integration.outputs.bundle_key }}
|
||||
path: frontend/resources/public
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install --frozen-lockfile;
|
||||
|
||||
# No-op once the shared volume is warm; keeps the first run working.
|
||||
- name: Install Playwright Chromium
|
||||
working-directory: ./frontend
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
# `strategy.job-total` is the matrix size, so the shard denominator
|
||||
# follows the `shards` input without being hardcoded.
|
||||
- name: Run Tests
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
WORKERS: ${{ inputs.workers }}
|
||||
run: |
|
||||
WORKERS=${WORKERS:-2}
|
||||
echo "Running shard ${{ matrix.shard }}/${{ strategy.job-total }} with $WORKERS workers"
|
||||
pnpm exec playwright test --project default \
|
||||
--workers="$WORKERS" \
|
||||
--shard=${{ matrix.shard }}/${{ strategy.job-total }} \
|
||||
--reporter=blob
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-blob-report-${{ matrix.shard }}
|
||||
path: frontend/blob-report/
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
|
||||
- name: Upload test result
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result-${{ matrix.shard }}
|
||||
path: frontend/test-results/
|
||||
overwrite: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
merge-reports:
|
||||
if: ${{ always() && !github.event.pull_request.draft && needs.test-integration.result != 'skipped' }}
|
||||
name: "Merge Integration Reports"
|
||||
runs-on: penpot-extended-runner
|
||||
timeout-minutes: 15
|
||||
|
||||
needs: test-integration
|
||||
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
- /var/cache/github-runner/m2:/root/.m2
|
||||
- /var/cache/github-runner/gitlib:/root/.gitlibs
|
||||
|
||||
needs: build-integration
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Restore Cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
key: "integration-bundle-${{ github.sha }}"
|
||||
path: frontend/resources/public
|
||||
ref: ${{ inputs.gh_ref }}
|
||||
|
||||
- name: Run Tests
|
||||
- name: Install deps
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
corepack enable;
|
||||
corepack install;
|
||||
pnpm install --frozen-lockfile;
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: frontend/all-blob-reports
|
||||
pattern: integration-blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge into HTML report
|
||||
working-directory: ./frontend
|
||||
env:
|
||||
PLAYWRIGHT_REPORTER: list,json
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: report.json
|
||||
run: |
|
||||
./scripts/test-e2e
|
||||
pnpm exec playwright merge-reports \
|
||||
--reporter=html,json,list ./all-blob-reports
|
||||
|
||||
- name: Flaky summary
|
||||
- name: Test summary
|
||||
if: always()
|
||||
working-directory: ./frontend
|
||||
run: |
|
||||
if [ ! -f report.json ]; then
|
||||
echo "No report.json produced (the run failed early)." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "No report produced (all shards failed early)." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
jq -r -f ../.github/scripts/playwright-summary.jq report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
jq -r '
|
||||
[ .. | objects
|
||||
| select(has("tests") and has("file"))
|
||||
| select(any(.tests[]; .status == "flaky"))
|
||||
| "- `\(.file):\(.line)` — \(.title)"
|
||||
] as $f
|
||||
| "## Flaky tests: \($f | length)\n"
|
||||
+ (if ($f | length) == 0 then "_none_" else ($f | join("\n")) end)
|
||||
' report.json >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Kept for 30 days so flakiness rates can be aggregated across runs
|
||||
# without scraping job logs.
|
||||
- name: Upload JSON report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
@@ -112,13 +238,13 @@ jobs:
|
||||
name: integration-json-report
|
||||
path: frontend/report.json
|
||||
overwrite: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload test result
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: integration-tests-result
|
||||
path: frontend/test-results/
|
||||
name: integration-html-report
|
||||
path: frontend/playwright-report/
|
||||
overwrite: true
|
||||
retention-days: 3
|
||||
retention-days: 7
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
test-library:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Library Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
test-mcp:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Test MCP"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container: penpotapp/devenv:latest
|
||||
|
||||
steps:
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
api-test-suite-mocked:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' && !github.event.pull_request.draft }}
|
||||
name: "Run Plugin API Test Suite (mocked)"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
# api-test-suite-live:
|
||||
# if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
# name: Run Plugin API Test Suite (live)
|
||||
# runs-on: penpot-runner-02
|
||||
# runs-on: penpot-extended-runner
|
||||
# container:
|
||||
# image: penpotapp/devenv:latest
|
||||
#
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
test-plugins:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: Plugins Runtime Linter & Tests
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
test-render-wasm:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
name: "Render WASM Tests"
|
||||
runs-on: penpot-runner-02
|
||||
runs-on: penpot-extended-runner
|
||||
container:
|
||||
image: penpotapp/devenv:latest
|
||||
volumes:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- LDAP login validates credentials against the external directory, fetches identity data, then logs in or registers a matching Penpot profile. LDAP registration is not a separate Penpot signup flow.
|
||||
- Logout may return an OIDC provider redirect URI when the session claims include provider/session data and the provider has a logout URI.
|
||||
- Invitation tokens are verified through token issuers and only accepted when the token member id/email matches the authenticated profile; otherwise login proceeds without consuming the invitation.
|
||||
- HTTP/session parsing details such as cookie/header precedence, JWT session token versions, and SameSite behavior are in `mem:backend/http-storage-filedata-subtleties`.
|
||||
- HTTP/session parsing details such as cookie/header precedence and SameSite behavior are in `mem:backend/http-storage-filedata-subtleties`.
|
||||
|
||||
## Permission model
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
|
||||
- RPC, DB helpers, workers, cron: `mem:backend/rpc-db-worker-subtleties`
|
||||
- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`.
|
||||
- HTTP sessions, config, media processing, and file data persistence: `mem:backend/http-storage-filedata-subtleties`.
|
||||
- Session lifetime config, token `:exp`, and idle/absolute GC: `mem:backend/session-expiration`.
|
||||
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
|
||||
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
- The backend sets Clojure `*assert*` globally from the `:backend-asserts` feature flag. Assertion-dependent checks can therefore differ by runtime flags.
|
||||
- Request body parsing is mostly POST-oriented and supports Transit JSON plus plain JSON. Plain JSON request keys are kebab-decoded before being merged into `:params`.
|
||||
- Response formatting negotiates with `Accept` or `_fmt=json`. Transit is the default for collection/boolean bodies; JSON encoding has special pointer-map handling.
|
||||
- Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`; JWTs with `kid=1` and `ver=1` are decoded as v1 session tokens, otherwise they are treated as legacy tokens.
|
||||
- Auth prefers the session cookie token before the `Authorization` header. Headers may be `Token` or `Bearer`. Only `kid=1`/`ver=1` tokens are decoded as session tokens; anything else is left unauthenticated (legacy v1 tokens were removed).
|
||||
- Shared-key auth requires `x-shared-key` as `<key-id> <key>` and stores the lowercased key id on the request. If no shared keys are configured it always rejects.
|
||||
- Session management uses DB storage unless the DB pool is read-only, then falls back to the in-memory manager. DB sessions support both legacy string ids and v2 UUID session ids.
|
||||
- Session cookies are renewed when using a legacy string id or when `modified-at` is older than the renewal interval. SameSite is `none` for CORS, otherwise strict/lax based on config.
|
||||
- Session management uses DB storage unless the DB pool is read-only, then falls back to the in-memory manager. Sessions use only the v2 UUID model (`http_session_v2`); legacy string ids were removed.
|
||||
- Session cookies are renewed when `modified-at` is older than the 6h renewal interval. SameSite is `none` for CORS, otherwise strict/lax based on config. Session lifetime config and GC: `mem:backend/session-expiration`.
|
||||
|
||||
## Storage and media
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Backend Session Expiration
|
||||
|
||||
## Config
|
||||
|
||||
- `:auth-token-cookie-name` / `PENPOT_AUTH_TOKEN_COOKIE_NAME` (default `auth-token`): cookie name.
|
||||
- `:auth-token-cookie-max-age` / `PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE` (default 7d): idle window; drives the sliding cookie `Expires` and the GC idle threshold.
|
||||
- `:auth-token-cookie-max-age-absolute` / `PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE_ABSOLUTE` (default 30d): hard cap from `created-at`; drives the token `:exp` and the GC absolute threshold.
|
||||
- Durations decode as `<n><unit>` with hour/minute/second units (`168h`, `30m`); day units like `7d` are rejected by the `Duration/parse` string path, use hours (`168h`, `720h`).
|
||||
- Session lifetime defaults live only as code constants in `session.clj` (`default-cookie-max-age`, `default-cookie-max-age-absolute`); do not duplicate them in the `config.clj` default map. Call sites always pass the constant as the `cf/get` fallback.
|
||||
|
||||
## Token and session model
|
||||
|
||||
- Sessions live only in `http_session_v2`. Legacy v1 support (`http_session`, string ids, `:ver 0` tokens) was removed and the table dropped (`0153-drop-http-session-table`).
|
||||
- `assign-token` emits header `{:kid 1 :ver 1}` with claims `:sid`, `:iat` (= `modified-at`) and `:exp` = `created-at + absolute-max-age` (omitted only when `created-at` is nil, which neither manager produces today; the branch is defensive).
|
||||
- `:exp` is anchored to `created-at`, never `modified-at`, so renewal cannot extend the absolute maximum.
|
||||
- Tokens issued before `:exp` existed carry no `:exp`; they are still bounded by the GC's `created_at` condition and acquire `:exp` on their next renewal (self-healing, no operator action).
|
||||
- `wrap-authz` resolves the session by `(:sid claims)` only; there is no fallback to reading a session by the raw token string.
|
||||
- `middleware/wrap-auth` attaches `::http/auth-data` only for `kid=1`/`ver=1` tokens with a configured decoder; anything else stays unauthenticated.
|
||||
- Renewal fires when `modified-at` is older than 6h (`default-renewal-max-age`, not configurable). It `UPDATE`s the same row (`modified-at` only) and issues a new token that keeps the original `:exp`.
|
||||
- `read-session` does not check age. Idle expiration is enforced only by the GC, so a copied token stays valid until the next GC run deletes its row (up to ~24h of grace).
|
||||
|
||||
## GC (`::tasks/gc`, cron `session-gc`, daily)
|
||||
|
||||
- Deletes from `http_session_v2` where `modified_at < now - :auth-token-cookie-max-age` **or** `created_at < now - :auth-token-cookie-max-age-absolute`.
|
||||
- The two thresholds are separate task params (`::tasks/max-age`, `::tasks/max-age-absolute`) built in `ig/expand-key`; both must be durations.
|
||||
- Do not collapse the two conditions: `modified_at` alone never collects active sessions; `created_at` alone logs out active users at the idle window.
|
||||
|
||||
## Related lifetimes
|
||||
|
||||
- Organization SSO entries in session `:props` (`:sso {org-id exp}`) last 4h, hardcoded in `app.auth.oidc`.
|
||||
- Access tokens (`app.rpc.commands.access-token`) have a user-chosen `:expires-at` (Never/30/60/90/180d), independent from HTTP sessions.
|
||||
- Cookie `SameSite`/`secure` depend on `:cors`, `:strict-session-cookies`, `:secure-session-cookies`; `parse-flags` auto-adds `:disable-secure-session-cookies` for non-localhost HTTP `public-uri`.
|
||||
- Without `PENPOT_SECRET_KEY`, derived subsystem keys change on restart and all sessions/invitations are invalidated.
|
||||
- Read-only DB pool: sessions fall back to `inmemory-manager`, lost on restart, no GC.
|
||||
- HTTP middleware and cookie/header precedence details: `mem:backend/http-storage-filedata-subtleties`.
|
||||
@@ -11,7 +11,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo.
|
||||
# Development workflow
|
||||
|
||||
- Commit/PR/issue creation is **on explicit request only**. Before any of these actions, read the relevant memory — don't infer format from prior examples:
|
||||
- Before `git commit` → `mem:workflow/creating-commits` (subject format, body, `AI-assisted-by: model-name` trailer)
|
||||
- Before `git commit` → `mem:workflow/creating-commits` (subject/body format, 76-char body wrapping enforced by `scripts/check-commit`, `AI-assisted-by: model-name` trailer)
|
||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type)
|
||||
- Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line)
|
||||
- Before a repo-wide pnpm version update → `mem:workflow/updating-pnpm` (workspace
|
||||
|
||||
@@ -14,12 +14,32 @@ automatically pull the identity from the local git config `user.name` and `user.
|
||||
:emoji: Subject line (imperative, capitalized, no period, <=70 chars)
|
||||
|
||||
Body explaining what changed and why.
|
||||
Wrap lines at 72 characters — git log and tooling
|
||||
render long lines poorly. Keep each line concise.
|
||||
Wrap lines at 76 characters — git log adds a
|
||||
four-space indent, so 76 + 4 fits an 80-column
|
||||
terminal. Keep each line concise.
|
||||
|
||||
AI-assisted-by: model-name
|
||||
```
|
||||
|
||||
## HARD RULES (inexcusable)
|
||||
|
||||
These rules are not advisory. Do not commit until every one holds. A commit
|
||||
that breaks them is wrong, even if the code is right.
|
||||
|
||||
- **Body lines MUST wrap at 76 characters or fewer.** Measure every line; do
|
||||
not eyeball it. This is the rule most often skipped. Rationale: `git log`
|
||||
indents the body four spaces, so 76 + 4 fits an 80-column terminal.
|
||||
- **Subject MUST be ≤70 chars**, imperative, capitalized, no trailing period.
|
||||
- **MUST be a blank line** between subject and body.
|
||||
- **MUST run `scripts/check-commit` and get exit code 0 before finishing.**
|
||||
It mechanically validates the rules above; a failing run is a blocker.
|
||||
- It checks `HEAD` by default: `./scripts/check-commit`
|
||||
- For another commit: `./scripts/check-commit -c <ref>`
|
||||
- **NEVER** hand-wave the body as "one long line". If a line exceeds 76,
|
||||
break it at a space.
|
||||
- Exceptions inside the body (do not wrap these): `Signed-off-by:`,
|
||||
`Co-authored-by:`, `AI-assisted-by:` trailers, and lines carrying a URL.
|
||||
|
||||
**AI-assisted-by trailer rules:**
|
||||
- Use only the model name, e.g. `mimo-v2.5`, `deepseek-v4-flash`
|
||||
- Do NOT add prefixes like `opencode-go/` — use the bare model name
|
||||
|
||||
@@ -52,10 +52,12 @@ file (never pipe tool output through filters).
|
||||
then re-run `corepack use pnpm@<tag>` in that directory.
|
||||
- A workspace may fail with `ERR_PNPM_IGNORED_BUILDS`, and pnpm then writes
|
||||
a placeholder scaffold into its `pnpm-workspace.yaml`:
|
||||
`allowBuilds: esbuild: set this to true or false` plus
|
||||
`ignoredBuiltDependencies`. Repo convention is `allowBuilds: esbuild: true`.
|
||||
Replace the placeholder and drop the `ignoredBuiltDependencies` entry,
|
||||
then re-run.
|
||||
`allowBuilds: esbuild: set this to true or false`. Current pnpm writes
|
||||
only the `allowBuilds` placeholder; any legacy key still present
|
||||
(`ignoredBuiltDependencies`, `onlyBuiltDependencies`,
|
||||
`neverBuiltDependencies`) is ignored since pnpm 11. Repo convention is
|
||||
`allowBuilds: esbuild: true`. Replace the placeholder and drop the
|
||||
legacy entry, then re-run.
|
||||
- `plugins/apps/composable-test-suite` once had its own
|
||||
`pnpm-workspace.yaml` and acted as a nested workspace root. That state is
|
||||
gone on purpose: pnpm picks the nearest `pnpm-workspace.yaml` walking up,
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
- **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.).
|
||||
Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file.
|
||||
This prevents hiding test failures. See `mem:testing` for details.
|
||||
- **`.claude/skills` is a symlink to `.agents/skills`.**
|
||||
Edit skills only in their canonical location (`.agents/skills`); never edit
|
||||
through `.claude/skills`.
|
||||
- **Commit message body lines MUST wrap at ≤76 chars** (subject ≤70 chars) and
|
||||
the commit MUST pass `./scripts/check-commit` with exit code 0 before you
|
||||
consider it done. This is mechanically checked — do not eyeball it.
|
||||
- **Read the workflow memory BEFORE the corresponding action**:
|
||||
- Before `git commit` → `mem:workflow/creating-commits` (commit format, AI-assisted-by trailer)
|
||||
- Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, Issue Type)
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## 2.18.0 (Unreleased)
|
||||
|
||||
### :rocket: Epics and highlights
|
||||
|
||||
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
|
||||
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
|
||||
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
|
||||
|
||||
### :bug: Bugs fixed
|
||||
|
||||
- Fix MCP integration hanging when the Penpot tab is backgrounded or frozen by the browser [#10323](https://github.com/penpot/penpot/issues/10323) (PR: [#10392](https://github.com/penpot/penpot/pull/10392))
|
||||
@@ -103,6 +109,21 @@
|
||||
- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290))
|
||||
- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317))
|
||||
- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359))
|
||||
- Fix missing text in legacy SVG board thumbnails [#10182](https://github.com/penpot/penpot/issues/10182) (PR: [#11552](https://github.com/penpot/penpot/pull/11552))
|
||||
- Fix workspace crash when applying transform modifiers in the WASM renderer [#10894](https://github.com/penpot/penpot/issues/10894) (PR: [#10896](https://github.com/penpot/penpot/pull/10896))
|
||||
- Limit ZIP entry count and object size on V3 binfile import [#11021](https://github.com/penpot/penpot/issues/11021) (PR: [#11022](https://github.com/penpot/penpot/pull/11022))
|
||||
- Block plugin UI iframe URLs targeting the Penpot domain [#11271](https://github.com/penpot/penpot/issues/11271) (PR: [#11273](https://github.com/penpot/penpot/pull/11273))
|
||||
- Restrict the MCP REPL code execution endpoint to development environments [#11283](https://github.com/penpot/penpot/issues/11283) (PR: [#11282](https://github.com/penpot/penpot/pull/11282))
|
||||
- Filter share-link tokens from the get-view-only-bundle response [#11285](https://github.com/penpot/penpot/issues/11285) (PR: [#11286](https://github.com/penpot/penpot/pull/11286))
|
||||
- Disable MCP developer tools in multi-user mode [#11291](https://github.com/penpot/penpot/issues/11291) (PR: [#11310](https://github.com/penpot/penpot/pull/11310))
|
||||
- Fix Hide comments setting being ignored after opening the Comments section [#11308](https://github.com/penpot/penpot/issues/11308) (PR: [#11492](https://github.com/penpot/penpot/pull/11492))
|
||||
- Block NAT64/6to4/Teredo IPv6 transition addresses in the SSRF guard [#11319](https://github.com/penpot/penpot/issues/11319) (PR: [#11320](https://github.com/penpot/penpot/pull/11320))
|
||||
- Prevent team admins from removing the team owner [#11367](https://github.com/penpot/penpot/issues/11367) (PR: [#11368](https://github.com/penpot/penpot/pull/11368))
|
||||
- Enforce share-link comment permissions and page scope [#11370](https://github.com/penpot/penpot/issues/11370) (PR: [#11371](https://github.com/penpot/penpot/pull/11371))
|
||||
- Clean up orphaned teams, projects and files on profile deletion [#11394](https://github.com/penpot/penpot/issues/11394) (PR: [#11395](https://github.com/penpot/penpot/pull/11395))
|
||||
- Fix crash when pressing Ctrl+D with no shape selected [#11448](https://github.com/penpot/penpot/issues/11448) (PR: [#11491](https://github.com/penpot/penpot/pull/11491))
|
||||
- Fix text layout not updating when auto-width is set by double-clicking the bounding box [#11480](https://github.com/penpot/penpot/issues/11480) (PR: [#11541](https://github.com/penpot/penpot/pull/11541))
|
||||
- Fix boolean shapes rendering deformed in the WASM renderer and exports [#11482](https://github.com/penpot/penpot/issues/11482) (PR: [#11551](https://github.com/penpot/penpot/pull/11551))
|
||||
|
||||
### :sparkles: New features & Enhancements
|
||||
|
||||
|
||||
@@ -188,8 +188,11 @@ Commit messages must follow this format:
|
||||
- Add clear and concise description on the body
|
||||
- Do not end the subject with a period
|
||||
- Keep the subject to **70 characters** or fewer
|
||||
- **Wrap body lines at 76 characters or fewer** (trailers and URLs excepted)
|
||||
- Separate the subject from the body with a **blank line**
|
||||
|
||||
You can check a commit against these rules with `./scripts/check-commit`.
|
||||
|
||||
### Examples
|
||||
|
||||
```
|
||||
|
||||
@@ -56,6 +56,7 @@ If your organization is scaling and needs extra support, we’re here to help. [
|
||||
|
||||
- [Why Penpot](#why-penpot)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Penpot Enterprise](#penpot-enterprise)
|
||||
- [Community](#community)
|
||||
- [Contributing](#contributing)
|
||||
- [Resources](#resources)
|
||||
@@ -93,6 +94,12 @@ Penpot is the only design & prototype platform that is deployment agnostic. You
|
||||
|
||||
Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host).
|
||||
|
||||
<img width="100%" height="1010" alt="2" src="https://github.com/user-attachments/assets/243e796e-a140-481a-b68f-b24be6a70e37" />
|
||||
|
||||
## Penpot Enterprise ##
|
||||
|
||||
Penpot Enterprise is our paid plan for organizations that need to scale their design work across multiple teams with advanced governance, security, and administration. Manage teams and access from a centralized **Admin Console**, configure advanced permissions, and connect your **identity provider through SSO**. Available for cloud and self-hosted environments, it combines enterprise controls with Penpot’s open-source foundation and open standards.
|
||||
|
||||
## Community ##
|
||||
|
||||
We love the Open Source software community. Contributing is our passion and if it’s yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome!
|
||||
|
||||
@@ -27,7 +27,7 @@ export PENPOT_MEDIA_PROCESSING_SERVICE_URI=http://localhost:6065
|
||||
export PENPOT_FLAGS="\
|
||||
$PENPOT_FLAGS \
|
||||
enable-login-with-password \
|
||||
disable-login-with-ldap \
|
||||
enable-login-with-ldap \
|
||||
disable-login-with-oidc \
|
||||
disable-login-with-google \
|
||||
disable-login-with-github \
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
[app.common.logging :as l]
|
||||
[app.common.schema :as sm]
|
||||
[clj-ldap.client :as ldap]
|
||||
[clojure.string]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]))
|
||||
|
||||
(defn- prepare-params
|
||||
@@ -36,11 +36,22 @@
|
||||
:cause cause))))
|
||||
|
||||
(defn- replace-several [s & {:as replacements}]
|
||||
(reduce-kv clojure.string/replace s replacements))
|
||||
(reduce-kv str/replace s replacements))
|
||||
|
||||
(defn- escape-ldap-filter-value
|
||||
"Escapes special characters in a string for use in LDAP filter values,
|
||||
per RFC 4515 section 3."
|
||||
[s]
|
||||
(-> s
|
||||
(str/replace "\\" "\\5c")
|
||||
(str/replace "*" "\\2a")
|
||||
(str/replace "(" "\\28")
|
||||
(str/replace ")" "\\29")
|
||||
(str/replace "\u0000" "\\00")))
|
||||
|
||||
(defn- search-user
|
||||
[{:keys [::conn base-dn] :as cfg} email]
|
||||
(let [query (replace-several (:query cfg) ":username" email)
|
||||
(let [query (replace-several (:query cfg) ":username" (escape-ldap-filter-value email))
|
||||
attrs [(:attrs-username cfg)
|
||||
(:attrs-email cfg)
|
||||
(:attrs-fullname cfg)]
|
||||
@@ -49,12 +60,19 @@
|
||||
:attributes attrs}]
|
||||
(first (ldap/search conn base-dn params))))
|
||||
|
||||
(defn- get-attr
|
||||
"Retrieves an attribute from an LDAP entry. Handles multi-valued
|
||||
attributes by returning the first value."
|
||||
[entry attr-key]
|
||||
(let [v (get entry attr-key)]
|
||||
(if (coll? v) (first v) v)))
|
||||
|
||||
(defn- retrieve-user
|
||||
[{:keys [::conn] :as cfg} {:keys [email password]}]
|
||||
(when-let [{:keys [dn] :as user} (search-user cfg email)]
|
||||
(when (ldap/bind? conn dn password)
|
||||
{:fullname (get user (-> cfg :attrs-fullname keyword))
|
||||
:email email
|
||||
{:fullname (get-attr user (-> cfg :attrs-fullname keyword))
|
||||
:email (get-attr user (-> cfg :attrs-email keyword))
|
||||
:backend "ldap"})))
|
||||
|
||||
(def ^:private schema:info-data
|
||||
@@ -79,7 +97,7 @@
|
||||
(l/warn :hint "invalid response from ldap, looks like ldap is not configured correctly" :data user)
|
||||
(ex/raise :type :restriction
|
||||
:code :wrong-ldap-response
|
||||
:explain explain)))
|
||||
::sm/explain explain)))
|
||||
user)))
|
||||
|
||||
(defn- try-connectivity
|
||||
|
||||
@@ -206,6 +206,7 @@
|
||||
|
||||
[:auth-token-cookie-name {:optional true} :string]
|
||||
[:auth-token-cookie-max-age {:optional true} ::ct/duration]
|
||||
[:auth-token-cookie-max-age-absolute {:optional true} ::ct/duration]
|
||||
|
||||
[:registration-domain-whitelist {:optional true} [::sm/set :string]]
|
||||
[:email-verify-threshold {:optional true} ::ct/duration]
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
(:require
|
||||
[app.common.schema :as sm]
|
||||
[app.util.ssrf :as ssrf]
|
||||
[app.worker :as-alias wrk]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[java-http-clj.core :as http])
|
||||
@@ -23,6 +24,8 @@
|
||||
java.net.URI))
|
||||
|
||||
(def default-max-redirects 5)
|
||||
(def default-connect-timeout 30000)
|
||||
(def default-request-timeout 30000)
|
||||
|
||||
(defn client?
|
||||
[o]
|
||||
@@ -33,15 +36,17 @@
|
||||
:pred client?})
|
||||
|
||||
(defmethod ig/init-key ::client
|
||||
[_ _]
|
||||
(http/build-client {:connect-timeout 30000
|
||||
[_ {:keys [::wrk/executor]}]
|
||||
(http/build-client {:connect-timeout default-connect-timeout
|
||||
:executor executor
|
||||
:follow-redirects :never}))
|
||||
|
||||
(defn send!
|
||||
([client req] (send! client req {}))
|
||||
([client req {:keys [response-type] :or {response-type :string}}]
|
||||
(assert (client? client) "expected valid http client")
|
||||
(http/send req {:client client :as response-type})))
|
||||
(http/send (merge {:timeout default-request-timeout} req)
|
||||
{:client client :as response-type})))
|
||||
|
||||
(defn- resolve-client
|
||||
[params]
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
(contains? params :block)
|
||||
(do
|
||||
(db/update! conn :profile {:is-blocked true} {:id (:id profile)})
|
||||
(db/delete! conn :http-session {:profile-id (:id profile)})
|
||||
(session/invalidate-all cfg (:id profile))
|
||||
|
||||
{::yres/status 200
|
||||
::yres/headers {"content-type" "text/plain"}
|
||||
|
||||
@@ -60,7 +60,13 @@
|
||||
|
||||
(defmethod handle-error :restriction
|
||||
[err request _]
|
||||
(let [{:keys [code] :as data} (ex-data err)]
|
||||
(let [data (ex-data err)
|
||||
code (get data :code)
|
||||
explain (ex/explain data)
|
||||
data (-> data
|
||||
(dissoc ::sm/explain)
|
||||
(cond-> explain (assoc :explain explain)))]
|
||||
|
||||
(if (= code :method-not-allowed)
|
||||
{::yres/status 405
|
||||
::yres/body data}
|
||||
|
||||
@@ -306,16 +306,15 @@
|
||||
(let [decode-fn (get decoders type)]
|
||||
(if (or (= type :cookie) (= type :bearer))
|
||||
(let [metadata (tokens/decode-header token)]
|
||||
;; NOTE: we only proceed to decode claims on new
|
||||
;; cookie tokens. The old cookies dont need to be
|
||||
;; decoded because they use the token string as ID
|
||||
;; NOTE: only current (kid=1/ver=1) cookie tokens carry
|
||||
;; decodable claims. Anything else is left unauthenticated.
|
||||
(if (and (= (:kid metadata) 1)
|
||||
(= (:ver metadata) 1)
|
||||
(some? decode-fn))
|
||||
(assoc request ::http/auth-data (assoc auth
|
||||
:claims (decode-fn token)
|
||||
:metadata metadata))
|
||||
(assoc request ::http/auth-data (assoc auth :metadata {:ver 0}))))
|
||||
request))
|
||||
|
||||
(if decode-fn
|
||||
(assoc request ::http/auth-data (assoc auth :claims (decode-fn token)))
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
;; Default age for automatic session renewal
|
||||
(def default-renewal-max-age (ct/duration {:hours 6}))
|
||||
|
||||
;; Default absolute maximum session duration
|
||||
(def default-cookie-max-age-absolute (ct/duration {:days 30}))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PROTOCOLS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -78,14 +81,8 @@
|
||||
[pool]
|
||||
(reify ISessionManager
|
||||
(read-session [_ id]
|
||||
(if (string? id)
|
||||
;; Backward compatibility: http_session (v1) has no props column
|
||||
(let [session (db/exec-one! pool (sql/select :http-session {:id id}))]
|
||||
(-> session
|
||||
(assoc :modified-at (:updated-at session))
|
||||
(dissoc :updated-at)))
|
||||
(some-> (db/exec-one! pool (sql/select :http-session-v2 {:id id}))
|
||||
(decode-session))))
|
||||
(some-> (db/exec-one! pool (sql/select :http-session-v2 {:id id}))
|
||||
(decode-session)))
|
||||
|
||||
(create-session [_ params]
|
||||
(assert (valid-params? params) "expect valid session params")
|
||||
@@ -100,23 +97,15 @@
|
||||
|
||||
(update-session [_ session]
|
||||
(let [modified-at (ct/now)]
|
||||
(if (string? (:id session))
|
||||
(db/insert! pool :http-session-v2
|
||||
(-> session
|
||||
(assoc :id (uuid/next))
|
||||
(assoc :created-at modified-at)
|
||||
(assoc :modified-at modified-at)))
|
||||
(db/update! pool :http-session-v2
|
||||
(cond-> {:modified-at modified-at}
|
||||
(some? (:props session))
|
||||
(assoc :props (db/tjson (:props session))))
|
||||
{:id (:id session)}
|
||||
{::db/return-keys true}))))
|
||||
(db/update! pool :http-session-v2
|
||||
(cond-> {:modified-at modified-at}
|
||||
(some? (:props session))
|
||||
(assoc :props (db/tjson (:props session))))
|
||||
{:id (:id session)}
|
||||
{::db/return-keys true})))
|
||||
|
||||
(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}))
|
||||
(db/delete! pool :http-session-v2 {:id id} {::db/return-keys false})
|
||||
nil)))
|
||||
|
||||
(defn inmemory-manager
|
||||
@@ -169,15 +158,19 @@
|
||||
|
||||
(defn- assign-token
|
||||
[cfg session]
|
||||
(let [claims {: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)}
|
||||
header {:kid 1 :ver 1}
|
||||
token (tokens/generate cfg claims header)]
|
||||
(let [absolute-max-age (cf/get :auth-token-cookie-max-age-absolute default-cookie-max-age-absolute)
|
||||
claims {: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)}
|
||||
claims (if (:created-at session)
|
||||
(assoc claims :exp (ct/plus (:created-at session) absolute-max-age))
|
||||
claims)
|
||||
header {:kid 1 :ver 1}
|
||||
token (tokens/generate cfg claims header)]
|
||||
(assoc session :token token)))
|
||||
|
||||
(defn create-fn
|
||||
@@ -249,25 +242,19 @@
|
||||
(db/exec! pool [sql:clear-organization-sso-sessions organization-key organization-key])))
|
||||
|
||||
(defn- renew-session?
|
||||
[{: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))))))
|
||||
[{:keys [modified-at]}]
|
||||
(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] :as cfg}]
|
||||
(assert (manager? manager) "expected valid session manager")
|
||||
(fn [request]
|
||||
(let [{:keys [type token claims metadata]} (get request ::http/auth-data)]
|
||||
(let [{:keys [type claims]} (get request ::http/auth-data)]
|
||||
(cond
|
||||
(= type :cookie)
|
||||
(let [session
|
||||
(case (:ver metadata)
|
||||
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
|
||||
0 (read-session manager token)
|
||||
1 (some->> (:sid claims) (read-session manager))
|
||||
nil)
|
||||
(let [session (some->> (:sid claims) (read-session manager))
|
||||
|
||||
request
|
||||
(cond-> request
|
||||
@@ -287,11 +274,7 @@
|
||||
response))
|
||||
|
||||
(= type :bearer)
|
||||
(let [session (case (:ver metadata)
|
||||
;; BACKWARD COMPATIBILITY WITH OLD TOKENS
|
||||
0 (read-session manager token)
|
||||
1 (some->> (:sid claims) (read-session manager))
|
||||
nil)
|
||||
(let [session (some->> (:sid claims) (read-session manager))
|
||||
request (cond-> request
|
||||
(some? session)
|
||||
(-> (assoc ::profile-id (:profile-id session))
|
||||
@@ -310,9 +293,9 @@
|
||||
(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 modified-at
|
||||
renewal (ct/plus created-at default-renewal-max-age)
|
||||
expires (ct/plus created-at max-age)
|
||||
renewal-at modified-at
|
||||
renewal (ct/plus renewal-at default-renewal-max-age)
|
||||
expires (ct/plus renewal-at max-age)
|
||||
secure? (contains? cf/flags :secure-session-cookies)
|
||||
strict? (contains? cf/flags :strict-session-cookies)
|
||||
cors? (contains? cf/flags :cors)
|
||||
@@ -339,32 +322,40 @@
|
||||
(defmethod ig/assert-key ::tasks/gc
|
||||
[_ params]
|
||||
(assert (db/pool? (::db/pool params)) "expected valid database pool")
|
||||
(assert (ct/duration? (::tasks/max-age params))))
|
||||
(assert (ct/duration? (::tasks/max-age params)))
|
||||
(assert (ct/duration? (::tasks/max-age-absolute params))))
|
||||
|
||||
(defmethod ig/expand-key ::tasks/gc
|
||||
[k v]
|
||||
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)]
|
||||
{k (merge {::tasks/max-age max-age} (d/without-nils v))}))
|
||||
(let [max-age (cf/get :auth-token-cookie-max-age default-cookie-max-age)
|
||||
max-age-absolute (cf/get :auth-token-cookie-max-age-absolute
|
||||
default-cookie-max-age-absolute)]
|
||||
{k (merge {::tasks/max-age max-age
|
||||
::tasks/max-age-absolute max-age-absolute}
|
||||
(d/without-nils v))}))
|
||||
|
||||
(def ^:private
|
||||
sql:delete-expired
|
||||
"DELETE FROM http_session
|
||||
WHERE updated_at < ?::timestamptz
|
||||
or (updated_at is null and
|
||||
created_at < ?::timestamptz)")
|
||||
sql:delete-expired-v2
|
||||
"DELETE FROM http_session_v2
|
||||
WHERE modified_at < ?::timestamptz
|
||||
OR created_at < ?::timestamptz")
|
||||
|
||||
(defn- collect-expired-tasks
|
||||
[{:keys [::db/conn ::tasks/max-age]}]
|
||||
(let [threshold (ct/minus (ct/now) max-age)
|
||||
result (-> (db/exec-one! conn [sql:delete-expired threshold threshold])
|
||||
(db/get-update-count))]
|
||||
[{:keys [::db/conn ::tasks/max-age ::tasks/max-age-absolute]}]
|
||||
(let [idle-threshold (ct/minus (ct/now) max-age)
|
||||
abs-threshold (ct/minus (ct/now) max-age-absolute)
|
||||
result (-> (db/exec-one! conn [sql:delete-expired-v2
|
||||
idle-threshold abs-threshold])
|
||||
(db/get-update-count))]
|
||||
(l/dbg :task "gc"
|
||||
:hint "clean http sessions"
|
||||
:deleted result)
|
||||
result))
|
||||
|
||||
(defmethod ig/init-key ::tasks/gc
|
||||
[_ {:keys [::tasks/max-age] :as cfg}]
|
||||
(l/dbg :hint "initializing session gc task" :max-age max-age)
|
||||
[_ {:keys [::tasks/max-age ::tasks/max-age-absolute] :as cfg}]
|
||||
(l/dbg :hint "initializing session gc task"
|
||||
:max-age max-age
|
||||
:max-age-absolute max-age-absolute)
|
||||
(fn [_]
|
||||
(db/tx-run! cfg collect-expired-tasks)))
|
||||
@@ -200,7 +200,7 @@
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
::http.client/client
|
||||
{}
|
||||
{::wrk/executor (ig/ref ::wrk/executor)}
|
||||
|
||||
::session/manager
|
||||
{::db/pool (ig/ref ::db/pool)}
|
||||
|
||||
@@ -75,10 +75,10 @@
|
||||
{:method method
|
||||
:uri uri
|
||||
:body body
|
||||
:headers headers}
|
||||
:headers headers
|
||||
:timeout timeout}
|
||||
{:response-type :input-stream
|
||||
:skip-ssrf-check? true
|
||||
:timeout timeout})
|
||||
:skip-ssrf-check? true})
|
||||
status (:status resp)]
|
||||
(when (not (<= 200 status 299))
|
||||
(let [body (:body resp)]
|
||||
|
||||
@@ -499,7 +499,10 @@
|
||||
:fn (mg/resource "app/migrations/sql/0152-improve-uuid-defaults-and-drop-extension.sql")}
|
||||
|
||||
{:name "0152-rename-version-and-add-indexes-to-server-error-report"
|
||||
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}])
|
||||
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}
|
||||
|
||||
{:name "0153-drop-http-session-table"
|
||||
:fn (mg/resource "app/migrations/sql/0153-drop-http-session-table.sql")}])
|
||||
|
||||
(defn apply-migrations!
|
||||
[pool name migrations]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Legacy v1 HTTP sessions have been removed from the backend; the
|
||||
-- http_session table is no longer read or written by any code path.
|
||||
|
||||
DROP TABLE http_session;
|
||||
@@ -390,18 +390,26 @@
|
||||
|
||||
(def ^:private sql:file-comment-users
|
||||
"WITH available_profiles AS (
|
||||
SELECT DISTINCT owner_id AS id
|
||||
FROM comment
|
||||
WHERE thread_id IN (SELECT id FROM comment_thread WHERE file_id=?)
|
||||
SELECT DISTINCT c.owner_id AS id
|
||||
FROM comment c
|
||||
JOIN comment_thread ct
|
||||
ON ct.id = c.thread_id
|
||||
WHERE ct.file_id = ?::uuid
|
||||
),
|
||||
profile_ids AS (
|
||||
SELECT id FROM available_profiles
|
||||
UNION
|
||||
SELECT ?::uuid
|
||||
)
|
||||
SELECT p.id,
|
||||
p.email,
|
||||
p.fullname AS name,
|
||||
p.fullname AS fullname,
|
||||
p.fullname,
|
||||
p.photo_id,
|
||||
p.is_active
|
||||
FROM profile AS p
|
||||
WHERE p.id IN (SELECT id FROM available_profiles) OR p.id=?")
|
||||
FROM profile p
|
||||
JOIN profile_ids AS x
|
||||
ON x.id = p.id;")
|
||||
|
||||
(defn get-file-comments-users
|
||||
[conn file-id profile-id]
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
[app.rpc.notifications :as notifications]
|
||||
[app.storage :as sto]
|
||||
[app.util.services :as sv]
|
||||
[app.util.ssrf :as ssrf]
|
||||
[app.worker :as wrk]
|
||||
[cuerdas.core :as str]))
|
||||
|
||||
@@ -960,13 +961,18 @@ RETURNING id, deleted_at;")
|
||||
(sv/defmethod ::check-organization-sso
|
||||
"Validate an organization SSO configuration by generating a login redirect URL.
|
||||
Nitrate calls this while configuring SSO to verify client credentials and OIDC
|
||||
discovery before saving the settings."
|
||||
discovery before saving the settings. The issuer URL is nitrate-supplied
|
||||
(customer-configured), so it is checked against the SSRF blocklist before
|
||||
any outbound request is attempted."
|
||||
{::doc/added "2.18"
|
||||
::sm/params cto/schema:nitrate-sso
|
||||
::sm/result schema:check-organization-sso-result
|
||||
::rpc/auth false}
|
||||
[cfg params]
|
||||
{:valid (oidc/is-organization-sso-config-valid? cfg params)})
|
||||
(let [issuer (oidc/organization-sso-discovery-uri params)]
|
||||
{:valid (boolean (and issuer
|
||||
(ssrf/safe-url? issuer)
|
||||
(oidc/is-organization-sso-config-valid? cfg params)))}))
|
||||
|
||||
;; ---- API: notify-organization-sso-change
|
||||
(sv/defmethod ::notify-organization-sso-change
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
{:columns [:id :email]})]
|
||||
(when-not (:is-blocked profile)
|
||||
(db/update! conn :profile {:is-blocked true} {:id (:id profile)})
|
||||
(db/delete! conn :http-session {:profile-id (:id profile)})
|
||||
(session/invalidate-all system (:id profile))
|
||||
:blocked))))))
|
||||
|
||||
(defn reset-password!
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns backend-tests.auth-ldap-test
|
||||
(:require
|
||||
[app.auth.ldap :as ldap-auth]
|
||||
[clj-ldap.client :as ldap]
|
||||
[clojure.test :as t]))
|
||||
|
||||
;; --- search-user: filter must be escaped (RED: currently not escaped)
|
||||
|
||||
(t/deftest search-user-escapes-email-in-filter
|
||||
(t/testing "wildcard * is escaped before building LDAP filter"
|
||||
(let [captured-query (atom nil)
|
||||
fake-search (fn [_conn _base-dn params]
|
||||
(reset! captured-query (:filter params))
|
||||
[])]
|
||||
(with-redefs [ldap/search fake-search]
|
||||
(#'ldap-auth/search-user {:query "(mail=:username)" :sizelimit 1
|
||||
:attrs-username "uid" :attrs-email "mail"
|
||||
:attrs-fullname "cn"}
|
||||
"fry*@planetexpress.com"))
|
||||
;; After fix: * should be escaped as \2a
|
||||
(t/is (= "(mail=fry\\2a@planetexpress.com)" @captured-query)
|
||||
"filter must have * escaped per RFC 4515"))))
|
||||
|
||||
;; --- retrieve-user: email must come from directory, not client (RED)
|
||||
|
||||
(t/deftest retrieve-user-uses-directory-email
|
||||
(t/testing "returned email is from LDAP directory, not client input"
|
||||
(let [fake-search (fn [_conn _base-dn _params]
|
||||
[{:dn "cn=fry,ou=people,dc=planetexpress,dc=com"
|
||||
:mail "fry@planetexpress.com"
|
||||
:cn "Philip J. Fry"
|
||||
:uid "fry"}])
|
||||
fake-bind? (fn [_conn _dn _password] true)]
|
||||
(with-redefs [ldap/search fake-search
|
||||
ldap/bind? fake-bind?]
|
||||
(let [cfg {:query "(mail=:username)" :sizelimit 1
|
||||
:attrs-username "uid" :attrs-email "mail"
|
||||
:attrs-fullname "cn"}
|
||||
result (#'ldap-auth/retrieve-user cfg {:email "fry*@planetexpress.com" :password "fry"})]
|
||||
;; After fix: email should be from directory (fry@planetexpress.com)
|
||||
;; BUG: email is client input (fry*@planetexpress.com)
|
||||
(t/is (= "fry@planetexpress.com" (:email result))
|
||||
"email must come from LDAP directory attribute, not client input"))))))
|
||||
|
||||
;; --- authenticate: full flow with directory email (RED)
|
||||
|
||||
(t/deftest authenticate-returns-directory-email
|
||||
(t/testing "authenticate returns directory email for profile"
|
||||
(let [fake-search (fn [_conn _base-dn _params]
|
||||
[{:dn "cn=amy,ou=people,dc=planetexpress,dc=com"
|
||||
:mail "amy@planetexpress.com"
|
||||
:cn "Amy Wong"
|
||||
:uid "amy"}])
|
||||
fake-bind? (fn [_conn _dn _password] true)]
|
||||
(with-redefs [ldap/search fake-search
|
||||
ldap/bind? fake-bind?
|
||||
ldap/connect (fn [_cfg] (reify java.lang.AutoCloseable (close [_] nil)))]
|
||||
(let [cfg {:query "(mail=:username)" :sizelimit 1
|
||||
:attrs-username "uid" :attrs-email "mail"
|
||||
:attrs-fullname "cn"
|
||||
:bind-dn "cn=admin,dc=planetexpress,dc=com"
|
||||
:bind-password "GoodNewsEveryone"
|
||||
:host "localhost" :port 10389
|
||||
:ssl false :tls false
|
||||
:base-dn "ou=people,dc=planetexpress,dc=com"}
|
||||
result (ldap-auth/authenticate cfg {:email "*@planetexpress.com" :password "amy"})]
|
||||
;; After fix: email should be amy@planetexpress.com (directory)
|
||||
;; BUG: email is *@planetexpress.com (client)
|
||||
(t/is (= "amy@planetexpress.com" (:email result))
|
||||
"authenticate must return directory email, not client-supplied wildcard"))))))
|
||||
@@ -0,0 +1,30 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns backend-tests.http-client-test
|
||||
(:require
|
||||
[app.http.client :as http]
|
||||
[clojure.test :as t]
|
||||
[java-http-clj.core :as jhttp]
|
||||
[mockery.core :refer [with-mocks]]))
|
||||
|
||||
(t/deftest send-injects-default-timeout-when-absent
|
||||
(with-mocks [mock {:target 'java-http-clj.core/send
|
||||
:return {:status 200 :body ""}}]
|
||||
(let [client (jhttp/build-client {})]
|
||||
(http/send! client {:method :get :uri "https://example.com/"})
|
||||
(let [[req _opts] (:call-args @mock)]
|
||||
(t/is (= http/default-request-timeout (:timeout req)))))))
|
||||
|
||||
(t/deftest send-preserves-caller-supplied-timeout
|
||||
(with-mocks [mock {:target 'java-http-clj.core/send
|
||||
:return {:status 200 :body ""}}]
|
||||
(let [client (jhttp/build-client {})]
|
||||
(http/send! client {:method :get
|
||||
:uri "https://example.com/"
|
||||
:timeout 5000})
|
||||
(let [[req _opts] (:call-args @mock)]
|
||||
(t/is (= 5000 (:timeout req)))))))
|
||||
@@ -8,6 +8,8 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.common.time :as ct]
|
||||
[app.common.uuid :as uuid]
|
||||
[app.config :as cf]
|
||||
[app.db :as db]
|
||||
[app.http :as-alias http]
|
||||
[app.http.access-token]
|
||||
@@ -21,6 +23,7 @@
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.string :as str]
|
||||
[clojure.test :as t]
|
||||
[integrant.core :as ig]
|
||||
[mockery.core :refer [with-mocks]]
|
||||
[yetti.request :as yreq]
|
||||
[yetti.response :as yres])
|
||||
@@ -117,12 +120,11 @@
|
||||
(handler (make-dummy-request {}))
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
;; A bearer token is only attached when it is a current session
|
||||
;; token (kid=1/ver=1) and a decoder is configured. Otherwise the
|
||||
;; request stays unauthenticated.
|
||||
(handler (make-dummy-request {:headers {"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/is (nil? (::http/auth-data @request)))))
|
||||
|
||||
(t/deftest auth-middleware-3
|
||||
(let [request (volatile! nil)
|
||||
@@ -134,11 +136,7 @@
|
||||
(t/is (nil? (::http/auth-data @request)))
|
||||
|
||||
(handler (make-dummy-request {:cookies {"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/is (nil? (::http/auth-data @request)))))
|
||||
|
||||
(t/deftest shared-key-auth
|
||||
(let [handler (#'app.http.middleware/wrap-shared-key-auth
|
||||
@@ -277,6 +275,225 @@
|
||||
(t/is (= (:id session) (:sid claims)))
|
||||
(t/is (= (:id profile) (:uid claims)))))
|
||||
|
||||
(t/deftest session-token-contains-exp-claim
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
profile (th/create-profile* 1)
|
||||
session (->> (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
claims (tokens/decode cfg (:token session))
|
||||
exp (:exp claims)]
|
||||
(t/is (some? exp) "session token should contain :exp claim")
|
||||
(t/is (ct/inst? exp) "exp should be an instant")))
|
||||
|
||||
(t/deftest session-token-exp-based-on-created-at
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
profile (th/create-profile* 1)
|
||||
session (->> (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
claims (tokens/decode cfg (:token session))
|
||||
expected-exp (ct/plus (:created-at session) (ct/duration {:days 30}))]
|
||||
(t/is (some? (:exp claims)) "session token should contain :exp claim")
|
||||
(t/is (= (inst-ms (:exp claims))
|
||||
(inst-ms expected-exp))
|
||||
"exp should equal created-at + 30 days")))
|
||||
|
||||
(t/deftest session-token-past-exp-is-rejected
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
profile (th/create-profile* 1)
|
||||
session (->> (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
(#'session/assign-token cfg))
|
||||
claims (tokens/decode cfg (:token session))
|
||||
past-claims (assoc claims :exp (ct/minus (ct/now) (ct/duration {:days 1})))
|
||||
past-token (tokens/generate cfg past-claims {:kid 1 :ver 1})]
|
||||
(t/is (nil? (session/decode-token cfg past-token))
|
||||
"token with exp in the past should be rejected")))
|
||||
|
||||
(t/deftest session-renewal-preserves-original-exp
|
||||
(let [cfg th/*system*
|
||||
profile (th/create-profile* 1)
|
||||
created (ct/minus (ct/now) (ct/duration {:days 1}))
|
||||
session {:id (uuid/random)
|
||||
:profile-id (:id profile)
|
||||
:user-agent "user agent"
|
||||
:created-at created
|
||||
:modified-at (ct/minus (ct/now) (ct/duration {:hours 7}))}
|
||||
manager (reify session/ISessionManager
|
||||
(read-session [_ _] session)
|
||||
(create-session [_ _] session)
|
||||
(update-session [_ s] (assoc s :modified-at (ct/now)))
|
||||
(delete-session [_ _] nil))
|
||||
|
||||
old-token (:token (#'session/assign-token cfg session))
|
||||
original-exp (:exp (tokens/decode cfg old-token))
|
||||
handler (-> (fn [req] req)
|
||||
(#'session/wrap-authz (assoc th/*system* ::session/manager manager))
|
||||
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
||||
:cookie (partial session/decode-token cfg)}))
|
||||
response (handler (make-dummy-request {:cookies {"auth-token" old-token}}))
|
||||
renewed-token (get-in response [::yres/cookies "auth-token" :value])
|
||||
renewed-claims (tokens/decode cfg renewed-token)]
|
||||
(t/is (some? original-exp) "original token should have :exp")
|
||||
(t/is (not= old-token renewed-token) "renewal should issue a new token string")
|
||||
(t/is (= (inst-ms original-exp) (inst-ms (:exp renewed-claims)))
|
||||
"renewed token should preserve the original :exp, not extend it")))
|
||||
|
||||
(t/deftest session-renewal-preserves-exp-with-db-manager
|
||||
(let [cfg th/*system*
|
||||
manager (::session/manager th/*system*)
|
||||
profile (th/create-profile* 1)
|
||||
created (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
_ (th/db-exec-one! ["UPDATE http_session_v2
|
||||
SET modified_at = now() - interval '7 hours'
|
||||
WHERE id = ?" (:id created)])
|
||||
stale (session/read-session manager (:id created))
|
||||
old-token (:token (#'session/assign-token cfg stale))
|
||||
original-exp (:exp (tokens/decode cfg old-token))
|
||||
handler (-> (fn [req] req)
|
||||
(#'session/wrap-authz cfg)
|
||||
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
||||
:cookie (partial session/decode-token cfg)}))
|
||||
response (handler (make-dummy-request {:cookies {"auth-token" old-token}}))
|
||||
renewed (get-in response [::yres/cookies "auth-token" :value])
|
||||
renewed-exp (:exp (tokens/decode cfg renewed))
|
||||
expected-exp (ct/plus (:created-at created) (ct/duration {:days 30}))
|
||||
current (session/read-session manager (:id created))]
|
||||
(t/is (some? original-exp) "original token should have :exp")
|
||||
(t/is (some? renewed) "renewal should issue a new cookie token")
|
||||
(t/is (not= old-token renewed) "renewal should issue a new token string")
|
||||
(t/is (= (inst-ms original-exp) (inst-ms renewed-exp))
|
||||
"renewed token should preserve the original :exp, not extend it")
|
||||
(t/is (= (inst-ms expected-exp) (inst-ms renewed-exp))
|
||||
"renewed :exp should equal created-at + 30 days")
|
||||
(t/is (some? current) "session row must still exist after renewal")
|
||||
(t/is (pos? (compare (:modified-at current) (:modified-at stale)))
|
||||
"persisted modified_at must move forward on renewal")))
|
||||
|
||||
(t/deftest session-renewal-cookie-expires-diverges-from-token-exp
|
||||
(let [cfg th/*system*
|
||||
manager (::session/manager th/*system*)
|
||||
profile (th/create-profile* 1)
|
||||
created (session/create-session manager {:profile-id (:id profile)
|
||||
:user-agent "user agent"})
|
||||
_ (th/db-exec-one! ["UPDATE http_session_v2
|
||||
SET created_at = now() - interval '29 days',
|
||||
modified_at = now() - interval '7 hours'
|
||||
WHERE id = ?" (:id created)])
|
||||
stale (session/read-session manager (:id created))
|
||||
old-token (:token (#'session/assign-token cfg stale))
|
||||
handler (-> (fn [req] req)
|
||||
(#'session/wrap-authz cfg)
|
||||
(#'mw/wrap-auth {:bearer (partial session/decode-token cfg)
|
||||
:cookie (partial session/decode-token cfg)}))
|
||||
response (handler (make-dummy-request {:cookies {"auth-token" old-token}}))
|
||||
cookie (get-in response [::yres/cookies "auth-token"])
|
||||
renewed (:value cookie)
|
||||
renewed-exp (:exp (tokens/decode cfg renewed))
|
||||
expected-exp (ct/plus (:created-at stale) (ct/duration {:days 30}))
|
||||
close-to? (fn [a b tolerance-ms]
|
||||
(<= (Math/abs (- (inst-ms a) (inst-ms b))) tolerance-ms))]
|
||||
(t/is (some? renewed) "renewal should issue a new cookie token")
|
||||
(t/is (not= old-token renewed) "renewal should issue a new token string")
|
||||
(t/is (= (inst-ms expected-exp) (inst-ms renewed-exp))
|
||||
"renewed :exp should equal created-at + 30 days")
|
||||
(t/is (close-to? renewed-exp (ct/plus (ct/now) (ct/duration {:days 1}))
|
||||
(* 10 60 1000))
|
||||
"renewed :exp should be ~1 day out (absolute cap is near)")
|
||||
(t/is (close-to? (:expires cookie) (ct/plus (ct/now) (ct/duration {:days 7}))
|
||||
(* 10 60 1000))
|
||||
"cookie Expires should slide ~7 days out from now")
|
||||
(t/is (pos? (compare (:expires cookie) renewed-exp))
|
||||
"cookie Expires should stay ahead of the token :exp")))
|
||||
|
||||
(t/deftest legacy-session-token-is-rejected
|
||||
(let [cfg th/*system*
|
||||
manager (session/inmemory-manager)
|
||||
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)}))
|
||||
token (tokens/generate cfg {:sid "legacy-session-id"} {:kid 0 :ver 0})
|
||||
response (handler (make-dummy-request {:cookies {"auth-token" token}}))]
|
||||
(t/is (nil? (get response ::http/auth-data))
|
||||
"legacy tokens must not be attached as auth data")
|
||||
(t/is (nil? (::session/profile-id response))
|
||||
"legacy tokens must not authenticate")))
|
||||
|
||||
(t/deftest session-gc-deletes-idle-and-absolute-expired-rows
|
||||
(let [profile (th/create-profile* 1)
|
||||
fresh (uuid/random)
|
||||
idle (uuid/random)
|
||||
absolute (uuid/random)
|
||||
valid (uuid/random)]
|
||||
|
||||
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, created_at, modified_at)
|
||||
VALUES (?, ?, now(), now())"
|
||||
fresh (:id profile)])
|
||||
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, created_at, modified_at)
|
||||
VALUES (?, ?, now() - interval '1 day', now() - interval '8 days')"
|
||||
idle (:id profile)])
|
||||
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, created_at, modified_at)
|
||||
VALUES (?, ?, now() - interval '31 days', now())"
|
||||
absolute (:id profile)])
|
||||
(th/db-exec-one! ["INSERT INTO http_session_v2 (id, profile_id, created_at, modified_at)
|
||||
VALUES (?, ?, now() - interval '1 day', now() - interval '6 days')"
|
||||
valid (:id profile)])
|
||||
|
||||
(db/tx-run! th/*system*
|
||||
(fn [cfg]
|
||||
(#'session/collect-expired-tasks
|
||||
(assoc cfg
|
||||
:app.http.session.tasks/max-age (ct/duration {:days 7})
|
||||
:app.http.session.tasks/max-age-absolute (ct/duration {:days 30})))))
|
||||
|
||||
(let [ids (->> (th/db-exec! ["SELECT id FROM http_session_v2 WHERE profile_id = ?" (:id profile)])
|
||||
(map :id)
|
||||
(set))]
|
||||
(t/is (contains? ids fresh) "fresh session must be kept")
|
||||
(t/is (contains? ids valid) "session within both windows must be kept")
|
||||
(t/is (not (contains? ids idle)) "idle session must be deleted")
|
||||
(t/is (not (contains? ids absolute)) "session past the absolute cap must be deleted"))))
|
||||
|
||||
(t/deftest session-gc-config-wiring
|
||||
(let [idle (ct/duration {:days 3})
|
||||
absolute (ct/duration {:days 10})]
|
||||
(with-redefs [cf/get (fn
|
||||
([k] (case k
|
||||
:auth-token-cookie-max-age idle
|
||||
:auth-token-cookie-max-age-absolute absolute
|
||||
nil))
|
||||
([k default] (case k
|
||||
:auth-token-cookie-max-age idle
|
||||
:auth-token-cookie-max-age-absolute absolute
|
||||
default)))]
|
||||
(let [expanded (ig/expand-key :app.http.session.tasks/gc {})]
|
||||
(t/is (= idle
|
||||
(get-in expanded [:app.http.session.tasks/gc
|
||||
:app.http.session.tasks/max-age]))
|
||||
"task max-age should carry the configured idle window")
|
||||
(t/is (= absolute
|
||||
(get-in expanded [:app.http.session.tasks/gc
|
||||
:app.http.session.tasks/max-age-absolute]))
|
||||
"task max-age-absolute should carry the configured absolute cap")))
|
||||
(with-redefs [cf/get (fn
|
||||
([_k] nil)
|
||||
([_k default] default))]
|
||||
(let [expanded (ig/expand-key :app.http.session.tasks/gc {})]
|
||||
(t/is (= session/default-cookie-max-age
|
||||
(get-in expanded [:app.http.session.tasks/gc
|
||||
:app.http.session.tasks/max-age]))
|
||||
"task max-age should fall back to the default idle window")
|
||||
(t/is (= session/default-cookie-max-age-absolute
|
||||
(get-in expanded [:app.http.session.tasks/gc
|
||||
:app.http.session.tasks/max-age-absolute]))
|
||||
"task max-age-absolute should fall back to the default absolute cap")))))
|
||||
|
||||
(t/deftest parse-request-illegal-argument-exception
|
||||
;; clojure.data.json raises IllegalArgumentException (case
|
||||
;; fall-through) on several kinds of malformed input. The
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.config :as cf]
|
||||
[app.http.client :as http]
|
||||
[app.media.remote :as media.remote]
|
||||
[app.setup :as-alias setup]
|
||||
[app.util.json :as json]
|
||||
@@ -500,6 +501,22 @@
|
||||
:headers {}})]
|
||||
(t/is (= 200 (:status resp))))))))
|
||||
|
||||
(t/deftest service-request-puts-configured-timeout-in-request
|
||||
(t/testing "service-request puts media-processing-service-timeout on the http request"
|
||||
(let [captured (atom nil)]
|
||||
(with-redefs [cf/get (th/config-get-mock config-mock)
|
||||
http/req (fn [_client request _opts]
|
||||
(reset! captured request)
|
||||
{:status 200
|
||||
:body (json-stream {:width 100 :height 100})})]
|
||||
(media.remote/service-request
|
||||
(mk-system)
|
||||
{:method :post
|
||||
:uri "http://localhost:6065/api/image/info"
|
||||
:body nil
|
||||
:headers {}})
|
||||
(t/is (= 5000 (:timeout @captured)))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared key
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
[app.msgbus :as mbus]
|
||||
[app.nitrate :as nitrate]
|
||||
[app.rpc :as-alias rpc]
|
||||
[app.util.ssrf :as ssrf]
|
||||
[app.worker :as wrk]
|
||||
[backend-tests.helpers :as th]
|
||||
[clojure.set :as set]
|
||||
@@ -1806,13 +1807,14 @@
|
||||
|
||||
(t/deftest check-organization-sso-returns-valid-true
|
||||
(let [organization-id (uuid/random)
|
||||
out (with-redefs [oidc/is-organization-sso-config-valid? (constantly true)]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id organization-id
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com"}))]
|
||||
out (with-redefs [ssrf/safe-url? (constantly true)
|
||||
oidc/is-organization-sso-config-valid? (constantly true)]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id organization-id
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com"}))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (true? (-> out :result :valid)))))
|
||||
|
||||
@@ -1827,19 +1829,36 @@
|
||||
|
||||
(t/deftest check-organization-sso-passes-issuer-to-validation
|
||||
(let [organization-id (uuid/random)
|
||||
out (with-redefs [oidc/is-organization-sso-config-valid?
|
||||
(fn [_cfg sso]
|
||||
(and (= "test-client" (:client-id sso))
|
||||
(= "https://idp.example.com/" (:issuer sso))))]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id organization-id
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com/"}))]
|
||||
out (with-redefs [ssrf/safe-url? (constantly true)
|
||||
oidc/is-organization-sso-config-valid?
|
||||
(fn [_cfg sso]
|
||||
(and (= "test-client" (:client-id sso))
|
||||
(= "https://idp.example.com/" (:issuer sso))))]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id organization-id
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "https://idp.example.com/"}))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (true? (-> out :result :valid)))))
|
||||
|
||||
(t/deftest check-organization-sso-returns-valid-false-on-ssrf-blocked-issuer
|
||||
(t/testing "an SSRF-blocked issuer must not reach the OIDC validation flow"
|
||||
(let [called? (atom false)
|
||||
out (with-redefs [oidc/is-organization-sso-config-valid?
|
||||
(fn [_cfg _sso] (reset! called? true) true)]
|
||||
(th/management-command!
|
||||
{::th/type :check-organization-sso
|
||||
:organization-id (uuid/random)
|
||||
:client-id "test-client"
|
||||
:client-secret "test-secret"
|
||||
:issuer "http://127.0.0.1/idp"}))]
|
||||
(t/is (th/success? out))
|
||||
(t/is (false? (-> out :result :valid)))
|
||||
(t/is (false? @called?)
|
||||
"OIDC validation should not run when the issuer is SSRF-blocked"))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; PUSH AUDIT EVENTS
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { rpcPost, extractCookie } from "./helpers/client.mjs";
|
||||
|
||||
async function loginWithLdap(email, password) {
|
||||
const res = await rpcPost("login-with-ldap", { email, password });
|
||||
if (res.status !== 200 || res.body.type) {
|
||||
throw new Error(
|
||||
`LDAP login failed: ${JSON.stringify(res.body)}`
|
||||
);
|
||||
}
|
||||
const cookie = extractCookie(res.setCookie);
|
||||
return { profile: res.body, cookie };
|
||||
}
|
||||
|
||||
describe("LDAP injection — T5-N1-03", () => {
|
||||
|
||||
it("normal LDAP login works with valid credentials", async () => {
|
||||
const { profile, cookie } = await loginWithLdap(
|
||||
"fry@planetexpress.com",
|
||||
"fry"
|
||||
);
|
||||
assert.equal(profile.email, "fry@planetexpress.com");
|
||||
assert.ok(profile.id, "profile should have id");
|
||||
assert.ok(cookie, "cookie should be set");
|
||||
});
|
||||
|
||||
it("wildcard injection: *@planetexpress.com must not return client literal as email", async () => {
|
||||
// ATTACK SCENARIO (from Criptored audit):
|
||||
// 1. Attacker (amy) sends email="*@planetexpress.com" with her own password
|
||||
// 2. LDAP filter becomes (mail=*@planetexpress.com) — * is a wildcard
|
||||
// 3. With sizelimit=1, LDAP returns amy's entry (first match)
|
||||
// 4. Bind succeeds: amy's DN + amy's password = valid
|
||||
//
|
||||
// EXPECTED BEHAVIOR AFTER FIX (two valid outcomes):
|
||||
// A) If * is escaped: LDAP finds no match → wrong-credentials (injection blocked)
|
||||
// B) If * matches: profile email must be "amy@planetexpress.com" (directory), not "*@planetexpress.com" (client)
|
||||
//
|
||||
// Either outcome is correct — the vulnerability is fixed.
|
||||
try {
|
||||
const { profile } = await loginWithLdap("*@planetexpress.com", "amy");
|
||||
// Outcome B: login succeeded, verify email is from directory
|
||||
assert.equal(
|
||||
profile.email,
|
||||
"amy@planetexpress.com",
|
||||
"email must come from LDAP directory, not client input"
|
||||
);
|
||||
} catch (e) {
|
||||
// Outcome A: injection blocked — * is escaped, no LDAP match
|
||||
assert.ok(
|
||||
e.message.includes("wrong-credentials"),
|
||||
"wildcard should be rejected or return directory email"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("identity swap: alternate email must return primary directory email", async () => {
|
||||
// Professor has two emails in LDAP: professor@ and hubert@.
|
||||
// Login with hubert@ — the profile email should be the one
|
||||
// the LDAP directory returns as attrs-email, not what the client typed.
|
||||
//
|
||||
// EXPECTED BEHAVIOR AFTER FIX:
|
||||
// Profile email should be "professor@planetexpress.com" (primary directory email),
|
||||
// NOT "hubert@planetexpress.com" (client literal).
|
||||
//
|
||||
// CURRENT BUG: email is "hubert@planetexpress.com" (client literal) — test FAILS
|
||||
const { profile, cookie } = await loginWithLdap(
|
||||
"hubert@planetexpress.com",
|
||||
"professor"
|
||||
);
|
||||
assert.ok(profile.id, "profile should have id");
|
||||
assert.ok(cookie, "cookie should be set");
|
||||
// This assertion FAILS with current code (RED) — proves the vulnerability
|
||||
assert.equal(
|
||||
profile.email,
|
||||
"professor@planetexpress.com",
|
||||
"email must come from LDAP directory, not client input"
|
||||
);
|
||||
});
|
||||
|
||||
it("wrong password fails", async () => {
|
||||
try {
|
||||
await loginWithLdap("fry@planetexpress.com", "wrong-password");
|
||||
assert.fail("should have thrown");
|
||||
} catch (e) {
|
||||
assert.ok(
|
||||
e.message.includes("LDAP login failed") ||
|
||||
e.message.includes("wrong-credentials"),
|
||||
"should fail with wrong credentials"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("non-existent user fails", async () => {
|
||||
try {
|
||||
await loginWithLdap("nobody@planetexpress.com", "password");
|
||||
assert.fail("should have thrown");
|
||||
} catch (e) {
|
||||
assert.ok(
|
||||
e.message.includes("LDAP login failed") ||
|
||||
e.message.includes("wrong-credentials"),
|
||||
"should fail for non-existent user"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -472,6 +472,44 @@ And configure it:
|
||||
PENPOT_SECRET_KEY: my-super-secure-key
|
||||
```
|
||||
|
||||
### Session expiration
|
||||
|
||||
__Since version 2.18.0__
|
||||
|
||||
User sessions are stored server-side and expire on two independent conditions: an
|
||||
**idle timeout** and an **absolute maximum lifetime**. Both are backend only.
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
# Idle timeout: the session stops working after this much inactivity.
|
||||
# Default: 168h
|
||||
PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE: 168h
|
||||
|
||||
# Absolute maximum lifetime from the moment the session was created,
|
||||
# regardless of activity. Default: 720h
|
||||
PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE_ABSOLUTE: 720h
|
||||
```
|
||||
|
||||
Durations use the `<number><unit>` form with hour, minute or second units,
|
||||
for example `168h`, `30m` or `90s` (day units like `7d` are not accepted).
|
||||
|
||||
While a user is active the session is automatically renewed every 6 hours (not
|
||||
configurable). Renewal extends the cookie, but never the absolute maximum. A
|
||||
running daily task (`session-gc`) deletes the sessions that have exceeded either
|
||||
window. Idle expiration takes effect on the next daily `session-gc` run, up to
|
||||
~24h after the idle window elapses; until then a copied session token still
|
||||
verifies. Legacy v1 sessions and the old `http_session` table are no longer
|
||||
used.
|
||||
|
||||
Sessions created before 2.18.0 carry no `:exp` in their token; they are still
|
||||
removed by the 30-day `created_at` cleanup and acquire `:exp` on their next
|
||||
renewal.
|
||||
|
||||
The `secure` and `same-site` attributes of the session cookie are controlled by
|
||||
the `disable-secure-session-cookies`, `strict-session-cookies` and `enable-cors`
|
||||
flags, and by whether `PENPOT_PUBLIC_URI` is served over HTTPS. See
|
||||
[Penpot URI](#penpot-uri).
|
||||
|
||||
### Database
|
||||
|
||||
Penpot only supports PostgreSQL and we highly recommend >=13 version. If you are using official
|
||||
|
||||
@@ -94,14 +94,46 @@ Similarly as the OIDC backend, it checks if the profile exists, and calls
|
||||
|
||||
## Sessions
|
||||
|
||||
User sessions are created when a user logs in via any one of the backends. A
|
||||
session token is generated (a JWT token that does not currently contain any data)
|
||||
and returned to frontend as a cookie.
|
||||
User sessions are created when a user logs in via any one of the backends. The
|
||||
backend generates a signed JWT token and returns it to the frontend as an
|
||||
<code class="language-text">auth-token</code> cookie. A matching row is stored in
|
||||
the <code class="language-text">http_session_v2</code> table with the profile id
|
||||
and the session timestamps.
|
||||
|
||||
Normally the session is stored in a DB table with the information of the user
|
||||
profile and the session expiration. But if a frontend connects to the backend in
|
||||
"read only" mode (for example, to debug something in production with the local
|
||||
devenv), sessions are stored in memory (may be lost if the backend restarts).
|
||||
A request is authenticated only when both the token verifies and its session row
|
||||
still exists. The token claims carry the session row id (<code
|
||||
class="language-clojure">:sid</code>), the last activity instant (<code
|
||||
class="language-clojure">:iat</code>) and an absolute expiration (<code
|
||||
class="language-clojure">:exp</code>). The server enforces two independent
|
||||
limits:
|
||||
|
||||
* **Idle timeout:** a session that is not renewed within
|
||||
<code class="language-bash">PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE</code> (default 7
|
||||
days) stops working once the next daily <code
|
||||
class="language-text">session-gc</code> run deletes it, up to ~24h after the
|
||||
idle window elapses.
|
||||
* **Absolute maximum:** a session cannot live longer than
|
||||
<code class="language-bash">PENPOT_AUTH_TOKEN_COOKIE_MAX_AGE_ABSOLUTE</code>
|
||||
(default 30 days) from its creation, no matter how much it is renewed. The
|
||||
<code class="language-clojure">:exp</code> claim enforces this even when the
|
||||
cookie is still present.
|
||||
|
||||
Sessions are automatically renewed every 6 hours of use (not configurable).
|
||||
Renewal issues a new token but keeps the same session row, so the absolute
|
||||
maximum is not extended. A daily garbage collector
|
||||
(<code class="language-text">session-gc</code>) deletes rows that exceed either
|
||||
the idle window or the absolute maximum.
|
||||
|
||||
Sessions created before 2.18.0 carry no <code
|
||||
class="language-clojure">:exp</code> in their token; they are still removed by
|
||||
the 30-day <code class="language-text">created_at</code> cleanup and acquire <code
|
||||
class="language-clojure">:exp</code> on their next renewal.
|
||||
|
||||
The normal storage is the database. When the backend uses a read-only database
|
||||
pool (for example, to debug something in production with the local devenv),
|
||||
sessions are kept in memory and are lost when the backend restarts. The
|
||||
organization SSO gate keeps an additional 4-hour entry inside the same session
|
||||
row, separate from the session token lifetime.
|
||||
|
||||
## Team invitations
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -291,8 +291,6 @@
|
||||
--modal-link-foreground-color: var(--color-accent-primary);
|
||||
--modal-border-color: var(--color-background-quaternary);
|
||||
--modal-separator-background-color: var(--color-background-quaternary);
|
||||
--modal-navigator-foreground-color-rest: var(--color-background-quaternary);
|
||||
--modal-navigator-foreground-color-active: var(--color-accent-primary);
|
||||
|
||||
// ALERTS, NOTIFICATION, TOAST & BADGES
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
[app.main.ui.releases.v2-15]
|
||||
[app.main.ui.releases.v2-16]
|
||||
[app.main.ui.releases.v2-17]
|
||||
[app.main.ui.releases.v2-18]
|
||||
[app.main.ui.releases.v2-2]
|
||||
[app.main.ui.releases.v2-3]
|
||||
[app.main.ui.releases.v2-4]
|
||||
@@ -107,4 +108,4 @@
|
||||
|
||||
(defmethod rc/render-release-notes "0.0"
|
||||
[params]
|
||||
(rc/render-release-notes (assoc params :version "2.17")))
|
||||
(rc/render-release-notes (assoc params :version "2.18")))
|
||||
@@ -0,0 +1,116 @@
|
||||
// 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 Sucursal en España SL
|
||||
|
||||
@use "ds/_utils.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/typography.scss" as *;
|
||||
|
||||
.modal-overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-index-set);
|
||||
background-color: var(--color-overlay-default);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: px2rem(324) 1fr;
|
||||
block-size: $sz-500;
|
||||
inline-size: px2rem(888);
|
||||
border-radius: $br-8;
|
||||
background-color: var(--color-background-primary);
|
||||
border: $b-2 solid var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
inline-size: px2rem(324);
|
||||
border-radius: $br-8 0 0 $br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: $sz-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr $sz-32;
|
||||
gap: var(--sp-xxl);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include use-typography("headline-small");
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
block-size: $sz-32;
|
||||
inline-size: $sz-96;
|
||||
background-color: var(--color-foreground-primary);
|
||||
color: var(--color-background-tertiary);
|
||||
border-radius: $br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include use-typography("headline-large");
|
||||
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-l);
|
||||
inline-size: px2rem(440);
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include use-typography("body-large");
|
||||
|
||||
color: var(--color-foreground-primary);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include use-typography("body-medium");
|
||||
|
||||
margin: 0;
|
||||
color: var(--color-foreground-secondary);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include use-typography("body-medium");
|
||||
|
||||
color: var(--color-foreground-secondary);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: var(--sp-s);
|
||||
}
|
||||
|
||||
.navigation {
|
||||
inline-size: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@@ -4,28 +4,29 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
@use "ds/_borders.scss" as *;
|
||||
@use "ds/_sizes.scss" as *;
|
||||
@use "ds/_utils.scss" as *;
|
||||
|
||||
.step-dots {
|
||||
display: grid;
|
||||
grid-template-columns: none;
|
||||
grid-auto-flow: column;
|
||||
gap: deprecated.$s-8;
|
||||
height: fit-content;
|
||||
width: fit-content;
|
||||
gap: var(--sp-s);
|
||||
block-size: fit-content;
|
||||
inline-size: fit-content;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
place-self: center flex-start;
|
||||
}
|
||||
|
||||
.dot {
|
||||
height: deprecated.$s-12;
|
||||
width: deprecated.$s-12;
|
||||
border-radius: deprecated.$br-circle;
|
||||
background-color: var(--modal-navigator-foreground-color-rest);
|
||||
cursor: pointer;
|
||||
block-size: $sz-12;
|
||||
inline-size: $sz-12;
|
||||
border-radius: $br-circle;
|
||||
background-color: var(--color-background-quaternary);
|
||||
}
|
||||
|
||||
.current {
|
||||
background-color: var(--modal-navigator-foreground-color-active);
|
||||
background-color: var(--color-accent-primary);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -57,8 +58,10 @@
|
||||
" Ready to dive in? Let 's get started!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -97,8 +100,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -131,8 +136,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -166,8 +173,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
3
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -198,6 +207,7 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,101 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -43,6 +44,7 @@
|
||||
" Ready to dive in? Let 's get started!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click finish} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,80 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -46,8 +47,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -79,8 +82,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -107,7 +112,8 @@
|
||||
"And one more thing…Tune into our product showcase to see future plans and help us shape Penpot. Come for the insights, stay for the community…"]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
[:a {:href "https://penpot.app/penpotfest"
|
||||
[:a {:class (stl/css :link)
|
||||
:href "https://penpot.app/penpotfest"
|
||||
:target "_blank"}
|
||||
"Get your tickets"]
|
||||
" now to join us 8-10 October, in Madrid!"]]
|
||||
@@ -118,8 +124,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -148,8 +156,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
3
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -179,6 +189,7 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -49,8 +50,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -79,8 +82,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -115,8 +120,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -153,8 +160,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
3
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -184,6 +193,7 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -46,8 +47,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -85,8 +88,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -121,8 +126,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
|
||||
2
|
||||
@@ -157,6 +164,7 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -46,8 +47,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -79,8 +82,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -113,6 +118,7 @@
|
||||
:navigate navigate
|
||||
:total 2}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -46,8 +47,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -79,8 +82,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -109,8 +114,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -139,8 +146,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
3
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -173,6 +182,7 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -46,8 +47,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -79,8 +82,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -120,8 +125,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -154,6 +161,7 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -2,12 +2,13 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.main.ui.releases.v2-16
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -44,8 +45,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -74,8 +77,10 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -104,8 +109,10 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -134,8 +141,10 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
3
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -177,8 +186,10 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
4
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -207,6 +218,7 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -2,107 +2,6 @@
|
||||
// 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
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -2,12 +2,13 @@
|
||||
;; 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
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.main.ui.releases.v2-17
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -44,8 +45,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -74,8 +77,10 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -104,8 +109,10 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -134,8 +141,10 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
3
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -171,8 +180,10 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
4
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -213,5 +224,7 @@
|
||||
:navigate navigate
|
||||
:total 5}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -2,107 +2,6 @@
|
||||
// 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
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -0,0 +1,252 @@
|
||||
;; 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 Sucursal en España SL
|
||||
|
||||
(ns app.main.ui.releases.v2-18
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
(defmethod c/render-release-notes "2.18"
|
||||
[{:keys [slide klass next finish navigate version]}]
|
||||
(mf/html
|
||||
(case slide
|
||||
:start
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.18-slide-0.jpg"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "Penpot 2.18 is here!"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"What’s new in Penpot?"]
|
||||
|
||||
[:div {:class (stl/css :version-tag)}
|
||||
(dm/str "Version " version)]]
|
||||
|
||||
[:div {:class (stl/css :features-block)}
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Say hello to Penpot Enterprise, our new paid plan, shipping alongside the most requested stroke-to-path functionality and a fresh batch of features and fixes."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Penpot stays free, unlimited, and open source. Enterprise is meant for organisations that need to govern over people, workflows or content."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.18-enterprise.jpg"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "Introducing Penpot Enterprise"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"Introducing Penpot Enterprise"]]
|
||||
|
||||
[:div {:class (stl/css :feature)}
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"As an open source product, Penpot brings the most advanced features to everyone for free."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Penpot Enterprise is a paid layer that overrides, tweaks or restricts how these features can be used at scale."]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:> c/navigation-bullets*
|
||||
{:slide slide
|
||||
:navigate navigate
|
||||
:total 6}]
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.18-sso.jpg"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "One Sign in for everyone"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"One Sign in for everyone"]]
|
||||
|
||||
[:div {:class (stl/css :feature)}
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Let your whole organization sign in through your own corporate identity provider, whether that's a generic OpenID Connect provider, Azure Active Directory, or Google."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"One consistent way into your organization’s teams and files, governed by the directory you already run. From the Admin Console, you’ll be able to configure your identity provider setup to Penpot in just a few steps."]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:> c/navigation-bullets*
|
||||
{:slide slide
|
||||
:navigate navigate
|
||||
:total 6}]
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.18-permissions.jpg"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "Advanced permissions"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"Advanced permissions"]]
|
||||
|
||||
[:div {:class (stl/css :feature)}
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Decide who can do what across every team at once, who can create, edit, administer teams, projects, and files, and who's allowed to invite new people in."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Your rules sit on top of everyone's normal role, so the whole organization stays aligned with how you want to work, and every change takes effect the moment you make it."]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:> c/navigation-bullets*
|
||||
{:slide slide
|
||||
:navigate navigate
|
||||
:total 6}]
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
3
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.18-console.jpg"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "An Admin Panel to rule them all"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"An Admin Panel to rule them all"]]
|
||||
|
||||
[:div {:class (stl/css :feature)}
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"A separate Admin Panel allows authorized users to create independent Organisations to which certain rules can be applied."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"We are starting with something straightforward; Per-Organisation SSO (choose between multiple identity providers) and advanced permissions (decide who can do what across every team)."]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:> c/navigation-bullets*
|
||||
{:slide slide
|
||||
:navigate navigate
|
||||
:total 6}]
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
4
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.18-billing.jpg"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "Penpot Enterprise billing"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"Penpot Enterprise billing"]]
|
||||
|
||||
[:div {:class (stl/css :feature)}
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"This is equally important. Whether on a monthly or annual subscription, customers using Penpot Enterprise only pay once per identifiable user (email address), regardless of the number of organisations this user is a member of."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Bulk adding or removing users is trivial and we are making sure that you have total transparency and ergonomics around your billing cycle and what it contains."]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:> c/navigation-bullets*
|
||||
{:slide slide
|
||||
:navigate navigate
|
||||
:total 6}]
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
5
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
[:div.animated {:class klass}
|
||||
[:div {:class (stl/css :modal-container)}
|
||||
[:img {:src "images/features/2.18-stroke2path.gif"
|
||||
:class (stl/css :start-image)
|
||||
:border "0"
|
||||
:alt "Stroke to Paths and new drawing tools"}]
|
||||
|
||||
[:div {:class (stl/css :modal-content)}
|
||||
[:div {:class (stl/css :modal-header)}
|
||||
[:h1 {:class (stl/css :modal-title)}
|
||||
"Stroke to Paths and new drawing tools"]]
|
||||
|
||||
[:div {:class (stl/css :feature)}
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Paths can now convert their stroke into a separate, editable path shape, letting you reshape or style the outline independently of the original path. Currently available under the new WebGL renderer."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Lots of smaller wins add up this cycle. A few favorites:"]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"• Draw faster with new shape and free-draw tool flyouts in the toolbar."]
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"• Dedicated Line and Arrow drawing tools (by @davidv399)."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"On top of that, 2.18 ships plugin API improvements, a broad round of bug fixes, and community-contributed fixes (thanks to @Krishcode264, @filipsajdak, @sawirricardo, and many more)."]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
|
||||
[:> c/navigation-bullets*
|
||||
{:slide slide
|
||||
:navigate navigate
|
||||
:total 6}]
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -0,0 +1,7 @@
|
||||
// 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 Sucursal en España SL
|
||||
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -46,6 +47,7 @@
|
||||
"Thanks again to our awesome community for their amazing contributions to this release!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click finish} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,80 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-480;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -47,8 +48,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -77,8 +80,10 @@
|
||||
:navigate navigate
|
||||
:total 2}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
|
||||
1
|
||||
@@ -98,7 +103,10 @@
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Penpot plugins are quite easy to install."]
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"Be sure to keep an eye on our evolving " [:a {:href "https://penpot.app/penpothub" :target "_blank"} "Penpot Hub"] " to pick the ones that are best suited to enhance your workflow."]
|
||||
"Be sure to keep an eye on our evolving "
|
||||
[:a {:class (stl/css :link)
|
||||
:href "https://penpot.app/penpothub"
|
||||
:target "_blank"} "Penpot Hub"] " to pick the ones that are best suited to enhance your workflow."]
|
||||
|
||||
[:p {:class (stl/css :feature-content)}
|
||||
"This is just the beginning of a myriad of possibilities. Let’s build this community together ❤️."]]
|
||||
@@ -110,6 +118,7 @@
|
||||
:navigate navigate
|
||||
:total 2}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -47,8 +48,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -77,8 +80,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -107,8 +112,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -136,6 +143,7 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -47,8 +48,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -77,8 +80,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -107,8 +112,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -141,8 +148,10 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
3
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -170,6 +179,7 @@
|
||||
:navigate navigate
|
||||
:total 4}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||
@@ -4,105 +4,4 @@
|
||||
//
|
||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
@use "refactor/common-refactor.scss" as deprecated;
|
||||
|
||||
.modal-overlay {
|
||||
@extend %modal-overlay-base;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: grid;
|
||||
grid-template-columns: deprecated.$s-324 1fr;
|
||||
height: deprecated.$s-500;
|
||||
width: deprecated.$s-888;
|
||||
border-radius: deprecated.$br-8;
|
||||
background-color: var(--modal-background-color);
|
||||
border: deprecated.$s-2 solid var(--modal-border-color);
|
||||
}
|
||||
|
||||
.start-image {
|
||||
width: deprecated.$s-324;
|
||||
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: deprecated.$s-40;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr deprecated.$s-32;
|
||||
gap: deprecated.$s-24;
|
||||
|
||||
a {
|
||||
color: var(--button-primary-background-color-rest);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
@include deprecated.flex-center;
|
||||
@include deprecated.headline-small-typography;
|
||||
|
||||
height: deprecated.$s-32;
|
||||
width: deprecated.$s-96;
|
||||
background-color: var(--communication-tag-background-color);
|
||||
color: var(--communication-tag-foreground-color);
|
||||
border-radius: deprecated.$br-8;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
@include deprecated.headline-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.features-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-16;
|
||||
width: deprecated.$s-440;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
@include deprecated.body-large-typography;
|
||||
|
||||
color: var(--modal-title-foreground-color);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
margin: 0;
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
@include deprecated.body-medium-typography;
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
list-style: disc;
|
||||
display: grid;
|
||||
gap: deprecated.$s-8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-areas: "bullets button";
|
||||
}
|
||||
|
||||
.next-btn {
|
||||
@extend %button-primary;
|
||||
|
||||
width: deprecated.$s-100;
|
||||
justify-self: flex-end;
|
||||
grid-area: button;
|
||||
}
|
||||
@use "base";
|
||||
@@ -8,6 +8,7 @@
|
||||
(:require-macros [app.main.style :as stl])
|
||||
(:require
|
||||
[app.common.data.macros :as dm]
|
||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||
[app.main.ui.releases.common :as c]
|
||||
[rumext.v2 :as mf]))
|
||||
|
||||
@@ -48,8 +49,10 @@
|
||||
"Let’s dive in!"]]
|
||||
|
||||
[:div {:class (stl/css :navigation)}
|
||||
[:button {:class (stl/css :next-btn)
|
||||
:on-click next} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
0
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -89,8 +92,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
1
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -125,8 +130,10 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click next
|
||||
:class (stl/css :next-btn)} "Continue"]]]]]]
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click next
|
||||
:variant "primary"}
|
||||
"Continue"]]]]]]
|
||||
|
||||
2
|
||||
[:div {:class (stl/css-case :modal-overlay true)}
|
||||
@@ -164,6 +171,7 @@
|
||||
:navigate navigate
|
||||
:total 3}]
|
||||
|
||||
[:button {:on-click finish
|
||||
:class (stl/css :next-btn)} "Let's go"]]]]]])))
|
||||
|
||||
[:> button* {:class (stl/css :next-btn)
|
||||
:on-click finish
|
||||
:variant "primary"}
|
||||
"Let's go"]]]]]])))
|
||||