mirror of
https://github.com/ever-co/ever-gauzy.git
synced 2026-10-19 05:59:23 +08:00
latest (#10490)
* feat(settings): redesign the Email Templates page Split the page into an editor pane and a live preview pane with a compact template picker, and give the welcome-user default template a card layout. The Accounting Templates page keeps the old editor styling in its own _template-editor partial so the redesign does not leak into it. * style(email-templates): smaller code text in the HTML editor * feat(i18n): add EMAIL_TEMPLATES_PAGE.FULL_PREVIEW label * feat(email-templates): open the email preview full size in a popup * style(email-templates): style the full preview button and popup * fix(tenant): PUT /tenant links only an image asset of the caller's own tenant An image asset id is a bare UUID, so a Super Admin could point their tenant's logo at another tenant's upload. updateProfile now refuses (400) an imageId whose ImageAsset does not belong to the tenant being updated, on both ORMs. null still clears the logo and an omitted imageId leaves it as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(email-templates): move the Save button onto the filter row * feat(i18n): add EMAIL_TEMPLATES_PAGE grid / editor view labels * feat(email-templates): add a grid view of all templates with rendered thumbnails * style(email-templates): style the view toggle and template grid * fix(accounting): sort incomes and expenses newest first in the cards layout too #10455 gave the Date column a default descending sort, which the grid applies when it mounts. The cards layout loads the same ServerDataSource without mounting the grid, so it still received rows in insertion order. The data source now carries the same default sort from the start; the grid keeps overriding it with whatever column the user sorts by. * fix(email-templates): resolve SonarCloud findings on the template grid - Render grid thumbnails through a SandboxedSrcdocDirective that sandboxes the iframe before writing srcdoc, instead of bypassSecurityTrustHtml (S6268). - Mark the fire-and-forget loadGrid() calls with void (S9383). - Use a fieldset for the Grid | Editor toggle instead of role="group" (S6819). - Merge the duplicate :host block in _template-editor.scss (S4666). * fix(email-templates): address review comments on layout and grid loading - Size the card from its parent (height: 100% down from the host) instead of a 100vh calc that missed the layout column's padding and caused a page scrollbar. - Give each workspace pane a 21rem minimum and let the workspace scroll, so a short window no longer clips the bottom of the Ace editor and its scrollbar. - Stop grid loading when the page is destroyed, and re-check the run after the template fetch before starting the two preview requests. - Walk the grid cards with a shared index instead of shifting a const queue. - Use inject() in SandboxedSrcdocDirective. * ci(egress-audit): a browser leg for the web app, and the harness 1.0.0-rc.5 The egress audit now also runs the published web app image and walks it in a sealed browser: sign-in through the real sign-in page, every route of the Angular router (ui-routes.json, checked against the router on every UI change), a DOM baseline of the links that predate the modules (may only shrink), and the statistics settings page's status request as the browser's positive control. Adds the loaded_off mode (modules loaded, statistics switched off by the operator). Each mode runs as its own job; the harness is installed with npm ci from a lockfile in tools/egress-audit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): wait for the web app before the walk; README for the browser leg Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(webapp): start when Google Maps cannot be loaded The Google Maps script is loaded by an app initializer. When it cannot be loaded (an install without internet access, a blocked host, a firewall), its promise rejected, Angular stopped the bootstrap and the web app stayed a blank page. The failure is now logged and the app starts; only the map widgets stay empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): allow the web app's font and maps hosts; sign-in failure evidence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(location-form): no geocoding or suggestions when Google Maps did not load The location form called google.maps (Geocoder, places Autocomplete) without checking that the script was loaded. With the app now starting when Maps cannot be loaded, the form skips those calls instead and the address is entered by hand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): pull every image up front (signed in to Docker Hub when available), PostgreSQL from the public mirror Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ever-connect): entitlement grace ladder, features, offline import and licence line (WIP) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): pull Docker Hub images through the public mirror, with more retries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): anonymous pulls through the mirror (no Docker Hub sign-in) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(ever-connect): entitlements, grace ladder, offline import Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(ever-connect): audit wording Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ever-connect): the import route is operator-only and needs a connection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ever-connect-ui): licence line, grace and paused notices, import for the operator only; format Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): the DOM baseline of pre-existing links, the web app's third-party hosts, the session check on the web app store Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(ever-connect): split the import checks; handle a file read failure in the UI Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): baseline the sidebar and footer links on every route (redirecting routes are captured before or after the redirect) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): a seed password made per run instead of a fixed one; simpler route formatting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): open the signed-out pages before signing in; the organization edit pages; smoke-test message Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): baseline the links of the signed-out pages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): the UI baseline is adopted; compare it with the base from now on Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(cspell): words of the egress audit's config and README Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ever-connect): store entitlement documents by compare and set; audit refused uploads; bounded file read; handle on import Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): no dependency of develop on a private ever-co repository Scans every tracked package.json, yarn.lock and workflow for git dependencies on ever-co repositories and workflow steps that use one, and asks GitHub without credentials whether each is public. Runs in the egress audit's inputs job on pull requests into develop, on the schedule and on demand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): run the private-dependency check in the inputs job; skip its own fixtures; cspell words Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(cspell): words of the Ever Platform connection module and its tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): the private-dependency check reads dependency fields, lockfile entries and workflow uses only, every npm git form; runs on workflow and nested lockfile changes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(cspell): scope the fixture word to its spec Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): lockfile SSH and shorthand forms in the private-dependency check; smaller functions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): GitHub tarballs and lockfile selector shorthand in the private-dependency check; no git process Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci(egress-audit): explicit sort order Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(email-templates): one scrollbar in the full preview popup The component-wide nb-card-body flex column also matched the dialog body, so the paper (min-height: 100%; overflow-x: auto) shrank to the body height and scrolled inside it, a second scrollbar inside the popup. The dialog body is now a plain block that scrolls once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(cspell): US spelling in the private-dependency check Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(ever-connect,ever-stats): Ever Platform SDK 1.0.0-rc.6 - The connection and statistics plugins pin @ever-co/connect-sdk, -contracts and -tools 1.0.0-rc.6 (from rc.3); the egress audit's harness moves to the same version. - A consent given in app.ever.co returns to the Ever Platform page of the web app (#/pages/integrations/ever-connect): the platform now accepts a return address with a fragment. - The test platform settings (EVER_PLATFORM_ISSUER, EVER_PLATFORM_ROOT_KEYS_FILE) are honoured for the hosts the contract lists for a root keys file (loopback and the private ranges), as the SDK honours them; never for a public address. The egress audit's mock platform sits on a private address of its sealed network. - The published stats schema link points at the SDK release now pinned (the schema is unchanged). - Egress audit: the web app's hash routing is configured with ui_routing (web_url without the fragment, sign-in route named); the harness signs the walk in through the adapter and checks that the sign-in holds, so the adapter's own session watch is gone; ui-routes.json regenerated (the generator now walks lazily loaded children under every parent). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(egress-audit): baseline entries for the newly walked routes; schema source at the pinned release The route generator of 1.0.0-rc.6 walks lazily loaded children under every parent (candidates, invoices) and the hash-routed walk records the signed-out pages under their own routes. Their pages show the same pre-existing links as every other page: the baseline gains those routes, and only for links it already excused (check-baseline-rewalk.mjs, run in place of the shrink check while BASELINE_REWALK is true in this change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(egress-audit): the rewalk check reads the base baseline from a file Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(egress-audit): the rewalk exception covers unrecorded routes and the signed-out pages only Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(income): stop deleted tags from returning after save * Merge pull request #10481 from joel-kalema/feat/file-storage-settings-content Feat/file storage settings content * Feat/settings accounting templates redesign (#10478) * feat(i18n): add ACCOUNTING_TEMPLATES_PAGE grid, editor, full preview and saved labels * feat(accounting-templates): add sandboxed iframe directive and dialog / icon modules * style(accounting-templates): give the page its own stylesheet in the Email Templates design * feat(accounting-templates): redesign the page as a toolbar, editor pane and preview pane * feat(accounting-templates): add the template grid, full preview popup and save feedback * fix(settings): give full-height settings pages a definite shell height * fix(accounting-templates): fill the settings content area and keep Ace sized to its frame * style(accounting-templates): theme the template preview for light and dark mode * feat(accounting-templates): theme grid thumbnails and inline the logo so it loads in the sandbox * fix(accounting-templates): refresh the grid after a save and keep the image set immutable - On a successful save, invalidate any grid load still in flight and reload the grid if it is open, so a card fetched before the save cannot stay stale. - Build the set of image sources in one expression instead of mutating a const. * fix(accounting-templates): keep authored colours in the preview and use a block layout for the full-preview body * Merge pull request #10477 from Cedric921/fix/team-tasks-mikro-orm-advanced-filters fix(tasks): apply the advanced filters in the MikroORM branch of findTeamTasks * fix(invoice): print each payment's date, not the invoice due date, in the payment PDF rows (#10474) * fix(invoice): print each payment's date, not the invoice due date, in the payment PDF rows The payment PDF's table is headed "Payment Date" but every row printed invoice.dueDate, so all payments appeared to share the invoice's due date. Rows now print the payment's own paymentDate (falling back to the date it was recorded); the due date stays in the header where it belongs. * fix(invoice): format the payment PDF dates as days of the organization time zone The forms save a picked day as the browser's local midnight, so 5 October picked in UTC+2 is stored as 2026-10-04T22:00:00Z and a UTC server printed 4 October. The payment rows and the due date in the header are now formatted in the organization's time zone (server zone when none is set), and the spec crosses UTC midnight both ways. * Merge pull request #10476 from Cedric921/fix/7538-capture-notification-primary-display fix(desktop): show the screenshot notification on the primary display every time * fix(public-invoice): the share link never serves the invoice's internal note (#10483) * fix(public-invoice): the share link never serves the invoice's internal note GET /public/invoice/:id/:token selects relations only, so TypeORM selects every invoice column, and PublicTransformInterceptor serialises the whole entity: the person the link was shared with (normally the customer) received internalNote, which the organization writes for itself. The service now drops internalNote before answering. Spec: the stored row carries a note and the served invoice has none (fails on the previous service). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Update packages/core/src/lib/public-share/invoice/public-invoice.service.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * ci: static and runtime egress checks for the optional platform modules (#10488) * ci: static and runtime egress checks for the optional platform modules Static checks, on every pull request that touches code (nothing installed but the harness): - tools/egress-audit/static-hostnames.mjs: no file outside the Ever Platform modules names an Ever host (the harness's never-allowed ever_owned names and every name under them) or an Ever Platform base URL variable more often than static-hostnames.baseline.json records; the baseline holds what Gauzy named before (site, docs, demo accounts, downloads) and may only shrink. With --bundle it scans the built web app of the image under test (static-bundle job). - tools/ever-platform/check-import-boundary.mjs and the ESLint rule ever-platform/import-boundary (one rule set, tools/ever-platform/import-boundary.cjs): only the modules and the plugin lists import a module, through its entry point; the statistics never import the connection or an analytics plugin, the instance identity no HTTP client, the connection and the sign-in plugin never each other, a web part never a server module; the connection exports no entitlement code. - docs/ever-platform/outbound-calls.md: every call the modules can make, generated from the pinned contract's outbound-call rows (tools/ever-platform/outbound-calls.mjs); a check keeps the page, the connection README's table, the statistics README, the audit's excluded rows and the pinned versions in step. Each check has a known-bad fixture its test requires to fail. Runtime runs (egress audit on the published images): - every_trigger: both modules on against the mock platform, every trigger fired; the calls made must be exactly the rows Gauzy makes, row for row. - connect_off_sign_in_on: connection off, Ever ID sign-in plugin on with an issuer on an Ever host: nothing looked up, requested or redirected to, in both legs. - entitlement_ladder: documents in grace, paused, issued in the future (refused) and slightly ahead (accepted); the ladder follows and Gauzy's own routes answer the same at every step. The baseline rewalk exception of the previous change is switched off again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(egress-audit): review findings - The workflow runs the inputs job for any change (the static checks read every text file); the base of the shrink check must exist; the bundle job fails naming a pull that never succeeded. - entitlement_ladder: Gauzy's own routes must answer before the ladder starts; the steps are named by their issue offset and the 7-day lifetime; the mock's clock is restored in a finally block. - every_trigger: the adapter sends the tenant header and picks an organization the Super Admin is a member of; its errors name each answer. - The import scanner parses re-exports (multi-line, comments) with TypeScript. - The README check also fails on a request of a row left out; row 8's schedule is Gauzy's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(egress-audit): explicit sort orders, ESLint locked with the harness Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(ever-platform): the generated tables are wide by design (markdownlint) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: joel-kalema <joelkalema63@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cedric Karungu <ckarungu921@gmail.com> Co-authored-by: aditya-mitra <55396651+aditya-mitra@users.noreply.github.com> Co-authored-by: Cédric K. | Lord VB <83704005+Cedric921@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
joel-kalema
Cedric Karungu
aditya-mitra
Cédric K. | Lord VB
greptile-apps[bot]
commit
0f28100f15
@@ -9,6 +9,15 @@
|
||||
"unmocked",
|
||||
"recno",
|
||||
"blackhole",
|
||||
"baselined",
|
||||
"rewalk",
|
||||
"hostnames",
|
||||
"counterparty",
|
||||
"discoverability",
|
||||
"licence",
|
||||
"licences",
|
||||
"dummyimage",
|
||||
"openstreetmap",
|
||||
"homelab",
|
||||
"selftest",
|
||||
"healthz",
|
||||
|
||||
+5
-1
@@ -1039,7 +1039,7 @@ KEYCLOAK_AUTH_LINK=
|
||||
# switched on, counts (tenants, users, employees, projects...) and monthly totals per currency. Never
|
||||
# a name, an e-mail address, an address or an amount per record (on a small installation a month's
|
||||
# total can equal a single invoice). Schema:
|
||||
# https://github.com/ever-co/ever-connect-sdk/blob/a9844bd44c9fdb4d7f8be404b88331e578d8180c/contracts/schemas/ever.stats.v1.json
|
||||
# https://github.com/ever-co/ever-connect-sdk/blob/93fbb1ef2b37f60205ab193d973467a176eec8df/contracts/schemas/ever.stats.v1.json
|
||||
# On by default; every line below is optional. Operators see the report and can switch it off in
|
||||
# Settings > Anonymous usage statistics. See packages/plugins/ever-stats/README.md.
|
||||
#
|
||||
@@ -1082,3 +1082,7 @@ KEYCLOAK_AUTH_LINK=
|
||||
# `interval` reads Ever Platform events every 15 minutes instead of a long poll (for proxies that
|
||||
# cut long requests).
|
||||
# EVER_CONNECT_FEED_MODE=longpoll
|
||||
# A downloaded entitlement document (a .jws file from app.ever.co), imported once when the connection
|
||||
# starts, for an installation without a route to Ever Platform. It must name this installation or one
|
||||
# of its organization links; the operator can also import one in Integrations > Ever Platform.
|
||||
# EVER_ENTITLEMENT_FILE=
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
name: Egress audit
|
||||
# cspell:words corepack println DOTENV readdir nomock pcaps
|
||||
|
||||
# Proves at runtime, with the published API image, what the anonymous usage statistics module may
|
||||
# send: nothing at all when it is switched off, and only the documented report when it is on. The
|
||||
# API runs with a throw-away PostgreSQL on Docker networks with no route out; CoreDNS is its only resolver and a
|
||||
# capture sees every connection attempt and DNS query from before the process starts. Modes
|
||||
# (tools/egress-audit/README.md):
|
||||
# Proves at runtime, with the published API and web app images, what the Ever Platform modules
|
||||
# (anonymous usage statistics, connection) may send: nothing at all when they are switched off or
|
||||
# loaded but idle, and only the documented report when the statistics are on. The API runs with a
|
||||
# throw-away PostgreSQL on Docker networks with no route out; CoreDNS is its only resolver and a
|
||||
# capture sees every connection attempt and DNS query from before the process starts. The browser
|
||||
# leg does the same for the web app: a real browser in its own sniffed namespace signs in, opens
|
||||
# every route of the Angular router (tools/egress-audit/ui-routes.json) and fails the run when the
|
||||
# UI looks up, requests or links an Ever host. Modes (tools/egress-audit/README.md):
|
||||
#
|
||||
# off EVER_STATS_ENABLED=false in the container environment: no Ever host looked
|
||||
# up, no connection attempt out, every statistics route 404
|
||||
# off_env_file the same switch written ONLY in the API's .env.local file
|
||||
# positive_stats module on against the mock platform: reports accepted (202), and no call
|
||||
# other than the statistics report
|
||||
# control positive_stats without the mock must FAIL, so a green run is not a blind one
|
||||
# off both modules off (EVER_STATS_ENABLED=false in the container environment): no
|
||||
# Ever host looked up or requested by the API or the browser, no connection
|
||||
# attempt out, every statistics route 404, no new Ever link in the UI
|
||||
# off_env_file the statistics switch written ONLY in the API's .env.local file (API leg)
|
||||
# loaded_off both modules loaded (statistics on by configuration, then switched off by the
|
||||
# operator in Settings; connection on, not connected): no call at all, in both legs
|
||||
# positive_stats statistics on against the mock platform: reports accepted (202), no other call;
|
||||
# the settings page's status request seen from the browser
|
||||
# control positive_stats without the mock platform must FAIL, so a green run is not a
|
||||
# blind one
|
||||
# every_trigger both modules on against the mock platform and every trigger of every call
|
||||
# exercised: the calls made are exactly the Gauzy rows of the contract this
|
||||
# release makes (docs/ever-platform/outbound-calls.md), row for row
|
||||
# connect_off_sign_in_on the connection off and the Ever ID sign-in plugin on with an issuer on
|
||||
# an Ever host: the issuer is left unused, nothing looked up or sent, in both legs
|
||||
# entitlement_ladder entitlement documents in grace, paused, issued in the future (refused) and
|
||||
# slightly ahead (accepted): the ladder follows, Gauzy's own routes answer the
|
||||
# same at every step, only the connection's documented calls are made
|
||||
# static-bundle the built web app of the image names no Ever host the baseline does not have
|
||||
#
|
||||
# "Switched off in Settings" (module loaded, no call at all, Send now 409) is proven by the plugin
|
||||
# suite against the same mock platform in build.yml (build-api).
|
||||
# The static checks run on every pull request that touches code (no install but the harness):
|
||||
# Ever hosts named outside the modules (tools/egress-audit/static-hostnames.mjs, a baseline that may
|
||||
# only shrink), the modules' import boundary (tools/ever-platform/check-import-boundary.mjs and its
|
||||
# ESLint rule), and the generated list of outbound calls (tools/ever-platform/outbound-calls.mjs).
|
||||
#
|
||||
# The harness is the egress audit of the SDK's dev tools (`ever-egress-audit` of `@ever-co/connect-tools`,
|
||||
# the exact version the statistics plugin pins in its package.json), installed from npm for this run;
|
||||
# The harness is the egress audit of the SDK's dev tools (`ever-egress-audit` of
|
||||
# `@ever-co/connect-tools`), installed with `npm ci` from tools/egress-audit/package-lock.json;
|
||||
# nothing of it is copied into this repository.
|
||||
#
|
||||
# Runs every day on the newest published develop API image (the demo image), and on demand with
|
||||
# any API image. The configuration comes from the default branch; no code of a pull request runs.
|
||||
# Runs every day on the newest published develop images (the demo images), on demand with any
|
||||
# images, and on pull requests: the route list and baseline checks on every change to the UI's
|
||||
# routes, the audit itself when its inputs change. The audit runs the published images: no code of
|
||||
# a pull request runs in it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 6 * * *'
|
||||
@@ -32,57 +52,264 @@ on:
|
||||
description: 'The Gauzy API image to audit'
|
||||
required: true
|
||||
default: 'ghcr.io/ever-co/gauzy-api-demo:latest'
|
||||
webapp_image:
|
||||
description: 'The Gauzy web app image to audit'
|
||||
required: true
|
||||
default: 'ghcr.io/ever-co/gauzy-webapp-demo:latest'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/egress-audit.yml'
|
||||
- 'tools/egress-audit/**'
|
||||
- 'apps/gauzy/src/**'
|
||||
- 'packages/ui-*/**'
|
||||
- 'packages/plugin-ui/**'
|
||||
- 'packages/plugins/*-ui/**'
|
||||
- 'tools/ever-platform/**'
|
||||
- 'docs/ever-platform/**'
|
||||
- 'packages/**'
|
||||
- 'apps/**'
|
||||
- 'eslint.config.js'
|
||||
- '.env.sample'
|
||||
# The static checks read every text file of the tree (deploy files, environment files,
|
||||
# scripts): any change runs the inputs job; the audit itself still runs only when its own
|
||||
# inputs change (see "Run the audit?").
|
||||
- '**'
|
||||
- '**/package.json'
|
||||
- '**/yarn.lock'
|
||||
- '.github/workflows/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
AUDIT_DIR: tools/egress-audit
|
||||
AUDIT_CONFIG: tools/egress-audit/egress-audit.config.json
|
||||
AUDIT_ARTIFACTS: egress-audit-artifacts
|
||||
# The baseline of Ever links the UI rendered before the modules (tools/egress-audit/ui-baseline.json)
|
||||
# may only shrink. It was adopted (--first-version) by the change that added it; from then on every
|
||||
# pull request compares it with its base. 'true' only in a change that adds a new baseline file.
|
||||
BASELINE_FIRST_VERSION: 'false'
|
||||
# 'true' only in a change that makes the browser walk open routes it did not open before (a newer
|
||||
# harness or route generator): the baseline may then gain entries for those routes, and only for
|
||||
# links it already excused on other routes (tools/egress-audit/check-baseline-rewalk.mjs). The next
|
||||
# change sets it back to 'false'.
|
||||
BASELINE_REWALK: 'false'
|
||||
|
||||
jobs:
|
||||
inputs:
|
||||
name: egress-audit inputs
|
||||
# Never run jobs for a pull request from a fork (owner decision 2026-09-16); branch PRs, the
|
||||
# schedule and manual runs still run. GitHub-hosted: nothing here needs the self-hosted fleet.
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
audit: ${{ steps.scope.outputs.audit }}
|
||||
steps:
|
||||
- name: Checkout (full history, for the baseline check)
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Install the harness (tools/egress-audit/package-lock.json)
|
||||
run: npm ci --prefix "$AUDIT_DIR" --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: The route list is in step with the Angular router
|
||||
run: |
|
||||
node "$AUDIT_DIR/node_modules/@ever-co/connect-tools/dist/egress-audit/run.mjs" ui-routes \
|
||||
--framework angular --entry apps/gauzy/src/app/app.routes.ts --out "$AUDIT_DIR/ui-routes.json" --check
|
||||
|
||||
- name: The baseline of Ever links only shrinks
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
flag=""
|
||||
if [ "$BASELINE_FIRST_VERSION" = "true" ]; then flag="--first-version"; fi
|
||||
if [ "$BASELINE_REWALK" = "true" ]; then
|
||||
node "$AUDIT_DIR/node_modules/@ever-co/connect-tools/dist/egress-audit/run.mjs" check-baseline-shrink \
|
||||
--base "origin/$BASE_REF" --config "$AUDIT_CONFIG" $flag || echo "(the walk opens new routes: the entries they added are checked next)"
|
||||
git show "origin/$BASE_REF:$AUDIT_DIR/ui-baseline.json" > "$RUNNER_TEMP/ui-baseline.base.json"
|
||||
node tools/egress-audit/check-baseline-rewalk.mjs --base-file "$RUNNER_TEMP/ui-baseline.base.json"
|
||||
else
|
||||
node "$AUDIT_DIR/node_modules/@ever-co/connect-tools/dist/egress-audit/run.mjs" check-baseline-shrink \
|
||||
--base "origin/$BASE_REF" --config "$AUDIT_CONFIG" $flag
|
||||
fi
|
||||
|
||||
- name: No Ever host named outside the Ever Platform modules beyond the baseline (sources)
|
||||
run: node --test tools/egress-audit/static-hostnames.test.mjs
|
||||
|
||||
- name: The baseline of Ever hosts in the sources only shrinks
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
base_ref="origin/$BASE_REF"
|
||||
base_path="$AUDIT_DIR/static-hostnames.baseline.json"
|
||||
base_file="$RUNNER_TEMP/static-hostnames.base.json"
|
||||
git rev-parse --verify --quiet "$base_ref^{commit}" > /dev/null || { echo "::error::the base $base_ref is not available"; exit 1; }
|
||||
if git cat-file -e "$base_ref:$base_path" 2> /dev/null; then
|
||||
git show "$base_ref:$base_path" > "$base_file"
|
||||
else
|
||||
: > "$base_file" # the baseline is new in this change
|
||||
fi
|
||||
node tools/egress-audit/static-hostnames.mjs --check-shrink "$base_file"
|
||||
|
||||
- name: The import boundary of the Ever Platform modules (scanner and ESLint rule)
|
||||
env:
|
||||
# The workspace's ESLint and parser versions, locked in tools/egress-audit/package-lock.json
|
||||
# and installed with the harness above (no workspace install).
|
||||
ESLINT_PREFIX: ${{ github.workspace }}/tools/egress-audit
|
||||
EVER_BOUNDARY_ESLINT_REQUIRED: 'true'
|
||||
run: |
|
||||
node --test tools/ever-platform/import-boundary.test.mjs
|
||||
|
||||
- name: The list of outbound calls is the pinned contract's, and everything that repeats it agrees
|
||||
run: node --test tools/ever-platform/outbound-calls.test.mjs
|
||||
|
||||
- name: No dependency on a private ever-co repository (the default branch must build for anyone)
|
||||
if: ${{ github.event_name != 'pull_request' || github.base_ref == 'develop' }}
|
||||
run: |
|
||||
node --test tools/ever-platform/check-private-deps.test.mjs
|
||||
node tools/ever-platform/check-private-deps.mjs
|
||||
|
||||
- name: Run the audit?
|
||||
id: scope
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
# On a pull request the audit runs only when its own inputs change: it audits the published
|
||||
# images, so a change elsewhere is audited once it is merged and built.
|
||||
audit=true
|
||||
if [ "$EVENT" = "pull_request" ]; then
|
||||
if git diff --quiet "origin/$BASE_REF...HEAD" -- "$AUDIT_DIR" .github/workflows/egress-audit.yml; then audit=false; fi
|
||||
fi
|
||||
echo "audit=$audit" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
egress-audit:
|
||||
name: egress-audit
|
||||
name: egress-audit (${{ matrix.mode }})
|
||||
needs: inputs
|
||||
if: ${{ !cancelled() && needs.inputs.result == 'success' && needs.inputs.outputs.audit == 'true' }}
|
||||
# GitHub-hosted on purpose: the capture needs Docker with NET_RAW and NET_ADMIN.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 240
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- mode: 'off'
|
||||
run: off
|
||||
dotenv: api-empty.env
|
||||
expect: 0
|
||||
- mode: off_env_file
|
||||
run: off_env_file
|
||||
dotenv: api-stats-off.env
|
||||
expect: 0
|
||||
- mode: loaded_off
|
||||
run: loaded_off
|
||||
dotenv: api-empty.env
|
||||
expect: 0
|
||||
- mode: positive_stats
|
||||
run: positive_stats
|
||||
dotenv: api-empty.env
|
||||
expect: 0
|
||||
# positive_stats without the mock platform must report a violation (exit 1), so a green
|
||||
# run is never a blind one.
|
||||
- mode: control
|
||||
run: positive_stats
|
||||
args: --no-mock
|
||||
dotenv: api-empty.env
|
||||
expect: 1
|
||||
- mode: every_trigger
|
||||
run: every_trigger
|
||||
dotenv: api-empty.env
|
||||
expect: 0
|
||||
- mode: connect_off_sign_in_on
|
||||
run: connect_off_sign_in_on
|
||||
dotenv: api-empty.env
|
||||
expect: 0
|
||||
- mode: entitlement_ladder
|
||||
run: entitlement_ladder
|
||||
dotenv: api-empty.env
|
||||
expect: 0
|
||||
steps:
|
||||
- name: Free disk space for the API image
|
||||
- name: Free disk space for the images
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL /usr/local/share/boost
|
||||
df -h /
|
||||
|
||||
- name: Docker Hub images through Google's public mirror (Docker Hub falls back when it lacks one)
|
||||
run: |
|
||||
# The modes pull the same Docker Hub images in parallel from shared runner addresses, which
|
||||
# Docker Hub rate-limits; every image of the audit is pinned by digest, so the mirror serves
|
||||
# the same bytes.
|
||||
cfg=/etc/docker/daemon.json
|
||||
if [ -s "$cfg" ]; then current=$(cat "$cfg"); else current='{}'; fi
|
||||
echo "$current" | jq '. + {"registry-mirrors": ["https://mirror.gcr.io"]}' | sudo tee "$cfg" > /dev/null
|
||||
sudo systemctl restart docker
|
||||
docker info --format '{{json .RegistryConfig.Mirrors}}'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 26
|
||||
|
||||
- name: Install the harness (@ever-co/connect-tools, the version the statistics plugin pins)
|
||||
- name: Install the harness (tools/egress-audit/package-lock.json)
|
||||
run: |
|
||||
version=$(node -p "require('./packages/plugins/ever-stats/package.json').devDependencies['@ever-co/connect-tools']")
|
||||
npm install --prefix "$RUNNER_TEMP/egress-audit" --no-save --no-audit --no-fund --ignore-scripts "@ever-co/connect-tools@$version"
|
||||
harness="$RUNNER_TEMP/egress-audit/node_modules/@ever-co/connect-tools/dist/egress-audit/run.mjs"
|
||||
npm ci --prefix "$AUDIT_DIR" --ignore-scripts --no-audit --no-fund
|
||||
harness="$PWD/$AUDIT_DIR/node_modules/@ever-co/connect-tools/dist/egress-audit/run.mjs"
|
||||
node "$harness" --help > /dev/null
|
||||
echo "EGRESS_AUDIT=$harness" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Pull the API image
|
||||
- name: Pull the images
|
||||
env:
|
||||
IMAGE: ${{ github.event.inputs.image || 'ghcr.io/ever-co/gauzy-api-demo:latest' }}
|
||||
WEBAPP_IMAGE: ${{ github.event.inputs.webapp_image || 'ghcr.io/ever-co/gauzy-webapp-demo:latest' }}
|
||||
run: |
|
||||
docker pull "$IMAGE"
|
||||
docker pull postgres:18-alpine
|
||||
pull() { for attempt in 1 2 3 4 5; do docker pull -q "$1" && return 0; sleep $((attempt * 20)); done; return 1; }
|
||||
pull "$IMAGE"
|
||||
pull "$WEBAPP_IMAGE"
|
||||
pull public.ecr.aws/docker/library/postgres:18-alpine
|
||||
# The harness's own images (CoreDNS, the sniffer, the namespace holder, the driver and the
|
||||
# browser), read from the installed harness so they follow its pins.
|
||||
dist="$AUDIT_DIR/node_modules/@ever-co/connect-tools/dist"
|
||||
for ref in $(grep -rhoE '[a-z0-9./_-]+:[a-z0-9._-]+@sha256:[a-f0-9]{64}' "$dist/egress-audit/lib" "$dist/egress-audit/compose.audit.yml" | sort -u); do pull "$ref"; done
|
||||
for ref in $(sed -n 's/^FROM \([^ ]*\).*/\1/p' "$dist/mock-platform/Dockerfile" | sort -u); do pull "$ref"; done
|
||||
echo "GAUZY_API_IMAGE=$IMAGE" >> "$GITHUB_ENV"
|
||||
echo "GAUZY_WEBAPP_IMAGE=$WEBAPP_IMAGE" >> "$GITHUB_ENV"
|
||||
commit=$(docker image inspect "$IMAGE" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^GAUZY_APP_COMMIT=//p')
|
||||
echo "The image was built from commit ${commit:-<unknown>}." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo "The API image was built from commit ${commit:-<unknown>}." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo "The web app image is $(docker image inspect "$WEBAPP_IMAGE" --format '{{index .RepoDigests 0}}')." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
df -h /
|
||||
|
||||
- name: The web app image serves its page (outside the audit, so a broken image is named here)
|
||||
run: |
|
||||
docker run -d --name webapp-smoke -e API_BASE_URL=http://api:3000 -e CLIENT_BASE_URL=http://webapp:4200 -p 127.0.0.1:4299:4200 "$GAUZY_WEBAPP_IMAGE" > /dev/null
|
||||
ok=false
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS -o /dev/null http://127.0.0.1:4299/; then ok=true; break; fi
|
||||
sleep 3
|
||||
done
|
||||
docker logs webapp-smoke 2>&1 | grep -v '^+ ' | tail -20 || true
|
||||
docker rm -f webapp-smoke > /dev/null
|
||||
if [ "$ok" != true ]; then echo "::error::the web app image did not serve its page within 3 minutes (port 4200 of the container, published as 127.0.0.1:4299)"; exit 1; fi
|
||||
echo "The web app image serves its page."
|
||||
|
||||
- name: Write the API .env.local files of the modes, and a secret for this run
|
||||
run: |
|
||||
@@ -91,62 +318,90 @@ jobs:
|
||||
echo "GAUZY_AUDIT_SECRET=$secret" >> "$GITHUB_ENV"
|
||||
: > "$RUNNER_TEMP/api-empty.env"
|
||||
printf 'EVER_STATS_ENABLED=false\n' > "$RUNNER_TEMP/api-stats-off.env"
|
||||
df -h /
|
||||
|
||||
- name: 'off: EVER_STATS_ENABLED=false in the container environment'
|
||||
if: ${{ !cancelled() }}
|
||||
- name: 'Audit: ${{ matrix.mode }}'
|
||||
env:
|
||||
GAUZY_AUDIT_DOTENV: ${{ runner.temp }}/api-empty.env
|
||||
run: node "$EGRESS_AUDIT" --config "$AUDIT_CONFIG" --mode off --artifacts "$AUDIT_ARTIFACTS" --verbose
|
||||
|
||||
- name: 'off_env_file: EVER_STATS_ENABLED=false only in the API .env.local file'
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
GAUZY_AUDIT_DOTENV: ${{ runner.temp }}/api-stats-off.env
|
||||
run: node "$EGRESS_AUDIT" --config "$AUDIT_CONFIG" --mode off_env_file --artifacts "$AUDIT_ARTIFACTS" --verbose
|
||||
|
||||
- name: 'positive_stats: on, against the mock platform'
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
GAUZY_AUDIT_DOTENV: ${{ runner.temp }}/api-empty.env
|
||||
run: node "$EGRESS_AUDIT" --config "$AUDIT_CONFIG" --mode positive_stats --artifacts "$AUDIT_ARTIFACTS" --verbose
|
||||
|
||||
- name: 'control: positive_stats without the mock platform must fail'
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
GAUZY_AUDIT_DOTENV: ${{ runner.temp }}/api-empty.env
|
||||
GAUZY_AUDIT_DOTENV: ${{ runner.temp }}/${{ matrix.dotenv }}
|
||||
RUN_MODE: ${{ matrix.run }}
|
||||
RUN_ARGS: ${{ matrix.args }}
|
||||
EXPECT: ${{ matrix.expect }}
|
||||
# Names added to the never-allowed list for this run, kept out of the public files: from a
|
||||
# secret, so the logs mask them, and the reports name them extra#<n>. Empty when unset.
|
||||
EVER_EGRESS_EXTRA_HOSTS: ${{ secrets.EVER_EGRESS_EXTRA_HOSTS }}
|
||||
run: |
|
||||
set +e
|
||||
node "$EGRESS_AUDIT" --config "$AUDIT_CONFIG" --mode positive_stats --no-mock --artifacts "$AUDIT_ARTIFACTS" --verbose
|
||||
node "$EGRESS_AUDIT" --config "$AUDIT_CONFIG" --mode "$RUN_MODE" $RUN_ARGS --artifacts "$AUDIT_ARTIFACTS" --verbose
|
||||
code=$?
|
||||
set -e
|
||||
if [ "$code" -ne 1 ]; then
|
||||
echo "::error::without the mock platform the audit must report a violation (exit 1); it exited $code, so a green run could be a blind one"
|
||||
if [ "$code" -ne "$EXPECT" ]; then
|
||||
if [ "$EXPECT" -eq 1 ]; then
|
||||
echo "::error::without the mock platform the audit must report a violation (exit 1); it exited $code, so a green run could be a blind one"
|
||||
else
|
||||
echo "::error::the audit exited $code (1: a violation, 2: a fault that proves nothing); see report.json in the artifact"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "The control failed as it must (exit 1)."
|
||||
if [ "$EXPECT" -eq 1 ]; then echo "The control failed as it must (exit 1)."; fi
|
||||
|
||||
- name: Summary
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
MODE: ${{ matrix.mode }}
|
||||
run: |
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const dir = process.env.AUDIT_ARTIFACTS;
|
||||
const rows = ["| Mode | Exit | Violations | DNS queries | Connection attempts | Calls |", "|---|---|---|---|---|---|"];
|
||||
for (const mode of fs.existsSync(dir) ? fs.readdirSync(dir).sort() : []) {
|
||||
const file = path.join(dir, mode, "report.json");
|
||||
const rows = ["| Run | Exit | Violations | Faults | DNS queries | Connection attempts | Calls | Browser: routes loaded, HAR entries, DOM refs (baselined) |", "|---|---|---|---|---|---|---|---|"];
|
||||
for (const run of fs.existsSync(dir) ? fs.readdirSync(dir).sort() : []) {
|
||||
const file = path.join(dir, run, "report.json");
|
||||
if (!fs.existsSync(file)) continue;
|
||||
const r = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
const rules = Object.entries(r.summary.rules).map(([k, v]) => `${k}: ${v}`).join(", ") || "none";
|
||||
rows.push(`| ${mode} | ${r.exit} | ${rules} | ${r.summary.dns_queries} | ${r.summary.connection_attempts} | ${r.summary.calls ? r.summary.calls.join(", ") || "none" : "-"} |`);
|
||||
const rules = Object.entries(r.summary.rules ?? {}).map(([k, v]) => `${k}: ${v}`).join(", ") || "none";
|
||||
const b = r.summary.browser;
|
||||
const browser = b ? (b.leg === "ran" ? `${b.loaded}/${b.visits}, ${b.checks.f.entries}, ${b.checks.g.refs} (${b.checks.g.baselined})` : b.leg ?? "-") : "-";
|
||||
rows.push(`| ${run} | ${r.exit} | ${rules} | ${(r.faults ?? []).length} | ${r.summary.dns_queries} | ${r.summary.connection_attempts} | ${r.summary.calls ? r.summary.calls.join(", ") || "none" : "-"} | ${browser} |`);
|
||||
}
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Egress audit\n\n${rows.join("\n")}\n\nThe positive_stats-nomock row is the control and must exit 1.\n`);
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Egress audit: ${process.env.MODE}\n\n${rows.join("\n")}\n\nThe control (positive_stats-nomock) must exit 1.\n`);
|
||||
'
|
||||
|
||||
- name: Upload the evidence (pcaps, DNS logs, reports)
|
||||
- name: Upload the evidence (pcaps, DNS logs, HAR, reports)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: egress-audit
|
||||
name: egress-audit-${{ matrix.mode }}
|
||||
path: ${{ env.AUDIT_ARTIFACTS }}
|
||||
retention-days: 14
|
||||
|
||||
static-bundle:
|
||||
name: egress-audit (static-bundle)
|
||||
needs: inputs
|
||||
if: ${{ !cancelled() && needs.inputs.result == 'success' && needs.inputs.outputs.audit == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 26
|
||||
|
||||
- name: Install the harness (tools/egress-audit/package-lock.json)
|
||||
run: npm ci --prefix "$AUDIT_DIR" --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: The built web app names no Ever host beyond the baseline
|
||||
# bash with pipefail, so the scan's exit code survives the tee.
|
||||
shell: bash
|
||||
env:
|
||||
WEBAPP_IMAGE: ${{ github.event.inputs.webapp_image || 'ghcr.io/ever-co/gauzy-webapp-demo:latest' }}
|
||||
run: |
|
||||
pull() { for attempt in 1 2 3 4 5; do docker pull -q "$1" && return 0; sleep $((attempt * 20)); done; echo "::error::could not pull $1"; return 1; }
|
||||
pull "$WEBAPP_IMAGE"
|
||||
echo "The web app image is $(docker image inspect "$WEBAPP_IMAGE" --format '{{index .RepoDigests 0}}')." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
id=$(docker create "$WEBAPP_IMAGE")
|
||||
docker cp "$id:/srv/gauzy" "$RUNNER_TEMP/webapp"
|
||||
docker rm "$id" > /dev/null
|
||||
node tools/egress-audit/static-hostnames.mjs --bundle "$RUNNER_TEMP/webapp" | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -181,9 +181,18 @@ const FEATURE_MODULES = [
|
||||
serverConnectionFactory(inject(ServerConnectionService), inject(Store), inject(Router))()
|
||||
),
|
||||
|
||||
// Loads the Google Maps JavaScript API key during bootstrap.
|
||||
// Loads the Google Maps JavaScript API during bootstrap. Maps are optional: when the script cannot
|
||||
// be loaded (no internet access, a blocked host, an install behind a firewall) the app still
|
||||
// starts and only the map widgets stay empty. A rejected initializer would stop the bootstrap and
|
||||
// leave a blank page.
|
||||
GoogleMapsLoaderService,
|
||||
provideAppInitializer(() => inject(GoogleMapsLoaderService).load(environment.GOOGLE_MAPS_API_KEY)),
|
||||
provideAppInitializer(() =>
|
||||
inject(GoogleMapsLoaderService)
|
||||
.load(environment.GOOGLE_MAPS_API_KEY)
|
||||
.catch((error: unknown) => {
|
||||
console.warn('Google Maps could not be loaded; maps stay unavailable:', error);
|
||||
})
|
||||
),
|
||||
|
||||
// Loads feature toggle definitions and stores them for the app.
|
||||
FeatureService,
|
||||
|
||||
+192
-118
@@ -1,134 +1,208 @@
|
||||
@if (form) {
|
||||
<form [formGroup]="form">
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<div class="row align-items-center">
|
||||
<div class="col-6">
|
||||
<h4>
|
||||
<ngx-header-title>
|
||||
<span class="menu-setting"
|
||||
>{{ 'MENU.SETTINGS' | translate }}/
|
||||
</span>
|
||||
{{ 'ACCOUNTING_TEMPLATES_PAGE.HEADER' | translate }}
|
||||
</ngx-header-title>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<form class="main-form" [formGroup]="form">
|
||||
<nb-card class="accounting-templates-card">
|
||||
<nb-card-header class="page-header">
|
||||
<h4>
|
||||
<ngx-header-title>
|
||||
<span class="menu-setting">
|
||||
{{ 'MENU.SETTINGS' | translate }}/
|
||||
</span>
|
||||
{{ 'ACCOUNTING_TEMPLATES_PAGE.HEADER' | translate }}
|
||||
</ngx-header-title>
|
||||
</h4>
|
||||
</nb-card-header>
|
||||
|
||||
<nb-card-body>
|
||||
<div class="row">
|
||||
<div class="col-6 email-template-column">
|
||||
<nb-card>
|
||||
<nb-card-header class="editor-header">
|
||||
<!-- ── Template picker ─────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-field language">
|
||||
<label class="toolbar-label" for="languageCode">
|
||||
{{ 'FORM.LABELS.LANGUAGE' | translate }}
|
||||
</label>
|
||||
<ngx-language-selector
|
||||
class="toolbar-control"
|
||||
size="small"
|
||||
template="ng-select"
|
||||
formControlName="languageCode"
|
||||
(selectedLanguageEvent)="subject$.next(true)"
|
||||
></ngx-language-selector>
|
||||
</div>
|
||||
<div class="toolbar-field template">
|
||||
<label class="toolbar-label" for="templateName">
|
||||
{{ 'FORM.LABELS.TEMPLATE_NAME' | translate }}
|
||||
</label>
|
||||
<nb-select
|
||||
id="templateName"
|
||||
class="toolbar-control"
|
||||
size="small"
|
||||
fullWidth
|
||||
formControlName="templateType"
|
||||
[placeholder]="'FORM.LABELS.TEMPLATE_NAME' | translate"
|
||||
(selectedChange)="onTemplatePicked()"
|
||||
>
|
||||
@for (name of templateTypes; track name) {
|
||||
<nb-option [value]="name">
|
||||
{{ 'ACCOUNTING_TEMPLATES_PAGE.TEMPLATE_NAMES.' + name | translate }}
|
||||
</nb-option>
|
||||
}
|
||||
</nb-select>
|
||||
</div>
|
||||
<fieldset class="view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle-option"
|
||||
[class.active]="viewMode === 'grid'"
|
||||
[attr.aria-pressed]="viewMode === 'grid'"
|
||||
(click)="setViewMode('grid')"
|
||||
>
|
||||
<nb-icon icon="grid-outline"></nb-icon>
|
||||
{{ 'ACCOUNTING_TEMPLATES_PAGE.VIEW_GRID' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle-option"
|
||||
[class.active]="viewMode === 'editor'"
|
||||
[attr.aria-pressed]="viewMode === 'editor'"
|
||||
(click)="setViewMode('editor')"
|
||||
>
|
||||
<nb-icon icon="code-outline"></nb-icon>
|
||||
{{ 'ACCOUNTING_TEMPLATES_PAGE.VIEW_EDITOR' | translate }}
|
||||
</button>
|
||||
</fieldset>
|
||||
<button
|
||||
type="button"
|
||||
class="save-button"
|
||||
[hidden]="viewMode === 'grid'"
|
||||
nbButton
|
||||
size="small"
|
||||
status="success"
|
||||
[disabled]="saving"
|
||||
(click)="onSave()"
|
||||
>
|
||||
<nb-icon icon="save-outline"></nb-icon>
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.SAVE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Template grid ───────────────────────────────────────────── -->
|
||||
@if (viewMode === 'grid') {
|
||||
<div class="template-grid">
|
||||
@for (card of gridCards; track card.name) {
|
||||
<button
|
||||
type="button"
|
||||
class="template-card"
|
||||
[class.active]="card.name === form.get('templateType').value"
|
||||
(click)="openTemplate(card.name)"
|
||||
>
|
||||
<div class="template-thumb">
|
||||
@if (card.html) {
|
||||
<iframe
|
||||
class="template-thumb-frame"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
[gaSandboxedSrcdoc]="card.html"
|
||||
></iframe>
|
||||
} @else {
|
||||
<div class="template-thumb-placeholder" [class.failed]="card.failed" [class.empty]="card.empty">
|
||||
<nb-icon
|
||||
[icon]="card.failed ? 'alert-circle-outline' : 'file-text-outline'"
|
||||
></nb-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="template-meta">
|
||||
<span class="template-name">
|
||||
{{ 'ACCOUNTING_TEMPLATES_PAGE.TEMPLATE_NAMES.' + card.name | translate }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="workspace" [hidden]="viewMode === 'grid'">
|
||||
<!-- ── Editor ──────────────────────────────────────────────── -->
|
||||
<section class="pane editor-pane">
|
||||
<header class="pane-header">
|
||||
<nb-icon icon="code-outline"></nb-icon>
|
||||
<span class="pane-title">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.HTML_EDITOR' | translate }}
|
||||
</nb-card-header>
|
||||
<nb-card-body class="editor">
|
||||
<div class="form-group">
|
||||
<label class="label" for="template">
|
||||
{{
|
||||
'FORM.LABELS.TEMPLATE_BODY' | translate
|
||||
}}
|
||||
</label>
|
||||
</span>
|
||||
</header>
|
||||
<div class="pane-body">
|
||||
<div class="field body-field">
|
||||
<label class="field-label" for="template">
|
||||
{{ 'FORM.LABELS.TEMPLATE_BODY' | translate }}
|
||||
</label>
|
||||
<div class="code-frame">
|
||||
<ace-editor
|
||||
#templateEditor
|
||||
id="template"
|
||||
class="ace-editor body-editor"
|
||||
[mode]="'handlebars'"
|
||||
class="ace-editor"
|
||||
[durationBeforeCallback]="400"
|
||||
(textChange)="onTemplateChange($event)"
|
||||
>
|
||||
</ace-editor>
|
||||
></ace-editor>
|
||||
</div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
</div>
|
||||
<div class="col-6 email-template-column">
|
||||
<nb-card class="email-template-right">
|
||||
<nb-card-header class="email-column-header">
|
||||
<div class="select">
|
||||
<div class="select-block">
|
||||
<label class="label" for="languageCode">
|
||||
{{ 'FORM.LABELS.LANGUAGE' | translate }}
|
||||
</label>
|
||||
<ngx-language-selector
|
||||
class="d-block"
|
||||
size="small"
|
||||
[template]="'ng-select'"
|
||||
formControlName="languageCode"
|
||||
(selectedLanguageEvent)="
|
||||
subject$.next(true)
|
||||
"
|
||||
>
|
||||
</ngx-language-selector>
|
||||
</div>
|
||||
<div class="select-block">
|
||||
<label class="label" for="templateName">
|
||||
{{
|
||||
'FORM.PLACEHOLDERS.TEMPLATES'
|
||||
| translate
|
||||
}}
|
||||
</label>
|
||||
<nb-select
|
||||
id="templateName"
|
||||
class="d-block"
|
||||
size="small"
|
||||
placeholder="{{
|
||||
'FORM.LABELS.TEMPLATE_NAME'
|
||||
| translate
|
||||
}}"
|
||||
formControlName="templateType"
|
||||
(selectedChange)="subject$.next(true)"
|
||||
>
|
||||
@for (name of templateTypes; track name) {
|
||||
<nb-option
|
||||
[value]="name"
|
||||
>
|
||||
{{
|
||||
'ACCOUNTING_TEMPLATES_PAGE.TEMPLATE_NAMES.' +
|
||||
name | translate
|
||||
}}
|
||||
</nb-option>
|
||||
}
|
||||
</nb-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-save">
|
||||
<button
|
||||
nbButton
|
||||
status="success"
|
||||
(click)="onSave()"
|
||||
>
|
||||
{{
|
||||
'EMAIL_TEMPLATES_PAGE.SAVE' | translate
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<div class="email-column-sub-header">
|
||||
<div class="form-group preview-subject">
|
||||
<label class="label" for="previewTemplate">
|
||||
{{
|
||||
'FORM.LABELS.TEMPLATE_PREVIEW'
|
||||
| translate
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="custom-parent-email-content">
|
||||
<div
|
||||
id="previewTemplate"
|
||||
[innerHtml]="previewTemplate"
|
||||
class="custom-preview-template"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Live preview ────────────────────────────────────────── -->
|
||||
<section class="pane preview-pane">
|
||||
<header class="pane-header">
|
||||
<nb-icon icon="eye-outline"></nb-icon>
|
||||
<label class="pane-title" for="previewTemplate">
|
||||
{{ 'FORM.LABELS.TEMPLATE_PREVIEW' | translate }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="full-preview-button"
|
||||
nbButton
|
||||
ghost
|
||||
size="tiny"
|
||||
status="basic"
|
||||
[disabled]="!previewTemplate"
|
||||
[title]="'ACCOUNTING_TEMPLATES_PAGE.FULL_PREVIEW' | translate"
|
||||
(click)="openFullPreview(fullPreviewDialog)"
|
||||
>
|
||||
<nb-icon icon="expand-outline"></nb-icon>
|
||||
{{ 'ACCOUNTING_TEMPLATES_PAGE.FULL_PREVIEW' | translate }}
|
||||
</button>
|
||||
</header>
|
||||
<div class="pane-body">
|
||||
<div class="preview-canvas">
|
||||
<div id="previewTemplate" class="template-paper" [innerHtml]="previewTemplate"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- ── Full-size preview popup ──────────────────────────────────────── -->
|
||||
<ng-template #fullPreviewDialog let-ref="dialogRef">
|
||||
<nb-card class="full-preview-card">
|
||||
<nb-card-header class="full-preview-header">
|
||||
<span class="full-preview-title">
|
||||
{{ 'ACCOUNTING_TEMPLATES_PAGE.TEMPLATE_NAMES.' + form.get('templateType').value | translate }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
nbButton
|
||||
ghost
|
||||
size="small"
|
||||
status="basic"
|
||||
[title]="'BUTTONS.CLOSE' | translate"
|
||||
(click)="ref.close()"
|
||||
>
|
||||
<nb-icon icon="close-outline"></nb-icon>
|
||||
</button>
|
||||
</nb-card-header>
|
||||
<nb-card-body class="full-preview-body">
|
||||
<div class="template-paper full-preview-paper" [innerHtml]="previewTemplate"></div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
</ng-template>
|
||||
|
||||
+758
-66
@@ -1,78 +1,770 @@
|
||||
// The email-templates sheet `@use`s the theme module, which keeps it private to
|
||||
// that file — `@use`ing that sheet here re-emits its CSS but does NOT bring
|
||||
// `nb-theme` into this file's scope. Sass passes an unknown function through as
|
||||
// plain CSS, so the two rules at the bottom of this file shipped as the literal
|
||||
// text `nb-theme(...)` and the browser dropped them: the language/template
|
||||
// selects lost their background, and — the visible one — the rendered template
|
||||
// preview never had `color: text-basic-color` applied, so it kept the seeded
|
||||
// HTML's own near-black text on the dark themes' dark panel. Hence `@use
|
||||
// 'themes'` as well; the email-templates `@use` is here purely for its CSS (it
|
||||
// exports no Sass members) and must stay.
|
||||
@use 'themes' as *;
|
||||
@use '../email-templates/email-templates.component' as *;
|
||||
@use 'gauzy/_gauzy-overrides' as *;
|
||||
@use 'sass:map';
|
||||
|
||||
// This page carried two `!important` height overrides — `calc($height + 3.5rem)`
|
||||
// on the Ace editor and `calc($height + 1.3rem)` on the preview box — which
|
||||
// added even more to a `100vh` constant that already ignored the footer, which
|
||||
// is why the overlap showed up worst here. Both are gone: email-templates.scss
|
||||
// now sizes the two panels from the content column via flex, and that shared
|
||||
// chain also covers this page's one-editor layout.
|
||||
/*
|
||||
* Settings → Accounting Templates.
|
||||
*
|
||||
* Same layout as Settings → Email Templates: the language / template picker on top,
|
||||
* the Handlebars source on the start side and the rendered invoice, estimate or
|
||||
* receipt on the end side, plus a grid of all three. 12px body, 500 titles / 400
|
||||
* everything else, gauzy-* surface tokens, hairline insets, no shadows.
|
||||
*
|
||||
* Self-contained: this page used to `@use` the Email Templates sheet for its layout,
|
||||
* so restyling that page also changed this one.
|
||||
*/
|
||||
|
||||
$hairline: var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
$radius: var(--gauzy-radius-sm, 0.5rem);
|
||||
$control-height: 2rem;
|
||||
|
||||
// Same frame as Email History: the card fills the page height and each pane scrolls
|
||||
// inside it, so no scrollbar lands on the page itself. A definite height all the way
|
||||
// down is also what Ace needs — its DOM is absolutely positioned, so an editor in an
|
||||
// auto-height box measures 0 tall.
|
||||
//
|
||||
// The height comes from the parent, not the viewport: the page renders inside the
|
||||
// settings shell's `.settings-content` (`height: 100%`), so `height: 100%` on the host
|
||||
// and the form hands the card exactly the content area. A `100vh - header - footer`
|
||||
// guess also had to count the layout column's padding, and was taller than the area
|
||||
// whenever it missed some — leaving a page scrollbar next to the pane scrollbars.
|
||||
:host {
|
||||
// A settings page CANNOT set its own `display`. `pages.component.scss` carries a
|
||||
// deep-scoped rule on the adjacent sibling of a router-outlet, forcing it to
|
||||
// display block and giving it the page fade animation. Being deep-scoped, that
|
||||
// rule leaks into NESTED outlets, so it also matches this page's
|
||||
// host inside the settings shell. Its specificity is (0,1,1) —
|
||||
// `[_nghost-pages] router-outlet + *` — against (0,1,0) for a component's own
|
||||
// `:host`, so `display: flex` here loses. Confirmed with CDP
|
||||
// `CSS.getMatchedStylesForNode`: both `:host` rules declare `display: flex`
|
||||
// and the winning declaration is `display: block` from that shared rule.
|
||||
// (Only `display` was overridden, which is why `flex-direction`/`min-height`
|
||||
// from the same block appeared to work and made this look like a build issue.)
|
||||
//
|
||||
// That rule is deliberately left alone: nested routed hosts are unknown
|
||||
// elements and default to `display: inline`, so dropping it would risk every
|
||||
// nested-router page in the app.
|
||||
//
|
||||
// So instead of fighting for the host's display, give it a definite height and
|
||||
// let the page's ROOT ELEMENT carry the frame (see `:host > form` below). A
|
||||
// block box with a definite height resolves a percentage height on its child
|
||||
// perfectly well — the flex container simply starts one level lower.
|
||||
//
|
||||
// This component declares no `selector`, so its host renders as `<ng-component>` —
|
||||
// worth knowing when reading a DOM dump and looking for a page-named element.
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.ng-select .ng-select-container {
|
||||
background-color: nb-theme(select-outline-basic-background-color);
|
||||
.main-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.accounting-templates-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
background: var(--gauzy-card-2, var(--background-basic-color-2));
|
||||
}
|
||||
|
||||
.page-header {
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Sits at the end of the picker row; wraps under the pickers on a narrow card.
|
||||
.save-button {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
// "Settings/" ahead of the page name, inside `ngx-header-title` — it inherits
|
||||
// that component's size, and only the weight makes it recede.
|
||||
.menu-setting {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
nb-card-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
background: var(--gauzy-card-2, var(--background-basic-color-2));
|
||||
font-size: 0.75rem;
|
||||
// The panes stack on the card's own width, not the window's: the sidebar and the
|
||||
// AI assistant panel can leave the card narrow on a wide screen.
|
||||
container: accounting-templates / inline-size;
|
||||
}
|
||||
|
||||
// ── Template picker ─────────────────────────────────────────────────────
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.toolbar-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
&.language .toolbar-control {
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
&.template .toolbar-control {
|
||||
width: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
|
||||
.toolbar-control {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// Both pickers share one control recipe with the Email History filters: 2rem tall,
|
||||
// recessed surface, hairline instead of a border. ng-select ships its own white theme,
|
||||
// loaded after the app styles, so it needs the host for one step more specificity.
|
||||
:host ::ng-deep {
|
||||
@include nb-select-overrides($control-height, $radius, none);
|
||||
@include ng-select-overrides($control-height, $radius, none);
|
||||
|
||||
.toolbar .ng-select {
|
||||
font-size: 0.75rem;
|
||||
|
||||
.ng-select-container {
|
||||
min-height: $control-height;
|
||||
height: $control-height;
|
||||
border: 0;
|
||||
border-radius: $radius;
|
||||
background-color: var(--background-basic-color-3);
|
||||
box-shadow: inset 0 0 0 1px $hairline;
|
||||
color: var(--text-basic-color);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--border-basic-color-5, $hairline);
|
||||
}
|
||||
}
|
||||
|
||||
&.ng-select-focused .ng-select-container,
|
||||
&.ng-select-opened > .ng-select-container {
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary-default) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// The frame the host cannot carry. A deliberate LOCAL RESTATEMENT: the shared
|
||||
// sheet pulled in by the `@use` above now declares the same height on its own
|
||||
// `> form`, so this is redundant today — it is kept so this page keeps a
|
||||
// definite height even if that shared chain is later re-scoped to selectors
|
||||
// this page's markup does not match. Without a definite height the form sized to
|
||||
// content (991px) inside an 806px host and the excess painted over the footer.
|
||||
// `height: 100%` of a block host with a definite height fixes that, and the
|
||||
// existing `min-height: 0` chain below it then scrolls internally as intended.
|
||||
//
|
||||
// `:host > form` is (0,1,1) but targets a DIFFERENT element than the
|
||||
// `router-outlet + *` rule, so there is no specificity fight to lose here.
|
||||
> form {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.toolbar nb-select .select-button {
|
||||
min-height: $control-height;
|
||||
height: $control-height;
|
||||
padding-block: 0;
|
||||
border: 0;
|
||||
border-radius: $radius;
|
||||
background-color: var(--background-basic-color-3);
|
||||
box-shadow: inset 0 0 0 1px $hairline;
|
||||
color: var(--text-basic-color);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
|
||||
.custom-preview-template {
|
||||
width: 100%;
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--border-basic-color-5, $hairline);
|
||||
}
|
||||
|
||||
.custom-preview-template ::ng-deep {
|
||||
* {
|
||||
color: nb-theme(text-basic-color);
|
||||
&:focus {
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary-default);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── View toggle ─────────────────────────────────────────────────────────
|
||||
// Segmented Grid | Editor switch, same 2rem recessed surface as the pickers.
|
||||
.view-toggle {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
height: $control-height;
|
||||
margin: 0;
|
||||
padding: 0.125rem;
|
||||
border: 0;
|
||||
border-radius: $radius;
|
||||
background-color: var(--background-basic-color-3);
|
||||
box-shadow: inset 0 0 0 1px $hairline;
|
||||
}
|
||||
|
||||
.view-toggle-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0 0.625rem;
|
||||
border: 0;
|
||||
border-radius: calc(#{$radius} - 0.125rem);
|
||||
background: transparent;
|
||||
color: var(--text-hint-color);
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
|
||||
nb-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-basic-color);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary-default);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--gauzy-card-1, var(--background-basic-color-1));
|
||||
box-shadow: inset 0 0 0 1px $hairline;
|
||||
color: var(--text-basic-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// `display` on these classes would otherwise beat the `hidden` attribute.
|
||||
.workspace[hidden],
|
||||
.save-button[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// ── Template grid ───────────────────────────────────────────────────────
|
||||
.template-grid {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||
grid-auto-rows: max-content;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: $radius;
|
||||
background: var(--gauzy-card-1, var(--background-basic-color-1));
|
||||
box-shadow: inset 0 0 0 1px $hairline;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--border-basic-color-5, $hairline);
|
||||
|
||||
.template-meta {
|
||||
background: var(--gauzy-hover-tint, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible,
|
||||
&.active {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary-default);
|
||||
}
|
||||
}
|
||||
|
||||
// A 640px-wide render of the template shrunk to fit, top-aligned so the header of
|
||||
// the document shows. Inset by 1px so the card's hairline stays visible around it.
|
||||
$thumb-width: 640px;
|
||||
$thumb-scale: 0.34;
|
||||
|
||||
.template-thumb {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
height: 11rem;
|
||||
margin: 1px 1px 0;
|
||||
overflow: hidden;
|
||||
border-radius: calc(#{$radius} - 1px) calc(#{$radius} - 1px) 0 0;
|
||||
// Same surface the themed thumbnail document paints, so no white edge shows.
|
||||
background: var(--gauzy-card-1, var(--background-basic-color-1));
|
||||
}
|
||||
|
||||
.template-thumb-frame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: $thumb-width;
|
||||
height: calc(11rem / #{$thumb-scale});
|
||||
margin-left: calc(#{$thumb-width} / -2);
|
||||
border: 0;
|
||||
transform: scale($thumb-scale);
|
||||
transform-origin: top center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.template-thumb-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: var(--background-basic-color-2);
|
||||
color: var(--text-hint-color);
|
||||
|
||||
nb-icon {
|
||||
font-size: 1.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:not(.failed):not(.empty) {
|
||||
animation: template-thumb-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.failed nb-icon {
|
||||
color: var(--color-danger-default);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes template-thumb-pulse {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.template-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem 0.625rem;
|
||||
border-top: 1px solid $hairline;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
overflow: hidden;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-basic-color);
|
||||
}
|
||||
|
||||
|
||||
// ── Workspace ───────────────────────────────────────────────────────────
|
||||
// A pane never gets shorter than $pane-min-height: its header, the field label and
|
||||
// the 10rem minimum of the editor. When the card is too short for that the
|
||||
// workspace scrolls, instead of the editor's frame clipping the bottom of Ace (and
|
||||
// its scrollbar) out of sight.
|
||||
$pane-min-height: 16rem;
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-rows: minmax($pane-min-height, 1fr);
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
// Narrow card: editor on top, preview below, each half the height.
|
||||
@container accounting-templates (max-width: 48rem) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax($pane-min-height, 1fr) minmax($pane-min-height, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: $radius;
|
||||
background: var(--gauzy-card-1, var(--background-basic-color-1));
|
||||
box-shadow: inset 0 0 0 1px $hairline;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid $hairline;
|
||||
|
||||
nb-icon {
|
||||
font-size: 1rem;
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-basic-color);
|
||||
}
|
||||
|
||||
.pane-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-height: 0;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.body-field {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
line-height: 1rem;
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
|
||||
// ── Code editors ────────────────────────────────────────────────────────
|
||||
// The frame owns the border and the corner clipping; the active Ace syntax theme
|
||||
// (`sqlserver` light / `tomorrow_night` dark, picked in the component) paints inside it.
|
||||
.code-frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border-radius: $radius;
|
||||
box-shadow: inset 0 0 0 1px $hairline;
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary-default);
|
||||
}
|
||||
|
||||
.body-field & {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.ace-editor {
|
||||
display: block;
|
||||
// Inset by the frame's hairline so the theme's background does not paint over it.
|
||||
margin: 1px;
|
||||
width: calc(100% - 2px);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
|
||||
// Takes what the frame leaves; Ace scrolls the document inside itself.
|
||||
.body-editor {
|
||||
flex: 1 1 0;
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
// ── Editor palette ──────────────────────────────────────────────────────
|
||||
// Our own syntax colours on top of whichever Ace theme the component loads: GitHub's
|
||||
// light and dark palettes, tuned for MJML + Handlebars. Tags, attributes and values
|
||||
// read as three clear layers, and every `{{ variable }}` sits on a soft pill so the
|
||||
// dynamic parts of an email stand out from the markup. Colours only — no padding or
|
||||
// weight changes, which would break Ace's character-width measurement.
|
||||
//
|
||||
// The palette is chosen by the Ace theme class (`ace-sqlserver` light /
|
||||
// `ace-tomorrow-night` dark), which the component already sets from the app theme.
|
||||
@mixin editor-palette($p) {
|
||||
background: var(--gauzy-card-1, var(--background-basic-color-1));
|
||||
color: map.get($p, text);
|
||||
|
||||
::ng-deep {
|
||||
.ace_scroller,
|
||||
.ace_content {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ace_cursor {
|
||||
color: map.get($p, cursor);
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
.ace_marker-layer {
|
||||
.ace_active-line {
|
||||
background: map.get($p, active-line);
|
||||
}
|
||||
|
||||
.ace_selection {
|
||||
background: map.get($p, selection);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ace_selected-word {
|
||||
background: map.get($p, selected-word);
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ace_bracket {
|
||||
margin: 0;
|
||||
border: 1px solid map.get($p, bracket);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ace_gutter {
|
||||
background: transparent;
|
||||
box-shadow: inset -1px 0 0 $hairline;
|
||||
color: map.get($p, gutter);
|
||||
}
|
||||
|
||||
.ace_gutter-active-line {
|
||||
background: map.get($p, active-line);
|
||||
color: map.get($p, gutter-active);
|
||||
}
|
||||
|
||||
.ace_indent-guide {
|
||||
background: none;
|
||||
box-shadow: inset 1px 0 0 map.get($p, indent-guide);
|
||||
}
|
||||
|
||||
.ace_invisible {
|
||||
color: map.get($p, gutter);
|
||||
}
|
||||
|
||||
// HTML / MJML
|
||||
.ace_meta.ace_tag {
|
||||
color: map.get($p, punctuation);
|
||||
}
|
||||
|
||||
.ace_meta.ace_tag.ace_tag-name {
|
||||
color: map.get($p, tag);
|
||||
}
|
||||
|
||||
.ace_entity.ace_other.ace_attribute-name {
|
||||
color: map.get($p, attribute);
|
||||
}
|
||||
|
||||
.ace_keyword.ace_operator.ace_attribute-equals {
|
||||
color: map.get($p, punctuation);
|
||||
}
|
||||
|
||||
.ace_string,
|
||||
.ace_string.ace_attribute-value {
|
||||
color: map.get($p, string);
|
||||
}
|
||||
|
||||
.ace_constant.ace_numeric {
|
||||
color: map.get($p, number);
|
||||
}
|
||||
|
||||
.ace_constant.ace_language.ace_escape {
|
||||
color: map.get($p, escape);
|
||||
}
|
||||
|
||||
.ace_comment {
|
||||
color: map.get($p, comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Handlebars: `{{`, `{{#`, `{{/`, `}}` and the variable between them form one pill.
|
||||
.ace_storage.ace_type,
|
||||
.ace_support.ace_function,
|
||||
.ace_variable.ace_parameter {
|
||||
background: map.get($p, hbs-bg);
|
||||
}
|
||||
|
||||
.ace_storage.ace_type,
|
||||
.ace_support.ace_function {
|
||||
color: map.get($p, hbs-brace);
|
||||
}
|
||||
|
||||
.ace_variable.ace_parameter {
|
||||
color: map.get($p, hbs-variable);
|
||||
}
|
||||
|
||||
.ace_storage.ace_type.ace_start {
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
|
||||
.ace_storage.ace_type.ace_end {
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
// Thin, theme-coloured scrollbars.
|
||||
.ace_scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: map.get($p, scrollbar) transparent;
|
||||
}
|
||||
|
||||
.ace_scrollbar-h {
|
||||
height: 8px !important;
|
||||
}
|
||||
|
||||
.ace_scrollbar-v {
|
||||
width: 8px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$editor-light: (
|
||||
text: #1f2328,
|
||||
cursor: #0969da,
|
||||
active-line: rgba(9, 105, 218, 0.05),
|
||||
selection: rgba(9, 105, 218, 0.18),
|
||||
selected-word: rgba(9, 105, 218, 0.1),
|
||||
bracket: rgba(9, 105, 218, 0.45),
|
||||
gutter: #8c959f,
|
||||
gutter-active: #1f2328,
|
||||
indent-guide: rgba(31, 35, 40, 0.08),
|
||||
punctuation: #6e7781,
|
||||
tag: #116329,
|
||||
attribute: #0550ae,
|
||||
string: #0a3069,
|
||||
number: #0550ae,
|
||||
escape: #8250df,
|
||||
comment: #6e7781,
|
||||
hbs-bg: rgba(130, 80, 223, 0.1),
|
||||
hbs-brace: #8250df,
|
||||
hbs-variable: #953800,
|
||||
scrollbar: rgba(31, 35, 40, 0.25)
|
||||
);
|
||||
|
||||
$editor-dark: (
|
||||
text: #e6edf3,
|
||||
cursor: #58a6ff,
|
||||
active-line: rgba(110, 118, 129, 0.12),
|
||||
selection: rgba(56, 139, 253, 0.3),
|
||||
selected-word: rgba(56, 139, 253, 0.16),
|
||||
bracket: rgba(88, 166, 255, 0.55),
|
||||
gutter: #6e7681,
|
||||
gutter-active: #e6edf3,
|
||||
indent-guide: rgba(240, 246, 252, 0.08),
|
||||
punctuation: #8b949e,
|
||||
tag: #7ee787,
|
||||
attribute: #79c0ff,
|
||||
string: #a5d6ff,
|
||||
number: #79c0ff,
|
||||
escape: #d2a8ff,
|
||||
comment: #8b949e,
|
||||
hbs-bg: rgba(210, 168, 255, 0.12),
|
||||
hbs-brace: #d2a8ff,
|
||||
hbs-variable: #ffa657,
|
||||
scrollbar: rgba(240, 246, 252, 0.2)
|
||||
);
|
||||
|
||||
.ace-editor.ace-sqlserver {
|
||||
@include editor-palette($editor-light);
|
||||
}
|
||||
|
||||
.ace-editor.ace-tomorrow-night {
|
||||
@include editor-palette($editor-dark);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.preview-canvas {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
border-radius: $radius;
|
||||
background: var(--gauzy-card-2, var(--background-basic-color-2));
|
||||
box-shadow: inset 0 0 0 1px $hairline;
|
||||
}
|
||||
|
||||
// The rendered invoice / estimate / receipt, on a sheet that follows the app theme:
|
||||
// the card surface and theme text colour in light mode and in dark mode.
|
||||
.template-paper {
|
||||
max-width: 42rem;
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
overflow-x: auto;
|
||||
border-radius: $radius;
|
||||
background: var(--gauzy-card-1, var(--background-basic-color-1));
|
||||
box-shadow: inset 0 0 0 1px $hairline;
|
||||
color: var(--text-basic-color);
|
||||
font-size: 0.875rem;
|
||||
|
||||
// The global theme paints every `tr` with the card colour (`!important`); keep the
|
||||
// sheet's own surface showing through the template's layout tables instead.
|
||||
::ng-deep tr {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
// Follow the app theme. MJML writes its default `color:#000000` inline on every text
|
||||
// block, so only `!important` lets the theme text colour through — without it the
|
||||
// invoice stays black on the dark sheet. Only that default is replaced: a colour the
|
||||
// user writes into the template is kept, as it will appear in the sent document. The
|
||||
// leading `;` keeps `background-color:#000000` out. Links without their own colour
|
||||
// take the theme's link colour.
|
||||
::ng-deep [style^='color:#000000'],
|
||||
::ng-deep [style*=';color:#000000'] {
|
||||
color: var(--text-basic-color) !important;
|
||||
}
|
||||
|
||||
::ng-deep a:not([style*='color']) {
|
||||
color: var(--text-primary-color, var(--color-primary-default)) !important;
|
||||
}
|
||||
|
||||
// The seeded templates rule their item rows with a light grey (#ecedee) that glares
|
||||
// on the dark sheet; draw any inline border in the theme's hairline instead.
|
||||
::ng-deep [style*='border'] {
|
||||
border-color: $hairline !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full-size preview popup ─────────────────────────────────────────────
|
||||
.full-preview-button {
|
||||
margin-inline-start: auto;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
|
||||
nb-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Declared in this component's template, so the encapsulated styles still reach
|
||||
// it inside the dialog overlay.
|
||||
.full-preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(56rem, calc(100vw - 2rem));
|
||||
height: calc(100vh - 4rem);
|
||||
margin: 0;
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.full-preview-header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
.full-preview-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-basic-color);
|
||||
}
|
||||
|
||||
|
||||
// Block, not the component-wide flex column on `nb-card-body`: a long document would
|
||||
// otherwise shrink inside it and scroll in a second, nested scrollbar.
|
||||
.full-preview-body {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
background: var(--gauzy-card-2, var(--background-basic-color-2));
|
||||
}
|
||||
|
||||
.full-preview-paper {
|
||||
max-width: 48rem;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, inject, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { debounceTime, filter, tap } from 'rxjs/operators';
|
||||
import { combineLatest, Subject } from 'rxjs';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { AceEditorComponent } from 'ngx-ace-editor-wrapper';
|
||||
import { NbThemeService } from '@nebular/theme';
|
||||
import { NbDialogService, NbThemeService } from '@nebular/theme';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
// Brace ships modes and themes as separate side-effect modules. This page is its own lazy
|
||||
// chunk, so it cannot rely on the email-templates page having pulled them in — without
|
||||
// these the editor silently falls back to plain text on the light default theme.
|
||||
@@ -16,7 +17,7 @@ import 'brace/theme/sqlserver';
|
||||
import 'brace/theme/tomorrow_night';
|
||||
import { AccountingTemplateTypeEnum, IOrganization, LanguagesEnum } from '@gauzy/contracts';
|
||||
import { distinctUntilChange } from '@gauzy/ui-core/common';
|
||||
import { AccountingTemplateService, Store } from '@gauzy/ui-core/core';
|
||||
import { AccountingTemplateService, Store, ToastrService } from '@gauzy/ui-core/core';
|
||||
|
||||
/**
|
||||
* Themes whose canvas is dark, so the Ace editor has to load a dark syntax theme.
|
||||
@@ -29,11 +30,31 @@ import { AccountingTemplateService, Store } from '@gauzy/ui-core/core';
|
||||
*/
|
||||
const DARK_CANVAS_THEMES: ReadonlySet<string> = new Set(['dark', 'cosmic', 'gauzy-dark', 'material-dark']);
|
||||
|
||||
export type AccountingTemplatesViewMode = 'grid' | 'editor';
|
||||
|
||||
/** One tile of the template grid: a thumbnail of the rendered template. */
|
||||
export interface AccountingTemplateCard {
|
||||
name: AccountingTemplateTypeEnum;
|
||||
/** Rendered template as returned by the API. */
|
||||
source: string | null;
|
||||
/**
|
||||
* `source` plus the current theme's colours; only ever written into a sandboxed
|
||||
* iframe by `SandboxedSrcdocDirective`.
|
||||
*/
|
||||
html: string | null;
|
||||
/** The organization has no template of this type in this language. */
|
||||
empty: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
@UntilDestroy({ checkProperties: true })
|
||||
@Component({
|
||||
templateUrl: './accounting-templates.component.html',
|
||||
styleUrls: ['./accounting-templates.component.scss'],
|
||||
standalone: false
|
||||
standalone: false,
|
||||
// Asks the settings shell for a definite height (see settings.component.scss), so
|
||||
// the card fills the content area and the panes scroll instead of the page.
|
||||
host: { class: 'settings-page-fill' }
|
||||
})
|
||||
export class AccountingTemplatesComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
previewTemplate: SafeHtml;
|
||||
@@ -41,6 +62,20 @@ export class AccountingTemplatesComponent implements OnInit, AfterViewInit, OnDe
|
||||
templateTypes: string[] = Object.values(AccountingTemplateTypeEnum);
|
||||
organization: IOrganization;
|
||||
subject$: Subject<any> = new Subject();
|
||||
saving = false;
|
||||
|
||||
viewMode: AccountingTemplatesViewMode = 'editor';
|
||||
gridCards: AccountingTemplateCard[] = [];
|
||||
/** Organization + language the grid was last loaded for, so toggling back does not refetch. */
|
||||
private gridKey: string;
|
||||
/** Bumped on every grid load and on destroy; responses from an older load are dropped. */
|
||||
private gridRun = 0;
|
||||
|
||||
private editorResizeObserver: ResizeObserver;
|
||||
|
||||
private readonly dialogService = inject(NbDialogService);
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
|
||||
readonly form: UntypedFormGroup = AccountingTemplatesComponent.buildForm(this.fb);
|
||||
static buildForm(fb: UntypedFormBuilder): UntypedFormGroup {
|
||||
@@ -66,6 +101,11 @@ export class AccountingTemplatesComponent implements OnInit, AfterViewInit, OnDe
|
||||
.pipe(
|
||||
debounceTime(200),
|
||||
tap(async () => await this.getTemplate()),
|
||||
tap(() => {
|
||||
if (this.viewMode === 'grid') {
|
||||
void this.loadGrid();
|
||||
}
|
||||
}),
|
||||
untilDestroyed(this)
|
||||
)
|
||||
.subscribe();
|
||||
@@ -92,6 +132,8 @@ export class AccountingTemplatesComponent implements OnInit, AfterViewInit, OnDe
|
||||
.pipe(untilDestroyed(this))
|
||||
.subscribe(({ name }: { name: string }) => {
|
||||
this.templateEditor.setTheme(DARK_CANVAS_THEMES.has(name) ? 'tomorrow_night' : 'sqlserver');
|
||||
// The theme's CSS variables switch with the body class; read them after it lands.
|
||||
setTimeout(() => this.rethemeThumbnails());
|
||||
});
|
||||
|
||||
const editorOptions = {
|
||||
@@ -103,6 +145,14 @@ export class AccountingTemplatesComponent implements OnInit, AfterViewInit, OnDe
|
||||
};
|
||||
|
||||
this.templateEditor.getEditor().setOptions(editorOptions);
|
||||
|
||||
// Ace sizes its rows from the box it measures, once. The frame settles after that
|
||||
// (fonts, the preview filling in, the grid toggle, window resizes), and Ace kept
|
||||
// drawing a handful of rows at the top of a much taller frame. Re-measure whenever
|
||||
// the box changes size.
|
||||
const editorElement = this.templateEditor.getEditor().container;
|
||||
this.editorResizeObserver = new ResizeObserver(() => this.templateEditor?.getEditor().resize());
|
||||
this.editorResizeObserver.observe(editorElement);
|
||||
}
|
||||
|
||||
async getTemplate() {
|
||||
@@ -153,18 +203,226 @@ export class AccountingTemplatesComponent implements OnInit, AfterViewInit, OnDe
|
||||
}
|
||||
|
||||
async onSave() {
|
||||
if (!this.organization) {
|
||||
if (!this.organization || this.saving) {
|
||||
return;
|
||||
}
|
||||
const { tenantId } = this.store.user;
|
||||
const { id: organizationId } = this.organization;
|
||||
const templateName = this.translateService.instant(
|
||||
'ACCOUNTING_TEMPLATES_PAGE.TEMPLATE_NAMES.' + this.form.get('templateType').value
|
||||
);
|
||||
|
||||
await this.accountingTemplateService.saveTemplate({
|
||||
...this.form.value,
|
||||
organizationId,
|
||||
tenantId
|
||||
});
|
||||
this.saving = true;
|
||||
try {
|
||||
await this.accountingTemplateService.saveTemplate({
|
||||
...this.form.value,
|
||||
organizationId,
|
||||
tenantId
|
||||
});
|
||||
// The saved template's grid thumbnail is now stale. Drop any grid load still in
|
||||
// flight (it may have fetched the template before the save landed) and, if the
|
||||
// grid is open, load it again so the card shows what was saved.
|
||||
this.gridRun++;
|
||||
this.gridKey = null;
|
||||
if (this.viewMode === 'grid') {
|
||||
void this.loadGrid();
|
||||
}
|
||||
this.toastrService.success('ACCOUNTING_TEMPLATES_PAGE.SAVED', { templateName });
|
||||
} catch (error) {
|
||||
this.toastrService.danger(error);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {}
|
||||
setViewMode(mode: AccountingTemplatesViewMode) {
|
||||
if (this.viewMode === mode) {
|
||||
return;
|
||||
}
|
||||
this.viewMode = mode;
|
||||
if (mode === 'grid') {
|
||||
void this.loadGrid();
|
||||
}
|
||||
}
|
||||
|
||||
/** Template picked from the dropdown: show it in the editor. */
|
||||
onTemplatePicked() {
|
||||
this.subject$.next(true);
|
||||
this.setViewMode('editor');
|
||||
}
|
||||
|
||||
/** Grid card clicked: select that template and switch to the editor. */
|
||||
openTemplate(templateType: AccountingTemplateTypeEnum) {
|
||||
if (this.form.get('templateType').value !== templateType) {
|
||||
this.form.patchValue({ templateType });
|
||||
this.subject$.next(true);
|
||||
}
|
||||
this.setViewMode('editor');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every template type for the current organization + language and renders
|
||||
* it, one card at a time. Cards fill in as their previews arrive.
|
||||
*/
|
||||
async loadGrid() {
|
||||
if (!this.organization) {
|
||||
return;
|
||||
}
|
||||
const { tenantId } = this.store.user;
|
||||
const { id: organizationId, name: organizationName } = this.organization;
|
||||
const { languageCode = LanguagesEnum.ENGLISH } = this.form.value;
|
||||
const key = `${organizationId}:${languageCode}`;
|
||||
if (key === this.gridKey) {
|
||||
return;
|
||||
}
|
||||
this.gridKey = key;
|
||||
const run = ++this.gridRun;
|
||||
|
||||
this.gridCards = this.templateTypes.map((name) => ({
|
||||
name: name as AccountingTemplateTypeEnum,
|
||||
source: null,
|
||||
html: null,
|
||||
empty: false,
|
||||
failed: false
|
||||
}));
|
||||
|
||||
for (const card of this.gridCards) {
|
||||
try {
|
||||
const template = await this.accountingTemplateService.getTemplate({
|
||||
languageCode,
|
||||
templateType: card.name,
|
||||
organizationId,
|
||||
tenantId
|
||||
});
|
||||
// The page may have been left, or the grid reloaded, while this was in flight.
|
||||
if (run !== this.gridRun) {
|
||||
return;
|
||||
}
|
||||
if (!template?.mjml) {
|
||||
card.empty = true;
|
||||
continue;
|
||||
}
|
||||
const { html } = await this.accountingTemplateService.generateTemplatePreview({
|
||||
organization: organizationName,
|
||||
data: template.mjml
|
||||
});
|
||||
if (run !== this.gridRun) {
|
||||
return;
|
||||
}
|
||||
const source = await this.inlineAppImages(html);
|
||||
if (run !== this.gridRun) {
|
||||
return;
|
||||
}
|
||||
card.source = source;
|
||||
card.html = this.themeThumbnail(source);
|
||||
} catch {
|
||||
card.failed = true;
|
||||
}
|
||||
}
|
||||
// A failed load may be retried by toggling the grid again.
|
||||
if (run === this.gridRun && this.gridCards.some((card) => card.failed)) {
|
||||
this.gridKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds the template's own images (the organization logo, `assets/images/...`) as
|
||||
* data URLs.
|
||||
*
|
||||
* The sandboxed thumbnail has an opaque origin, so it cannot resolve the relative path,
|
||||
* and even an absolute app URL is a cross-site request from it, which the dev server
|
||||
* answers with 403. Fetching from the page (same origin) and inlining the result keeps
|
||||
* the frame fully sandboxed. Images on other hosts are left as they are; a failed fetch
|
||||
* leaves that image unchanged.
|
||||
*/
|
||||
private async inlineAppImages(html: string): Promise<string> {
|
||||
// Built in one expression and never changed afterwards.
|
||||
const sources = new Set(Array.from(html.matchAll(/<img\b[^>]*?\ssrc="([^"]+)"/gi), (match) => match[1]));
|
||||
let result = html;
|
||||
for (const src of sources) {
|
||||
if (src.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
const url = new URL(src, document.baseURI);
|
||||
if (url.origin !== location.origin) {
|
||||
continue;
|
||||
}
|
||||
const dataUrl = await this.toDataUrl(url.href);
|
||||
if (dataUrl) {
|
||||
result = result.split(`src="${src}"`).join(`src="${dataUrl}"`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Cached per URL: the three templates share one logo. */
|
||||
private readonly imageCache = new Map<string, Promise<string | null>>();
|
||||
|
||||
private toDataUrl(href: string): Promise<string | null> {
|
||||
if (!this.imageCache.has(href)) {
|
||||
const load = fetch(href)
|
||||
.then((response) => (response.ok ? response.blob() : null))
|
||||
.then(
|
||||
(blob) =>
|
||||
blob &&
|
||||
new Promise<string | null>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null);
|
||||
reader.onerror = () => resolve(null);
|
||||
reader.readAsDataURL(blob);
|
||||
})
|
||||
)
|
||||
.catch(() => null);
|
||||
this.imageCache.set(href, load);
|
||||
}
|
||||
return this.imageCache.get(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives a grid thumbnail the same light / dark look as the Template Preview.
|
||||
*
|
||||
* The thumbnail lives in a sandboxed iframe, out of reach of the page's styles, so the
|
||||
* theme's colours are resolved here and written into the document as a style block.
|
||||
* `!important` beats the default `color:#000000` MJML writes inline on every text block;
|
||||
* colours the user wrote into the template are left alone, as in the Template Preview.
|
||||
*/
|
||||
private themeThumbnail(source: string): string {
|
||||
const styles = getComputedStyle(document.body);
|
||||
// Theme values only; anything that could close the rule or the tag is dropped.
|
||||
const token = (name: string, fallback: string) =>
|
||||
(styles.getPropertyValue(name).trim() || fallback).replace(/[^#\w\s(),.%-]/g, '');
|
||||
const surface = token('--gauzy-card-1', token('--background-basic-color-1', '#ffffff'));
|
||||
const text = token('--text-basic-color', '#222b45');
|
||||
const link = token('--text-primary-color', text);
|
||||
const hairline = token('--gauzy-border-default-color', token('--border-basic-color-3', '#e4e9f2'));
|
||||
const style =
|
||||
`<style>html,body{background:${surface} !important;color:${text};}` +
|
||||
`[style^="color:#000000"],[style*=";color:#000000"]{color:${text} !important;}` +
|
||||
`a:not([style*="color"]){color:${link} !important;}` +
|
||||
`[style*="border"]{border-color:${hairline} !important;}</style>`;
|
||||
return source.includes('</head>') ? source.replace('</head>', style + '</head>') : style + source;
|
||||
}
|
||||
|
||||
/** Re-applies the current theme to the thumbnails already rendered. */
|
||||
private rethemeThumbnails() {
|
||||
for (const card of this.gridCards) {
|
||||
if (card.source) {
|
||||
card.html = this.themeThumbnail(card.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the rendered template at full size, so a long invoice can be read without
|
||||
* scrolling inside the side-by-side preview pane.
|
||||
*/
|
||||
openFullPreview(dialog: TemplateRef<unknown>) {
|
||||
this.dialogService.open(dialog, { closeOnBackdropClick: true, hasScroll: false });
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
// Stops a grid load still running: it checks the run between requests.
|
||||
this.gridRun++;
|
||||
this.editorResizeObserver?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { NbLayoutModule, NbCardModule, NbSelectModule, NbButtonModule } from '@nebular/theme';
|
||||
import {
|
||||
NbLayoutModule,
|
||||
NbCardModule,
|
||||
NbSelectModule,
|
||||
NbButtonModule,
|
||||
NbIconModule,
|
||||
NbDialogModule
|
||||
} from '@nebular/theme';
|
||||
import { AceEditorModule } from 'ngx-ace-editor-wrapper';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { LanguageSelectorModule, SharedModule } from '@gauzy/ui-core/shared';
|
||||
import { AccountingTemplatesRoutingModule } from './accounting-templates-routing.module';
|
||||
import { AccountingTemplatesComponent } from './accounting-templates.component';
|
||||
import { SandboxedSrcdocDirective } from './sandboxed-srcdoc.directive';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -12,13 +20,15 @@ import { AccountingTemplatesComponent } from './accounting-templates.component';
|
||||
NbCardModule,
|
||||
NbLayoutModule,
|
||||
NbSelectModule,
|
||||
NbIconModule,
|
||||
NbDialogModule.forChild(),
|
||||
AceEditorModule,
|
||||
TranslateModule.forChild(),
|
||||
SharedModule,
|
||||
LanguageSelectorModule,
|
||||
AccountingTemplatesRoutingModule
|
||||
],
|
||||
declarations: [AccountingTemplatesComponent],
|
||||
declarations: [AccountingTemplatesComponent, SandboxedSrcdocDirective],
|
||||
providers: []
|
||||
})
|
||||
export class AccountingTemplatesModule {}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Directive, ElementRef, inject, Input, OnInit, Renderer2 } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Renders a full HTML document (a rendered email) inside an `<iframe>` without turning
|
||||
* off Angular's sanitizer.
|
||||
*
|
||||
* Binding `[srcdoc]` would need `bypassSecurityTrustHtml`, and Angular's sanitizer would
|
||||
* otherwise strip the email's `<head>` / `<style>` blocks that the layout depends on.
|
||||
* Instead the frame is locked down first — an empty `sandbox` gives it an opaque origin
|
||||
* with scripts, forms, popups and top navigation all disabled — and only then receives
|
||||
* the document, so nothing in it can run or reach the page.
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'iframe[gaSandboxedSrcdoc]',
|
||||
standalone: false
|
||||
})
|
||||
export class SandboxedSrcdocDirective implements OnInit {
|
||||
private readonly elementRef = inject<ElementRef<HTMLIFrameElement>>(ElementRef);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
|
||||
@Input('gaSandboxedSrcdoc') set document(html: string | null) {
|
||||
this.html = html ?? '';
|
||||
if (this.sandboxed) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private html = '';
|
||||
private sandboxed = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.renderer.setAttribute(this.elementRef.nativeElement, 'sandbox', '');
|
||||
this.sandboxed = true;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.renderer.setProperty(this.elementRef.nativeElement, 'srcdoc', this.html);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
@if (form) {
|
||||
<form class="main-form" [formGroup]="form">
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<nb-card class="email-templates-card">
|
||||
<nb-card-header class="page-header">
|
||||
<h4>
|
||||
<ngx-header-title [allowEmployee]="false">
|
||||
<span class="menu-setting">
|
||||
@@ -11,146 +11,224 @@
|
||||
</ngx-header-title>
|
||||
</h4>
|
||||
</nb-card-header>
|
||||
|
||||
<nb-card-body>
|
||||
<div class="row">
|
||||
<div class="col-6 email-template-column">
|
||||
<nb-card>
|
||||
<nb-card-header class="editor-header">
|
||||
<!-- ── Template picker ─────────────────────────────────────────── -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-field language">
|
||||
<label class="toolbar-label" for="languageCode">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.LABELS.LANGUAGE' | translate }}
|
||||
</label>
|
||||
<ngx-language-selector
|
||||
class="toolbar-control"
|
||||
size="small"
|
||||
template="ng-select"
|
||||
formControlName="languageCode"
|
||||
(selectedLanguageEvent)="subject$.next(true)"
|
||||
></ngx-language-selector>
|
||||
</div>
|
||||
<div class="toolbar-field template">
|
||||
<label class="toolbar-label" for="templateName">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.LABELS.TEMPLATE_NAME' | translate }}
|
||||
</label>
|
||||
<nb-select
|
||||
id="templateName"
|
||||
class="toolbar-control"
|
||||
size="small"
|
||||
fullWidth
|
||||
formControlName="name"
|
||||
[placeholder]="'EMAIL_TEMPLATES_PAGE.LABELS.TEMPLATE_NAME' | translate"
|
||||
(selectedChange)="onTemplatePicked()"
|
||||
>
|
||||
@for (name of templates; track name) {
|
||||
<nb-option [value]="name">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.TEMPLATE_NAMES.' + name | translate }}
|
||||
</nb-option>
|
||||
}
|
||||
</nb-select>
|
||||
</div>
|
||||
<fieldset class="view-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle-option"
|
||||
[class.active]="viewMode === 'grid'"
|
||||
[attr.aria-pressed]="viewMode === 'grid'"
|
||||
(click)="setViewMode('grid')"
|
||||
>
|
||||
<nb-icon icon="grid-outline"></nb-icon>
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.VIEW_GRID' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-toggle-option"
|
||||
[class.active]="viewMode === 'editor'"
|
||||
[attr.aria-pressed]="viewMode === 'editor'"
|
||||
(click)="setViewMode('editor')"
|
||||
>
|
||||
<nb-icon icon="code-outline"></nb-icon>
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.VIEW_EDITOR' | translate }}
|
||||
</button>
|
||||
</fieldset>
|
||||
<button
|
||||
type="button"
|
||||
class="save-button"
|
||||
[hidden]="viewMode === 'grid'"
|
||||
nbButton
|
||||
size="small"
|
||||
status="success"
|
||||
[disabled]="form.invalid"
|
||||
(click)="submitForm()"
|
||||
>
|
||||
<nb-icon icon="save-outline"></nb-icon>
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.SAVE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Template grid ───────────────────────────────────────────── -->
|
||||
@if (viewMode === 'grid') {
|
||||
<div class="template-grid">
|
||||
@for (card of gridCards; track card.name) {
|
||||
<button
|
||||
type="button"
|
||||
class="template-card"
|
||||
[class.active]="card.name === form.get('name').value"
|
||||
(click)="openTemplate(card.name)"
|
||||
>
|
||||
<div class="template-thumb">
|
||||
@if (card.html) {
|
||||
<iframe
|
||||
class="template-thumb-frame"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
[gaSandboxedSrcdoc]="card.html"
|
||||
></iframe>
|
||||
} @else {
|
||||
<div class="template-thumb-placeholder" [class.failed]="card.failed">
|
||||
<nb-icon [icon]="card.failed ? 'alert-circle-outline' : 'email-outline'"></nb-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="template-meta">
|
||||
<span class="template-name">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.TEMPLATE_NAMES.' + card.name | translate }}
|
||||
</span>
|
||||
<span class="template-subject" [innerHtml]="card.subject || '—'"></span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="workspace" [hidden]="viewMode === 'grid'">
|
||||
<!-- ── Editor ──────────────────────────────────────────────── -->
|
||||
<section class="pane editor-pane">
|
||||
<header class="pane-header">
|
||||
<nb-icon icon="code-outline"></nb-icon>
|
||||
<span class="pane-title">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.HTML_EDITOR' | translate }}
|
||||
</nb-card-header>
|
||||
<nb-card-body class="editor">
|
||||
<div class="form-group">
|
||||
<label class="label" for="subject">
|
||||
{{
|
||||
'EMAIL_TEMPLATES_PAGE.LABELS.SUBJECT'
|
||||
| translate
|
||||
}}
|
||||
</label>
|
||||
</span>
|
||||
</header>
|
||||
<div class="pane-body">
|
||||
<div class="field subject-field">
|
||||
<label class="field-label" for="subject">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.LABELS.SUBJECT' | translate }}
|
||||
</label>
|
||||
<div class="code-frame">
|
||||
<ace-editor
|
||||
id="subject"
|
||||
#subjectEditor
|
||||
style="height: 10vh; font-size: medium"
|
||||
class="ace-editor subject-editor"
|
||||
[durationBeforeCallback]="400"
|
||||
[mode]="'handlebars'"
|
||||
(textChange)="onSubjectChange($event)"
|
||||
>
|
||||
</ace-editor>
|
||||
></ace-editor>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="label" for="email">
|
||||
{{
|
||||
'EMAIL_TEMPLATES_PAGE.LABELS.EMAIL_BODY'
|
||||
| translate
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="field body-field">
|
||||
<label class="field-label" for="email">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.LABELS.EMAIL_BODY' | translate }}
|
||||
</label>
|
||||
<div class="code-frame">
|
||||
<ace-editor
|
||||
#emailEditor
|
||||
id="email"
|
||||
class="ace-editor body-editor"
|
||||
[mode]="'handlebars'"
|
||||
class="ace-editor"
|
||||
[durationBeforeCallback]="400"
|
||||
(textChange)="onEmailChange($event)"
|
||||
></ace-editor>
|
||||
</div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
</div>
|
||||
<!-- Live Preview column -->
|
||||
<div class="col-6 email-template-column">
|
||||
<nb-card class="email-template-right">
|
||||
<nb-card-header class="email-column-header">
|
||||
<div class="select">
|
||||
<div class="select-block">
|
||||
<label class="label" for="languageCode">
|
||||
{{
|
||||
'EMAIL_TEMPLATES_PAGE.LABELS.LANGUAGE'
|
||||
| translate
|
||||
}}
|
||||
</label>
|
||||
<ngx-language-selector
|
||||
class="d-block"
|
||||
size="small"
|
||||
template="ng-select"
|
||||
formControlName="languageCode"
|
||||
(selectedLanguageEvent)="subject$.next(true)"
|
||||
></ngx-language-selector>
|
||||
</div>
|
||||
<div class="select-block">
|
||||
<label class="label" for="templateName">
|
||||
{{
|
||||
'EMAIL_TEMPLATES_PAGE.LABELS.TEMPLATE_NAME'
|
||||
| translate
|
||||
}}
|
||||
</label>
|
||||
<nb-select
|
||||
id="templateName"
|
||||
class="d-block"
|
||||
size="small"
|
||||
formControlName="name"
|
||||
[placeholder]="'EMAIL_TEMPLATES_PAGE.LABELS.TEMPLATE_NAME' | translate"
|
||||
(selectedChange)="subject$.next(true)"
|
||||
>
|
||||
@for (name of templates; track name) {
|
||||
<nb-option
|
||||
[value]="name"
|
||||
>
|
||||
{{
|
||||
'EMAIL_TEMPLATES_PAGE.TEMPLATE_NAMES.' +
|
||||
name | translate
|
||||
}}
|
||||
</nb-option>
|
||||
}
|
||||
</nb-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-save">
|
||||
<button
|
||||
[disabled]="form.invalid"
|
||||
(click)="submitForm()"
|
||||
nbButton
|
||||
status="success"
|
||||
>
|
||||
{{
|
||||
'EMAIL_TEMPLATES_PAGE.SAVE' | translate
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<div class="email-column-sub-header">
|
||||
<div class="form-group preview-subject">
|
||||
<label class="label" for="previewSubject"
|
||||
>{{
|
||||
'EMAIL_TEMPLATES_PAGE.LABELS.SUBJECT_PREVIEW'
|
||||
| translate
|
||||
}}:
|
||||
</label>
|
||||
<div
|
||||
id="previewSubject"
|
||||
[innerHtml]="previewSubject"
|
||||
></div>
|
||||
</div>
|
||||
<label class="label" for="previewEmail"
|
||||
>{{
|
||||
'EMAIL_TEMPLATES_PAGE.LABELS.EMAIL_PREVIEW'
|
||||
| translate
|
||||
}}
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="custom-parent-email-content">
|
||||
<div
|
||||
id="previewEmail"
|
||||
[innerHtml]="previewEmail"
|
||||
class="email-content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Live preview ────────────────────────────────────────── -->
|
||||
<section class="pane preview-pane">
|
||||
<header class="pane-header">
|
||||
<nb-icon icon="eye-outline"></nb-icon>
|
||||
<label class="pane-title" for="previewEmail">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.LABELS.EMAIL_PREVIEW' | translate }}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="full-preview-button"
|
||||
nbButton
|
||||
ghost
|
||||
size="tiny"
|
||||
status="basic"
|
||||
[disabled]="!previewEmail"
|
||||
[title]="'EMAIL_TEMPLATES_PAGE.FULL_PREVIEW' | translate"
|
||||
(click)="openFullPreview(fullPreviewDialog)"
|
||||
>
|
||||
<nb-icon icon="expand-outline"></nb-icon>
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.FULL_PREVIEW' | translate }}
|
||||
</button>
|
||||
</header>
|
||||
<div class="pane-body">
|
||||
<div class="preview-subject">
|
||||
<label class="field-label" for="previewSubject">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.LABELS.SUBJECT_PREVIEW' | translate }}
|
||||
</label>
|
||||
<div id="previewSubject" class="preview-subject-value" [innerHtml]="previewSubject"></div>
|
||||
</div>
|
||||
<div class="preview-canvas">
|
||||
<div id="previewEmail" class="email-paper" [innerHtml]="previewEmail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- ── Full-size preview popup ──────────────────────────────────────── -->
|
||||
<ng-template #fullPreviewDialog let-ref="dialogRef">
|
||||
<nb-card class="full-preview-card">
|
||||
<nb-card-header class="full-preview-header">
|
||||
<div class="full-preview-heading">
|
||||
<span class="full-preview-title">
|
||||
{{ 'EMAIL_TEMPLATES_PAGE.TEMPLATE_NAMES.' + form.get('name').value | translate }}
|
||||
</span>
|
||||
<span class="full-preview-subject">
|
||||
<span class="field-label">{{ 'EMAIL_TEMPLATES_PAGE.LABELS.SUBJECT' | translate }}:</span>
|
||||
<span [innerHtml]="previewSubject"></span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
nbButton
|
||||
ghost
|
||||
size="small"
|
||||
status="basic"
|
||||
[title]="'BUTTONS.CLOSE' | translate"
|
||||
(click)="ref.close()"
|
||||
>
|
||||
<nb-icon icon="close-outline"></nb-icon>
|
||||
</button>
|
||||
</nb-card-header>
|
||||
<nb-card-body class="full-preview-body">
|
||||
<div class="email-paper full-preview-paper" [innerHtml]="previewEmail"></div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
</ng-template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import { AfterViewInit, Component, OnDestroy, OnInit, SecurityContext, ViewChild } from '@angular/core';
|
||||
import { AfterViewInit, Component, OnDestroy, OnInit, SecurityContext, TemplateRef, ViewChild } from '@angular/core';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { EmailTemplateEnum, IOrganization, LanguagesEnum } from '@gauzy/contracts';
|
||||
import { NbThemeService } from '@nebular/theme';
|
||||
import { NbDialogService, NbThemeService } from '@nebular/theme';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import 'brace';
|
||||
@@ -30,6 +30,20 @@ import { ToastrService } from '@gauzy/ui-core/core';
|
||||
*/
|
||||
const DARK_CANVAS_THEMES: ReadonlySet<string> = new Set(['dark', 'cosmic', 'gauzy-dark', 'material-dark']);
|
||||
|
||||
/** How many templates the grid fetches and renders at once. */
|
||||
const GRID_CONCURRENCY = 4;
|
||||
|
||||
export type EmailTemplatesViewMode = 'grid' | 'editor';
|
||||
|
||||
/** One tile of the template grid: the rendered subject and a thumbnail of the body. */
|
||||
export interface EmailTemplateCard {
|
||||
name: EmailTemplateEnum;
|
||||
subject: SafeHtml | null;
|
||||
/** Raw rendered email; only ever written into a sandboxed iframe by `SandboxedSrcdocDirective`. */
|
||||
html: string | null;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
@UntilDestroy({ checkProperties: true })
|
||||
@Component({
|
||||
templateUrl: './email-templates.component.html',
|
||||
@@ -44,6 +58,13 @@ export class EmailTemplatesComponent extends TranslationBaseComponent implements
|
||||
public previewSubject: SafeHtml;
|
||||
public organization: IOrganization;
|
||||
|
||||
public viewMode: EmailTemplatesViewMode = 'editor';
|
||||
public gridCards: EmailTemplateCard[] = [];
|
||||
/** Organization + language the grid was last loaded for, so toggling back does not refetch. */
|
||||
private gridKey: string;
|
||||
/** Bumped on every grid load; responses from an older load are dropped. */
|
||||
private gridRun = 0;
|
||||
|
||||
/**
|
||||
* Email Template Mutation Form
|
||||
*/
|
||||
@@ -67,7 +88,8 @@ export class EmailTemplatesComponent extends TranslationBaseComponent implements
|
||||
private readonly fb: UntypedFormBuilder,
|
||||
private readonly toastrService: ToastrService,
|
||||
private readonly emailTemplateService: EmailTemplateService,
|
||||
private readonly themeService: NbThemeService
|
||||
private readonly themeService: NbThemeService,
|
||||
private readonly dialogService: NbDialogService
|
||||
) {
|
||||
super(translateService);
|
||||
}
|
||||
@@ -77,6 +99,11 @@ export class EmailTemplatesComponent extends TranslationBaseComponent implements
|
||||
.pipe(
|
||||
debounceTime(200),
|
||||
tap(() => this.getTemplate()),
|
||||
tap(() => {
|
||||
if (this.viewMode === 'grid') {
|
||||
void this.loadGrid();
|
||||
}
|
||||
}),
|
||||
untilDestroyed(this)
|
||||
)
|
||||
.subscribe();
|
||||
@@ -163,6 +190,112 @@ export class EmailTemplatesComponent extends TranslationBaseComponent implements
|
||||
this.previewEmail = this.sanitizer.bypassSecurityTrustHtml(html);
|
||||
}
|
||||
|
||||
setViewMode(mode: EmailTemplatesViewMode) {
|
||||
if (this.viewMode === mode) {
|
||||
return;
|
||||
}
|
||||
this.viewMode = mode;
|
||||
if (mode === 'grid') {
|
||||
void this.loadGrid();
|
||||
} else {
|
||||
this.resizeEditors();
|
||||
}
|
||||
}
|
||||
|
||||
/** Template picked from the dropdown: show it in the editor. */
|
||||
onTemplatePicked() {
|
||||
this.subject$.next(true);
|
||||
this.setViewMode('editor');
|
||||
}
|
||||
|
||||
/** Grid card clicked: select that template and switch to the editor. */
|
||||
openTemplate(name: EmailTemplateEnum) {
|
||||
if (this.form.get('name').value !== name) {
|
||||
this.form.patchValue({ name });
|
||||
this.subject$.next(true);
|
||||
}
|
||||
this.setViewMode('editor');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every template for the current organization + language and renders its
|
||||
* subject and body, a few at a time. Cards fill in as their previews arrive.
|
||||
*/
|
||||
async loadGrid() {
|
||||
if (!this.organization) {
|
||||
return;
|
||||
}
|
||||
const { tenantId } = this.store.user;
|
||||
const { id: organizationId } = this.organization;
|
||||
const { languageCode = LanguagesEnum.ENGLISH } = this.form.getRawValue();
|
||||
const key = `${organizationId}:${languageCode}`;
|
||||
if (key === this.gridKey) {
|
||||
return;
|
||||
}
|
||||
this.gridKey = key;
|
||||
const run = ++this.gridRun;
|
||||
|
||||
this.gridCards = this.templates.map((name) => ({
|
||||
name: name as EmailTemplateEnum,
|
||||
subject: null,
|
||||
html: null,
|
||||
failed: false
|
||||
}));
|
||||
|
||||
const cards = this.gridCards;
|
||||
// Index of the next card to load, shared by the workers.
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < cards.length && run === this.gridRun) {
|
||||
const card = cards[next++];
|
||||
try {
|
||||
const { subject, template } = await this.emailTemplateService.getTemplate({
|
||||
languageCode,
|
||||
name: card.name,
|
||||
organizationId,
|
||||
tenantId
|
||||
});
|
||||
// The page may have been left, or the grid reloaded, while this was in flight.
|
||||
if (run !== this.gridRun) {
|
||||
return;
|
||||
}
|
||||
const [{ html: subjectHtml }, { html: bodyHtml }] = await Promise.all([
|
||||
this.emailTemplateService.generateTemplatePreview(subject),
|
||||
this.emailTemplateService.generateTemplatePreview(template)
|
||||
]);
|
||||
if (run !== this.gridRun) {
|
||||
return;
|
||||
}
|
||||
card.subject = this.sanitizer.sanitize(SecurityContext.HTML, subjectHtml);
|
||||
card.html = bodyHtml;
|
||||
} catch {
|
||||
card.failed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: GRID_CONCURRENCY }, worker));
|
||||
// A failed load may be retried by toggling the grid again.
|
||||
if (run === this.gridRun && this.gridCards.some((card) => card.failed)) {
|
||||
this.gridKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ace measures its box when shown; it was hidden while the grid was up. */
|
||||
private resizeEditors() {
|
||||
setTimeout(() => {
|
||||
this.emailEditor?.getEditor().resize();
|
||||
this.subjectEditor?.getEditor().resize();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the rendered email at full size, so long templates can be read without
|
||||
* scrolling inside the side-by-side preview pane.
|
||||
*/
|
||||
openFullPreview(dialog: TemplateRef<unknown>) {
|
||||
this.dialogService.open(dialog, { closeOnBackdropClick: true, hasScroll: false });
|
||||
}
|
||||
|
||||
selectedLanguage(event) {
|
||||
this.form.patchValue({
|
||||
languageCode: event.code
|
||||
@@ -181,6 +314,8 @@ export class EmailTemplatesComponent extends TranslationBaseComponent implements
|
||||
organizationId,
|
||||
tenantId
|
||||
});
|
||||
// The saved template's grid thumbnail is now stale.
|
||||
this.gridKey = null;
|
||||
this.toastrService.success('TOASTR.MESSAGE.EMAIL_TEMPLATE_SAVED', {
|
||||
templateName: this.getTranslation('EMAIL_TEMPLATES_PAGE.TEMPLATE_NAMES.' + this.form.get('name').value)
|
||||
});
|
||||
@@ -189,5 +324,8 @@ export class EmailTemplatesComponent extends TranslationBaseComponent implements
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
ngOnDestroy(): void {
|
||||
// Stops any grid load still running: its workers check the run between requests.
|
||||
this.gridRun++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { EmailTemplateService } from '@gauzy/ui-core/core';
|
||||
import { LanguageSelectorModule, TableComponentsModule, UserFormsModule } from '@gauzy/ui-core/shared';
|
||||
import { EmailTemplatesRoutingModule } from './email-templates-routing.module';
|
||||
import { EmailTemplatesComponent } from './email-templates.component';
|
||||
import { SandboxedSrcdocDirective } from './sandboxed-srcdoc.directive';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -39,6 +40,6 @@ import { EmailTemplatesComponent } from './email-templates.component';
|
||||
LanguageSelectorModule
|
||||
],
|
||||
providers: [EmailTemplateService],
|
||||
declarations: [EmailTemplatesComponent]
|
||||
declarations: [EmailTemplatesComponent, SandboxedSrcdocDirective]
|
||||
})
|
||||
export class EmailTemplatesModule {}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Directive, ElementRef, inject, Input, OnInit, Renderer2 } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Renders a full HTML document (a rendered email) inside an `<iframe>` without turning
|
||||
* off Angular's sanitizer.
|
||||
*
|
||||
* Binding `[srcdoc]` would need `bypassSecurityTrustHtml`, and Angular's sanitizer would
|
||||
* otherwise strip the email's `<head>` / `<style>` blocks that the layout depends on.
|
||||
* Instead the frame is locked down first — an empty `sandbox` gives it an opaque origin
|
||||
* with scripts, forms, popups and top navigation all disabled — and only then receives
|
||||
* the document, so nothing in it can run or reach the page.
|
||||
*/
|
||||
@Directive({
|
||||
selector: 'iframe[gaSandboxedSrcdoc]',
|
||||
standalone: false
|
||||
})
|
||||
export class SandboxedSrcdocDirective implements OnInit {
|
||||
private readonly elementRef = inject<ElementRef<HTMLIFrameElement>>(ElementRef);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
|
||||
@Input('gaSandboxedSrcdoc') set document(html: string | null) {
|
||||
this.html = html ?? '';
|
||||
if (this.sandboxed) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private html = '';
|
||||
private sandboxed = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.renderer.setAttribute(this.elementRef.nativeElement, 'sandbox', '');
|
||||
this.sandboxed = true;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.renderer.setProperty(this.elementRef.nativeElement, 'srcdoc', this.html);
|
||||
}
|
||||
}
|
||||
@@ -718,6 +718,9 @@ export class ExpensesComponent extends PaginationFilterBaseComponent implements
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
// The grid applies the Date column's default sort itself, but the cards layout never mounts
|
||||
// the grid and kept the server's insertion order: ask for newest first here as well (#530).
|
||||
this.smartTableSource.setSort([{ field: 'valueDate', direction: 'desc' }], false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -604,6 +604,9 @@ export class IncomeComponent extends PaginationFilterBaseComponent implements Af
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
// The grid applies the Date column's default sort itself, but the cards layout never mounts
|
||||
// the grid and kept the server's insertion order: ask for newest first here as well (#530).
|
||||
this.smartTableSource.setSort([{ field: 'valueDate', direction: 'desc' }], false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,21 +14,36 @@
|
||||
line-height: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
// The page card is pinned to the page frame by `.card-scroll`, and the global
|
||||
// `.card-scroll nb-card nb-card-body { overflow: hidden; height: 100% }` stops its
|
||||
// body from scrolling. With the summary strip and provider notes above the
|
||||
// provider form, the content is taller than the frame and spilled over the
|
||||
// footer. The body is the one scroll area; the footer (Save) stays put.
|
||||
nb-card.card-scroll > nb-card-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
nb-card {
|
||||
nb-card-body {
|
||||
// The provider configuration card (S3 / Wasabi / Cloudinary / DigitalOcean).
|
||||
//
|
||||
// `height` pinned it to a viewport-derived box regardless of how many
|
||||
// fields the selected provider actually has — four rows for S3, seven
|
||||
// for DigitalOcean — so short forms sat in a tall empty panel while the
|
||||
// outer `.card-scroll` card, already bounded by the page frame, scrolled
|
||||
// around it. As a MAX it still caps a long form (which then scrolls in
|
||||
// its own body, as before) and lets a short one shrink to its content.
|
||||
// Sized to its fields: the page body above scrolls, so a nested
|
||||
// viewport-derived max-height (and a second scrollbar) is no longer
|
||||
// needed — it was what pushed the card under the footer.
|
||||
nb-card {
|
||||
background-color: nb-theme(gauzy-card-4);
|
||||
max-height: calc(100vh - 24rem);
|
||||
margin: 0;
|
||||
|
||||
// Redesigned cards (Wasabi, Local) have no fill of their own: they sit
|
||||
// on the page surface, drawn by their hairline. Set here, not on
|
||||
// `nb-card.provider-card` alone — this nested selector out-ranks that.
|
||||
&.provider-card {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
// Was 18px. `text-heading-6-font-size` is the 1rem step the density
|
||||
// pass set for every theme; an 18px section title here was the only
|
||||
// h6 in Settings that ignored it.
|
||||
@@ -41,7 +56,8 @@
|
||||
|
||||
nb-card-body {
|
||||
background: unset;
|
||||
overflow: auto;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,5 +66,406 @@
|
||||
background-color: nb-theme(gauzy-card-2);
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
// Action bar: same shape as Settings > General's group footer — status
|
||||
// text pushed to the start, small buttons grouped on the end, hairline above.
|
||||
nb-card-footer.form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
|
||||
.form-status {
|
||||
margin-inline-end: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
|
||||
button[nbButton] {
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
|
||||
nb-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Current configuration strip ───────────────────────────────────────
|
||||
// Same vocabulary as Settings > General: 12px body, hairline inset, no shadow.
|
||||
.storage-summary {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: var(--gauzy-radius-sm, 0.5rem);
|
||||
background: transparent;
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.summary-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem 0.75rem;
|
||||
padding: 0.75rem 1rem 0.625rem;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summary-hint {
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
|
||||
.summary-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
min-width: 0;
|
||||
padding: 0.625rem 1rem;
|
||||
|
||||
& + .summary-item {
|
||||
border-inline-start: 1px solid var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
}
|
||||
|
||||
dt {
|
||||
margin-bottom: 0.125rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
|
||||
dd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
color: var(--text-basic-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.mono {
|
||||
display: block;
|
||||
font-family: var(--font-family-monospace, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty tenant field: the API uses the server's own configuration. Muted, not a warning.
|
||||
.server-default {
|
||||
color: var(--text-hint-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
|
||||
&.success {
|
||||
background: var(--color-success-default);
|
||||
}
|
||||
&.warning {
|
||||
background: var(--color-warning-default);
|
||||
}
|
||||
&.danger {
|
||||
background: var(--color-danger-default);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider description + docs link ──────────────────────────────────
|
||||
.provider-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.125rem;
|
||||
color: var(--text-hint-color);
|
||||
|
||||
> nb-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
color: var(--text-basic-color);
|
||||
}
|
||||
|
||||
a {
|
||||
white-space: nowrap;
|
||||
|
||||
nb-icon {
|
||||
font-size: 0.75rem;
|
||||
vertical-align: -0.1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local storage card ────────────────────────────────────────────────
|
||||
// Same header as the provider forms; no fills of its own — the warning is
|
||||
// a transparent tint, never a white/light panel.
|
||||
.local-status,
|
||||
.local-callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
line-height: 1.125rem;
|
||||
|
||||
nb-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.0625rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.local-status {
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text-basic-color);
|
||||
}
|
||||
|
||||
.local-callout {
|
||||
margin: 0 1rem 1rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--gauzy-radius-sm, 0.5rem);
|
||||
background: var(--color-warning-transparent-default, rgba(255, 170, 0, 0.08));
|
||||
box-shadow: inset 0 0 0 1px var(--color-warning-transparent-active, rgba(255, 170, 0, 0.24));
|
||||
color: var(--text-basic-color);
|
||||
}
|
||||
|
||||
// ── Check configuration result ────────────────────────────────────────
|
||||
.check-result {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: var(--gauzy-radius-sm, 0.5rem);
|
||||
box-shadow: inset 0 0 0 1px var(--color-warning-default);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.125rem;
|
||||
|
||||
&.ok {
|
||||
box-shadow: inset 0 0 0 1px var(--color-success-default);
|
||||
}
|
||||
|
||||
> nb-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider card: sectioned form (Wasabi) ────────────────────────────
|
||||
// Label + hint on the start side, control on the end side, hairline between
|
||||
// rows — the Settings > General row vocabulary, inside the provider card.
|
||||
// (Its transparent fill is set in the nested provider-card rule at the top.)
|
||||
nb-card.provider-card {
|
||||
overflow: hidden;
|
||||
|
||||
> nb-card-header,
|
||||
> nb-card-body {
|
||||
background: transparent;
|
||||
}
|
||||
border-radius: var(--gauzy-radius-sm, 0.5rem);
|
||||
box-shadow: inset 0 0 0 1px var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
}
|
||||
|
||||
.provider-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
}
|
||||
|
||||
.provider-mark {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--gauzy-radius-sm, 0.5rem);
|
||||
background: var(--color-primary-transparent-default, rgba(51, 102, 255, 0.08));
|
||||
color: var(--color-primary-default);
|
||||
|
||||
nb-icon {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-heading {
|
||||
min-width: 0;
|
||||
|
||||
h6 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-subtitle {
|
||||
margin: 0.125rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.125rem;
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
|
||||
nb-card-body.provider-form {
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.provider-section + .provider-section {
|
||||
border-top: 1px solid var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.125rem 0.5rem;
|
||||
padding: 0.75rem 1rem 0.25rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 22rem);
|
||||
align-items: start;
|
||||
gap: 0.5rem 1.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
|
||||
& + .field-row {
|
||||
border-top: 1px solid var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.field-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
padding-top: 0.375rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-basic-color);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
line-height: 1.125rem;
|
||||
color: var(--text-hint-color);
|
||||
}
|
||||
|
||||
.field-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
|
||||
&.toggle {
|
||||
align-items: flex-end;
|
||||
padding-top: 0.25rem;
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
nb-form-field,
|
||||
nb-select,
|
||||
input[nbInput] {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
input.mono {
|
||||
font-family: var(--font-family-monospace, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.field-suggestion {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--text-hint-color);
|
||||
|
||||
code {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-basic-color);
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.link-button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary-color, var(--color-primary-default));
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--gauzy-border-default-color, var(--border-basic-color-3));
|
||||
background: transparent;
|
||||
line-height: 1.125rem;
|
||||
color: var(--text-hint-color);
|
||||
|
||||
nb-icon {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.0625rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,148 @@ import { TranslationBaseComponent } from '@gauzy/ui-core/i18n';
|
||||
import { ErrorHandlingService, Store, ToastrService } from '@gauzy/ui-core/core';
|
||||
import { FileStorageService, TenantService } from '@gauzy/ui-core/core';
|
||||
|
||||
/**
|
||||
* Where each cloud provider keeps the values the summary and the configuration check look at.
|
||||
* Field names are the form control names, which are also the tenant setting names.
|
||||
*/
|
||||
interface IProviderFieldMap {
|
||||
bucket?: string;
|
||||
region?: string;
|
||||
endpoint?: string;
|
||||
credentials: string[];
|
||||
/** Fields the check looks at; with `serverFallback`, an empty one is not an error. */
|
||||
required: { control: string; label: string }[];
|
||||
/**
|
||||
* The API provider keeps the server's own configuration (environment) for every field the
|
||||
* tenant leaves empty, so empty here means "use the server default", not "missing".
|
||||
* Not so for Wasabi: saving validates the submitted values alone.
|
||||
*/
|
||||
serverFallback: boolean;
|
||||
/** URL fields; `httpsOnly` where the access keys are sent to that address (an S3 endpoint). */
|
||||
urls: { control: string; label: string; httpsOnly?: boolean }[];
|
||||
docsUrl: string;
|
||||
}
|
||||
|
||||
const PROVIDER_FIELDS: Partial<Record<FileStorageProviderEnum, IProviderFieldMap>> = {
|
||||
[FileStorageProviderEnum.S3]: {
|
||||
bucket: 'aws_bucket',
|
||||
region: 'aws_default_region',
|
||||
credentials: ['aws_access_key_id', 'aws_secret_access_key'],
|
||||
required: [
|
||||
{ control: 'aws_access_key_id', label: 'SETTINGS_FILE_STORAGE.S3.LABELS.ACCESS_KEY_ID' },
|
||||
{ control: 'aws_secret_access_key', label: 'SETTINGS_FILE_STORAGE.S3.LABELS.SECRET_ACCESS_KEY' },
|
||||
{ control: 'aws_default_region', label: 'SETTINGS_FILE_STORAGE.S3.LABELS.REGION' },
|
||||
{ control: 'aws_bucket', label: 'SETTINGS_FILE_STORAGE.S3.LABELS.BUCKET' }
|
||||
],
|
||||
urls: [],
|
||||
serverFallback: true,
|
||||
docsUrl: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html'
|
||||
},
|
||||
[FileStorageProviderEnum.WASABI]: {
|
||||
bucket: 'wasabi_aws_bucket',
|
||||
region: 'wasabi_aws_default_region',
|
||||
endpoint: 'wasabi_aws_service_url',
|
||||
credentials: ['wasabi_aws_access_key_id', 'wasabi_aws_secret_access_key'],
|
||||
required: [
|
||||
{ control: 'wasabi_aws_access_key_id', label: 'SETTINGS_FILE_STORAGE.WASABI.LABELS.ACCESS_KEY_ID' },
|
||||
{ control: 'wasabi_aws_secret_access_key', label: 'SETTINGS_FILE_STORAGE.WASABI.LABELS.SECRET_ACCESS_KEY' },
|
||||
{ control: 'wasabi_aws_default_region', label: 'SETTINGS_FILE_STORAGE.WASABI.LABELS.REGION' },
|
||||
{ control: 'wasabi_aws_service_url', label: 'SETTINGS_FILE_STORAGE.WASABI.LABELS.SERVICE_URL' },
|
||||
{ control: 'wasabi_aws_bucket', label: 'SETTINGS_FILE_STORAGE.WASABI.LABELS.BUCKET' }
|
||||
],
|
||||
urls: [
|
||||
{ control: 'wasabi_aws_service_url', label: 'SETTINGS_FILE_STORAGE.WASABI.LABELS.SERVICE_URL', httpsOnly: true }
|
||||
],
|
||||
serverFallback: false,
|
||||
docsUrl: 'https://docs.wasabi.com/'
|
||||
},
|
||||
[FileStorageProviderEnum.CLOUDINARY]: {
|
||||
bucket: 'cloudinary_cloud_name',
|
||||
endpoint: 'cloudinary_delivery_url',
|
||||
credentials: ['cloudinary_api_key', 'cloudinary_api_secret'],
|
||||
required: [
|
||||
{ control: 'cloudinary_cloud_name', label: 'SETTINGS_FILE_STORAGE.CLOUDINARY.LABELS.CLOUD_NAME' },
|
||||
{ control: 'cloudinary_api_key', label: 'SETTINGS_FILE_STORAGE.CLOUDINARY.LABELS.ACCESS_API_KEY' },
|
||||
{ control: 'cloudinary_api_secret', label: 'SETTINGS_FILE_STORAGE.CLOUDINARY.LABELS.ACCESS_API_SECRET' },
|
||||
{ control: 'cloudinary_delivery_url', label: 'SETTINGS_FILE_STORAGE.CLOUDINARY.LABELS.DELIVERY_URL' }
|
||||
],
|
||||
urls: [{ control: 'cloudinary_delivery_url', label: 'SETTINGS_FILE_STORAGE.CLOUDINARY.LABELS.DELIVERY_URL' }],
|
||||
serverFallback: true,
|
||||
docsUrl: 'https://cloudinary.com/documentation'
|
||||
},
|
||||
[FileStorageProviderEnum.DIGITALOCEAN]: {
|
||||
bucket: 'digitalocean_s3_bucket',
|
||||
region: 'digitalocean_default_region',
|
||||
endpoint: 'digitalocean_service_url',
|
||||
credentials: ['digitalocean_access_key_id', 'digitalocean_secret_access_key'],
|
||||
required: [
|
||||
{ control: 'digitalocean_access_key_id', label: 'SETTINGS_FILE_STORAGE.DIGITALOCEAN.LABELS.ACCESS_KEY_ID' },
|
||||
{
|
||||
control: 'digitalocean_secret_access_key',
|
||||
label: 'SETTINGS_FILE_STORAGE.DIGITALOCEAN.LABELS.SECRET_ACCESS_KEY'
|
||||
},
|
||||
{ control: 'digitalocean_service_url', label: 'SETTINGS_FILE_STORAGE.DIGITALOCEAN.LABELS.SERVICE_URL' },
|
||||
{ control: 'digitalocean_s3_bucket', label: 'SETTINGS_FILE_STORAGE.DIGITALOCEAN.LABELS.BUCKET' }
|
||||
],
|
||||
urls: [
|
||||
{
|
||||
control: 'digitalocean_service_url',
|
||||
label: 'SETTINGS_FILE_STORAGE.DIGITALOCEAN.LABELS.SERVICE_URL',
|
||||
httpsOnly: true
|
||||
},
|
||||
{ control: 'digitalocean_cdn_url', label: 'SETTINGS_FILE_STORAGE.DIGITALOCEAN.LABELS.CDN_URL' }
|
||||
],
|
||||
serverFallback: true,
|
||||
docsUrl: 'https://docs.digitalocean.com/products/spaces/how-to/manage-access/'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Wasabi regions offered as suggestions on the region input (free text is still accepted).
|
||||
* Each region's endpoint is `https://s3.<region>.wasabisys.com`; us-east-1 keeps the
|
||||
* historical `https://s3.wasabisys.com`, which is also the form default.
|
||||
*/
|
||||
const WASABI_REGIONS = [
|
||||
'us-east-1',
|
||||
'us-east-2',
|
||||
'us-central-1',
|
||||
'us-west-1',
|
||||
'ca-central-1',
|
||||
'eu-central-1',
|
||||
'eu-central-2',
|
||||
'eu-west-1',
|
||||
'eu-west-2',
|
||||
'eu-south-1',
|
||||
'ap-northeast-1',
|
||||
'ap-northeast-2',
|
||||
'ap-southeast-1',
|
||||
'ap-southeast-2'
|
||||
];
|
||||
|
||||
/**
|
||||
* The last saved configuration, as shown in the summary strip. A value is '' when the provider
|
||||
* has no such field (rendered —) and null when it is empty and the server's default applies.
|
||||
*/
|
||||
export interface IFileStorageSummary {
|
||||
provider: FileStorageProviderEnum;
|
||||
bucket: string | null;
|
||||
region: string | null;
|
||||
endpoint: string | null;
|
||||
credentials: 'saved' | 'incomplete' | 'none' | 'server_default' | 'not_needed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the client-side configuration check, as field labels: required but empty, holding
|
||||
* an invalid URL, or empty and so left to the server's own configuration.
|
||||
*/
|
||||
export interface IFileStorageCheck {
|
||||
missing: string[];
|
||||
invalidUrls: string[];
|
||||
/** Valid http:// URLs where https:// is required. */
|
||||
insecureUrls: string[];
|
||||
serverDefaults: string[];
|
||||
}
|
||||
|
||||
@UntilDestroy({ checkProperties: true })
|
||||
@Component({
|
||||
selector: 'ga-file-storage',
|
||||
@@ -32,11 +174,20 @@ export class FileStorageComponent extends TranslationBaseComponent implements On
|
||||
{ label: SMTPSecureEnum.TRUE, value: 'true' },
|
||||
{ label: SMTPSecureEnum.FALSE, value: 'false' }
|
||||
];
|
||||
readonly wasabiRegions = WASABI_REGIONS;
|
||||
PermissionsEnum = PermissionsEnum;
|
||||
FileStorageProviderEnum = FileStorageProviderEnum;
|
||||
user: IUser;
|
||||
settings: ITenantSetting = new Object();
|
||||
loading: boolean = false;
|
||||
/** True while a save request is in flight. */
|
||||
saving: boolean = false;
|
||||
/** What the tenant is saved with right now — not what is being edited in the form. */
|
||||
summary: IFileStorageSummary | null = null;
|
||||
/** Result of the last "Check configuration"; cleared whenever the form changes. */
|
||||
check: IFileStorageCheck | null = null;
|
||||
/** Secret inputs the user has chosen to reveal, by control name. */
|
||||
revealed: Record<string, boolean> = {};
|
||||
|
||||
public readonly form: UntypedFormGroup = FileStorageComponent.buildForm(this._fb);
|
||||
|
||||
@@ -98,6 +249,13 @@ export class FileStorageComponent extends TranslationBaseComponent implements On
|
||||
return this.form.get('fileStorageProvider').value;
|
||||
}
|
||||
|
||||
/*
|
||||
* Documentation link for the selected provider (none for LOCAL)
|
||||
*/
|
||||
get providerDocsUrl(): string | null {
|
||||
return PROVIDER_FIELDS[this.fileStorageProvider as FileStorageProviderEnum]?.docsUrl ?? null;
|
||||
}
|
||||
|
||||
constructor(
|
||||
public readonly translate: TranslateService,
|
||||
private readonly _fb: UntypedFormBuilder,
|
||||
@@ -120,16 +278,28 @@ export class FileStorageComponent extends TranslationBaseComponent implements On
|
||||
])
|
||||
.pipe(untilDestroyed(this))
|
||||
.subscribe();
|
||||
|
||||
// A check result describes the values it was run on; any edit makes it stale.
|
||||
this.form.valueChanges
|
||||
.pipe(
|
||||
tap(() => (this.check = null)),
|
||||
untilDestroyed(this)
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current tenant's file storage settings.
|
||||
* If settings are available, updates the file storage provider accordingly.
|
||||
* If no settings are available, uses the default file storage provider from the environment.
|
||||
*
|
||||
* @param patchForm - false to refresh only the saved settings and summary, leaving the form as edited.
|
||||
* Even when true, the form is left alone if it was edited while the request was pending.
|
||||
*/
|
||||
async getSetting(): Promise<void> {
|
||||
async getSetting(patchForm: boolean = true): Promise<void> {
|
||||
try {
|
||||
this.loading = true; // Set loading state to true while fetching settings
|
||||
const formBeforeFetch = JSON.stringify(this.form.getRawValue());
|
||||
|
||||
// Fetch tenant settings
|
||||
const settings = (this.settings = await this._tenantService.getSettings());
|
||||
@@ -142,7 +312,11 @@ export class FileStorageComponent extends TranslationBaseComponent implements On
|
||||
const fileStorageProvider = isNotEmpty(settings)
|
||||
? settings.fileStorageProvider
|
||||
: defaultFileStorageProvider;
|
||||
this.setFileStorageProvider(fileStorageProvider);
|
||||
// An edit made while the request was pending wins over the fetched values.
|
||||
if (patchForm && JSON.stringify(this.form.getRawValue()) === formBeforeFetch) {
|
||||
this.setFileStorageProvider(fileStorageProvider);
|
||||
}
|
||||
this.summary = this.buildSummary(fileStorageProvider || defaultFileStorageProvider, settings);
|
||||
} catch (error) {
|
||||
console.error('Error fetching tenant settings:', error); // Log the error
|
||||
// You can add more specific error handling here if needed
|
||||
@@ -155,13 +329,20 @@ export class FileStorageComponent extends TranslationBaseComponent implements On
|
||||
* SAVE current tenant file storage setting
|
||||
*/
|
||||
async submit() {
|
||||
try {
|
||||
if (this.form.invalid) {
|
||||
return;
|
||||
}
|
||||
if (this.form.invalid || this.saving) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
// What is being saved. An edit made while the request is pending must stay unsaved and
|
||||
// must not be overwritten by the refresh that follows.
|
||||
const submitted = JSON.stringify(this.form.getRawValue());
|
||||
const editedSinceSubmit = () => JSON.stringify(this.form.getRawValue()) !== submitted;
|
||||
let saved = false;
|
||||
|
||||
try {
|
||||
// Extract the file storage provider and settings from the form data
|
||||
const { fileStorageProvider = FileStorageProviderEnum.LOCAL, ...filesystem } = this.form.getRawValue();
|
||||
const { fileStorageProvider = FileStorageProviderEnum.LOCAL, ...filesystem } = JSON.parse(submitted);
|
||||
|
||||
// Construct the settings object with the extracted data
|
||||
const settings: ITenantSetting = {
|
||||
@@ -186,12 +367,20 @@ export class FileStorageComponent extends TranslationBaseComponent implements On
|
||||
|
||||
// Saves the tenant settings and displays a success message upon successful saving.
|
||||
await this._tenantService.saveSettings(settings);
|
||||
saved = true;
|
||||
if (!editedSinceSubmit()) {
|
||||
this.form.markAsPristine(); // clears the footer's "Unsaved changes"
|
||||
}
|
||||
this._toastrService.success('TOASTR.MESSAGE.SETTINGS_SAVED');
|
||||
} catch (error) {
|
||||
console.error('Error while submitting tenant settings:', error);
|
||||
this._toastrService.danger('An error occurred while saving settings. Please try again.');
|
||||
} finally {
|
||||
this.subject$.next(true);
|
||||
this.saving = false;
|
||||
// Reload what is saved. Patch it into the form only after a successful save of exactly
|
||||
// what the form holds: a failed save keeps the attempted values for correction, and
|
||||
// newer edits are never overwritten (getSetting re-checks when the fetch resolves).
|
||||
await this.getSetting(saved && !editedSinceSubmit());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,4 +401,127 @@ export class FileStorageComponent extends TranslationBaseComponent implements On
|
||||
providerControl.updateValueAndValidity();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Wasabi endpoint that matches the entered region, or null when the region is not a
|
||||
* known Wasabi region or the service URL already matches it.
|
||||
*/
|
||||
get wasabiSuggestedEndpoint(): string | null {
|
||||
const group = this.form.get(FileStorageProviderEnum.WASABI);
|
||||
const region = `${group.get('wasabi_aws_default_region').value ?? ''}`.trim().toLowerCase();
|
||||
if (!WASABI_REGIONS.includes(region)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endpoint = region === 'us-east-1' ? 'https://s3.wasabisys.com' : `https://s3.${region}.wasabisys.com`;
|
||||
let current = `${group.get('wasabi_aws_service_url').value ?? ''}`.trim();
|
||||
while (current.endsWith('/')) {
|
||||
current = current.slice(0, -1);
|
||||
}
|
||||
return current === endpoint ? null : endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the suggested Wasabi endpoint in the service URL field
|
||||
*/
|
||||
useWasabiSuggestedEndpoint(): void {
|
||||
const endpoint = this.wasabiSuggestedEndpoint;
|
||||
if (endpoint) {
|
||||
const control = this.form.get(FileStorageProviderEnum.WASABI).get('wasabi_aws_service_url');
|
||||
control.setValue(endpoint);
|
||||
control.markAsDirty(); // a programmatic change is still unsaved
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the value of a secret input
|
||||
*
|
||||
* @param control - The form control name of the secret input.
|
||||
*/
|
||||
toggleSecret(control: string): void {
|
||||
this.revealed[control] = !this.revealed[control];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the selected provider's configuration in the browser: every URL field holds an http(s)
|
||||
* URL (https where the keys are sent to it), and every field is filled — or, for providers that
|
||||
* fall back to the server's own configuration, list the empty ones as left to it. Nothing is
|
||||
* sent to the server; Wasabi credentials are still verified by the API when the settings are saved.
|
||||
*/
|
||||
checkConfiguration(): void {
|
||||
const fields = PROVIDER_FIELDS[this.fileStorageProvider as FileStorageProviderEnum];
|
||||
if (!fields) {
|
||||
this.check = { missing: [], invalidUrls: [], insecureUrls: [], serverDefaults: [] };
|
||||
return;
|
||||
}
|
||||
|
||||
const values = this.form.get(this.fileStorageProvider).getRawValue();
|
||||
const isBlank = (value: unknown) =>
|
||||
value === null || value === undefined || (typeof value === 'string' && value.trim() === '');
|
||||
|
||||
const empty = fields.required.filter(({ control }) => isBlank(values[control])).map(({ label }) => label);
|
||||
|
||||
const filledUrls = fields.urls.filter(({ control }) => !isBlank(values[control]));
|
||||
const invalidUrls = filledUrls
|
||||
.filter(({ control }) => !this.isHttpUrl(`${values[control]}`.trim()))
|
||||
.map(({ label }) => label);
|
||||
// The keys are signed into requests to this address: plain http would expose them.
|
||||
const insecureUrls = filledUrls
|
||||
.filter(({ control, httpsOnly }) => {
|
||||
const value = `${values[control]}`.trim();
|
||||
return httpsOnly && this.isHttpUrl(value) && new URL(value).protocol !== 'https:';
|
||||
})
|
||||
.map(({ label }) => label);
|
||||
|
||||
// Where the API falls back to the server's configuration, an empty field is a choice, not an error.
|
||||
this.check = fields.serverFallback
|
||||
? { missing: [], invalidUrls, insecureUrls, serverDefaults: empty }
|
||||
: { missing: empty, invalidUrls, insecureUrls, serverDefaults: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize the saved settings for the status strip.
|
||||
*
|
||||
* @param provider - The saved (or default) file storage provider.
|
||||
* @param settings - The tenant settings as returned by the API (secrets arrive masked).
|
||||
*/
|
||||
private buildSummary(provider: FileStorageProviderEnum, settings: ITenantSetting): IFileStorageSummary {
|
||||
const fields = PROVIDER_FIELDS[provider];
|
||||
const values = (settings ?? {}) as Record<string, any>;
|
||||
// '' when the provider has no such field; null when empty and the server's default applies.
|
||||
const read = (name?: string): string | null => {
|
||||
if (!name) return '';
|
||||
if (isNotEmpty(values[name])) return `${values[name]}`;
|
||||
return fields?.serverFallback ? null : '';
|
||||
};
|
||||
|
||||
let credentials: IFileStorageSummary['credentials'] = 'not_needed';
|
||||
if (fields) {
|
||||
const saved = fields.credentials.filter((name) => isNotEmpty(values[name])).length;
|
||||
if (saved === fields.credentials.length) {
|
||||
credentials = 'saved';
|
||||
} else if (saved > 0) {
|
||||
credentials = 'incomplete';
|
||||
} else {
|
||||
credentials = fields.serverFallback ? 'server_default' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
bucket: read(fields?.bucket),
|
||||
region: read(fields?.region),
|
||||
endpoint: read(fields?.endpoint),
|
||||
credentials
|
||||
};
|
||||
}
|
||||
|
||||
private isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const { protocol } = new URL(value);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NgModule } from '@angular/core';
|
||||
import {
|
||||
NbButtonModule,
|
||||
NbCardModule,
|
||||
NbFormFieldModule,
|
||||
NbIconModule,
|
||||
NbInputModule,
|
||||
NbSelectModule,
|
||||
NbSpinnerModule,
|
||||
@@ -15,7 +17,16 @@ import { FileStorageRoutingModule } from './file-storage-routing.module';
|
||||
import { FileStorageComponent } from './file-storage.component';
|
||||
|
||||
// Nebular Modules
|
||||
const NB_MODULES = [NbButtonModule, NbCardModule, NbInputModule, NbSelectModule, NbSpinnerModule, NbToggleModule];
|
||||
const NB_MODULES = [
|
||||
NbButtonModule,
|
||||
NbCardModule,
|
||||
NbFormFieldModule,
|
||||
NbIconModule,
|
||||
NbInputModule,
|
||||
NbSelectModule,
|
||||
NbSpinnerModule,
|
||||
NbToggleModule
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
||||
@@ -116,6 +116,15 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// A page that fills the content area and scrolls inside itself (the template editors)
|
||||
// marks its host `settings-page-fill`. `min-height` alone leaves the shell's height
|
||||
// indefinite, so the page's `height: 100%` resolved to its content and it overflowed
|
||||
// the column; for such a page the shell takes the column's exact height instead. Long
|
||||
// pages keep `min-height` and grow, which is what keeps the sticky menu in view.
|
||||
.settings-shell:has(> .settings-content > .settings-page-fill) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@include respond(md) {
|
||||
.settings-shell {
|
||||
flex-direction: column;
|
||||
@@ -130,4 +139,10 @@
|
||||
.settings-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Stacked under the menu, a fill page takes what the menu leaves.
|
||||
.settings-shell:has(> .settings-content > .settings-page-fill) > .settings-content {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<!-- markdownlint-disable MD013 MD043 -->
|
||||
|
||||
# What Ever Gauzy sends to Ever Platform
|
||||
|
||||
Ever Gauzy has two optional modules that talk to Ever Platform: the **anonymous usage statistics**
|
||||
([`@gauzy/plugin-ever-stats`](../../packages/plugins/ever-stats/README.md)) and the **connection**
|
||||
([`@gauzy/plugin-ever-connect`](../../packages/plugins/ever-connect/README.md)). This page lists every
|
||||
call either of them can make. Nothing else in Gauzy calls Ever Platform.
|
||||
|
||||
## When nothing is sent
|
||||
|
||||
| Setting | Effect |
|
||||
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `EVER_STATS_ENABLED=false` | The statistics module is not loaded: no route (`/api/ever-stats/*` answers 404), no timer, no request. |
|
||||
| Statistics switched off in Settings > Anonymous usage statistics | The module stays loaded for the settings page; it sends nothing. |
|
||||
| `EVER_CONNECT_ENABLED` unset (the default), or anything but `true` | The connection module is not loaded: no route (`/api/ever-connect/*` answers 404), no timer, no request. |
|
||||
| `EVER_CONNECT_ENABLED=true`, not connected | Loaded and idle: no request until the operator of the installation connects with a code. |
|
||||
| `ZITADEL_ENABLED=true` with an issuer on an Ever host, the connection off | The sign-in plugin does not use that issuer on a self-hosted installation, so nothing is sent to it. |
|
||||
|
||||
Every call below needs its module on, and every call of the connection needs a connection the
|
||||
operator made. A call that is listed but whose trigger never happens is never made.
|
||||
|
||||
## Every call
|
||||
|
||||
The table is generated from the outbound-call rows of the Ever Platform contract, at the exact version
|
||||
the modules pin; a check in CI fails when the table, the modules' READMEs or the egress audit drift
|
||||
from it. The row number is the contract's. Where Gauzy calls on a schedule of its own (row 8), the
|
||||
"When" and "How often" columns give Gauzy's.
|
||||
|
||||
<!-- generated:version (node tools/ever-platform/outbound-calls.mjs --write, from the pinned contract; do not edit by hand) -->
|
||||
|
||||
Contract version: `1.0.0-rc.6` (`@ever-co/connect-contracts` and `@ever-co/connect-tools` at the same version).
|
||||
|
||||
<!-- /generated:version -->
|
||||
|
||||
<!-- generated:calls (node tools/ever-platform/outbound-calls.mjs --write, from the pinned contract; do not edit by hand) -->
|
||||
|
||||
| # | Module | Request | When | What it carries | How often | How to stop it |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 | connection | `GET /.well-known/ever-keys.json` | first signature verification; every 24 h; an unknown key id | none (read) | at most every 24 h; at most once per 10 min on an unknown key id | disconnect, or leave EVER_CONNECT_ENABLED unset |
|
||||
| 3 | connection | `POST /v1/connect/redeem` | the operator submits a connect code; EVER_CONNECT_CODE at first boot | code, product, version, install_source, kind (self_hosted; cloud on deployments Ever operates), serves_products[], public_jwk (the connect key), tenant {product_tenant_id, product_org_id?, display_name}? (absent values are omitted, never sent as null); never the installation's address | once | do not connect |
|
||||
| 4 | connection | `POST /v1/instances/token` | while connected: before the token expires, and after a 401 | grant_type, client_assertion_type, client_assertion (issuer and subject are the Ever Platform instance id returned at connect, never the statistics instance id) | every 50 min while connected (a token is kept for its hour; at most 60 an hour); on a 401 | disconnect |
|
||||
| 5 | connection | `POST /v1/instances/me/tenant-links`, `DELETE /v1/instances/me/tenant-links/{link}` | an organization admin submits a link code, removes a link, or a single-organization product moves its link to a new organization id | link_code, product, product_tenant_id, product_org_id?, display_name? of the tenant | on action | do not link |
|
||||
| 6 | connection | `POST /v1/instances/me/heartbeat`, `GET /v1/instances/me` | while connected; the status read also while an approval is pending and when an admin opens the connection page | version, module_version?, serves_products[]? (heartbeat); none (status read) | within 5 min of boot, then every 24 h | disconnect |
|
||||
| 7 | connection | `GET /v1/instances/me/events`, `POST /v1/instances/me/events/ack` | while connected | cursor only | continuous long-poll (wait=25), or one read every 15 min with EVER_CONNECT_FEED_MODE=interval | disconnect |
|
||||
| 8 | connection | `GET /v1/instances/me/entitlement`, `GET /v1/instances/me/tenant-links/{link}/entitlement` | while connected: at connect and at each link, with each heartbeat, on an entitlement notice on the event feed, on demand (Refresh, at most 6 an hour) | none (read; If-None-Match with the cached sequence number) | with the heartbeat (every 24 h); on a notice; on demand | disconnect |
|
||||
| 9 | connection | `GET /v1/instances/me/integrations`, `GET /v1/instances/me/consent-url` | after a consent notice; on return from app.ever.co; when an admin opens the integrations tab | integration, link, return (query) | on action or notice | disconnect |
|
||||
| 10 | connection | `PUT /v1/instances/me/integrations/{key}` | an admin disables an integration locally; an operator policy denies it | enabled: false, reason (instance or policy), tenant_link_id? | on action | none needed: this call only ever disables |
|
||||
| 11 | connection | `POST /v1/instances/me/stats-link` | integration stats_link enabled (self-hosted installations only) | stats_instance_id, stats_public_jwk, statement_sig: a statement signed with the separate statistics key, sent under the connect-key token, so app.ever.co can show the installation's last report | once; again after Reset instance identity | disable stats_link |
|
||||
| 16 | connection | `POST /v1/instances/me/disconnect`, `POST /v1/instances/me/keys` | the operator disconnects; the connect key is rotated | none (disconnect); public_jwk of the new connect key with two rotation proofs, one signed with the current connect key and one with the new key (rotation; the statistics key is never rotated by this call) | on action | none needed: operator action only |
|
||||
| 17 | statistics | `POST /v1/stats/reports` | statistics module loaded and enabled | the ever.stats.v1 document: statistics instance id, product, version, channel, install source, coarse country, month, allow-listed counts, feature flags and integer aggregates | once a day at a jittered time; at boot when the last send is older than 24 h; a final re-send of the previous month on days 1-3 | EVER_STATS_ENABLED=false, or the settings toggle |
|
||||
| 31 | connection | `POST /v1/instances/me/integrations/{key}/accept` | the operator accepts or declines an installation-wide integration that waits for the local accept | consent_id, accepted | on action | none needed: operator action only |
|
||||
|
||||
<!-- /generated:calls -->
|
||||
|
||||
Every request goes to `EVER_PLATFORM_API_URL` (the statistics: `EVER_STATS_API_URL`), both
|
||||
`https://api.ever.co` by default. No request carries a person's name or e-mail address. The module
|
||||
READMEs say in detail what each request carries and when.
|
||||
|
||||
## Calls of the contract that Gauzy does not make
|
||||
|
||||
The contract describes more calls for Gauzy than this release makes. These are never made:
|
||||
|
||||
<!-- generated:not-made (node tools/ever-platform/outbound-calls.mjs --write, from the pinned contract; do not edit by hand) -->
|
||||
|
||||
| # | Calls | Why Gauzy does not make them |
|
||||
| --- | --- | --- |
|
||||
| 2 | Legal texts | not built in this release |
|
||||
| 14 | Ever ID sign-in client | not served by version 1 of the Ever Platform API |
|
||||
| 18 | Installation address | not built in this release |
|
||||
| 19 | Person links | not served by version 1 of the Ever Platform API |
|
||||
| 20 | Receipt for a deletion or export request | not served by version 1 of the Ever Platform API |
|
||||
| 22 | Identity resolution | not served by version 1 of the Ever Platform API |
|
||||
| 23 | Signed-in person's context | not built in this release |
|
||||
| 25 | Cloud billing link | not served by version 1 of the Ever Platform API |
|
||||
| 27 | Usage reports | not served by version 1 of the Ever Platform API |
|
||||
| 30 | Webhook endpoint | not built in this release |
|
||||
| 33 | In-product consent | not served by version 1 of the Ever Platform API |
|
||||
|
||||
<!-- /generated:not-made -->
|
||||
|
||||
## Older features that call Ever servers
|
||||
|
||||
None of Gauzy's other features calls an Ever server by default. The egress audit (below) checks it
|
||||
on every published build, with both modules off: no Ever host is looked up or requested by the API or
|
||||
by the web app.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **At run time**, the [egress audit](../../tools/egress-audit/README.md) runs the published API and
|
||||
web app images on Docker networks with no route out and records every DNS query and connection
|
||||
attempt, of the API and of a real browser that opens every page of the web app. With both modules
|
||||
off, or loaded but idle, there is none to an Ever host. With a module on, against a mock of Ever
|
||||
Platform, every call made is one of the table above.
|
||||
- **In the code**, `node tools/egress-audit/static-hostnames.mjs` fails when code outside the modules
|
||||
names an Ever host (or the Ever Platform address variables) it did not name before, in the sources
|
||||
and in the built web app; `node tools/ever-platform/check-import-boundary.mjs` fails when code
|
||||
outside the modules imports them.
|
||||
|
||||
## How to verify yourself
|
||||
|
||||
Run the API with both modules off and capture its traffic (for example `tcpdump -i any host api.ever.co`,
|
||||
or a Compose network with `internal: true`): there is none to Ever Platform. Or run the egress audit
|
||||
locally:
|
||||
|
||||
```sh
|
||||
npm ci --prefix tools/egress-audit
|
||||
npx --prefix tools/egress-audit ever-egress-audit --config tools/egress-audit/egress-audit.config.json --mode off
|
||||
```
|
||||
@@ -21,6 +21,7 @@
|
||||
* `TypeError: baseConfig is not iterable` and every `nx lint <project>` failed.
|
||||
*/
|
||||
const nx = require('@nx/eslint-plugin');
|
||||
const everPlatformBoundary = require('./tools/ever-platform/import-boundary.cjs');
|
||||
|
||||
// Files ESLint should never look at. Replaces the legacy root `.eslintrc.json`'s
|
||||
// `"ignorePatterns": ["**/*"]`, which disabled linting for the entire workspace.
|
||||
@@ -50,6 +51,17 @@ const globalIgnores = {
|
||||
]
|
||||
};
|
||||
|
||||
// The import boundary of the optional Ever Platform modules (`@gauzy/plugin-ever-*`): only the
|
||||
// modules and the two plugin lists import them, through their entry points; the rules are in
|
||||
// tools/ever-platform/import-boundary.cjs, which the CI check (check-import-boundary.mjs) reads too.
|
||||
// A rule of its own, so a project config's `no-restricted-imports` never replaces it.
|
||||
const everPlatformImportBoundary = {
|
||||
name: 'gauzy/ever-platform-import-boundary',
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.cts', '**/*.mts', '**/*.js', '**/*.jsx', '**/*.cjs', '**/*.mjs'],
|
||||
plugins: { 'ever-platform': everPlatformBoundary.plugin },
|
||||
rules: { 'ever-platform/import-boundary': 'error' }
|
||||
};
|
||||
|
||||
module.exports = [
|
||||
// Registers the `@nx` plugin namespace (enforce-module-boundaries, dependency-checks,
|
||||
// nx-plugin-checks) that the project configs rely on.
|
||||
@@ -59,6 +71,8 @@ module.exports = [
|
||||
|
||||
globalIgnores,
|
||||
|
||||
everPlatformImportBoundary,
|
||||
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx', '**/*.cts', '**/*.mts', '**/*.js', '**/*.jsx', '**/*.cjs', '**/*.mjs'],
|
||||
rules: {
|
||||
|
||||
+32
-16
@@ -1,37 +1,53 @@
|
||||
<mjml>
|
||||
<mj-body background-color="#fff">
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"></mj-all>
|
||||
<mj-text color="#3d4459" font-size="16px" line-height="26px" padding="0 32px"></mj-text>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body background-color="#f4f5f8" width="600px">
|
||||
<mj-section padding="32px 16px 0">
|
||||
<mj-column background-color="#ffffff" border="1px solid #e6e8ee" border-radius="12px" padding="32px 0 24px">
|
||||
<mj-image
|
||||
width="100px"
|
||||
width="110px"
|
||||
align="left"
|
||||
padding="0 32px"
|
||||
src="{{appLogo}}"
|
||||
></mj-image>
|
||||
<mj-divider
|
||||
border-color="#1B005D"
|
||||
border-width="1px"
|
||||
border-width="2px"
|
||||
width="48px"
|
||||
align="left"
|
||||
padding="24px 32px"
|
||||
></mj-divider>
|
||||
<mj-text font-size="20px" font-family="helvetica">
|
||||
<mj-text color="#1b005d" font-size="24px" font-weight="700" line-height="32px" padding="0 32px 16px">
|
||||
Welcome to {{appName}}!
|
||||
</mj-text>
|
||||
<mj-text font-size="16px" font-family="helvetica">
|
||||
<p>Dear {{email}},</p>
|
||||
<p style="line-height: 1.5rem">
|
||||
<mj-text>
|
||||
<p style="margin: 0 0 16px">Dear {{email}},</p>
|
||||
<p style="margin: 0 0 16px">
|
||||
Thank you for your registration in {{appName}} -
|
||||
Open-Source Business Management Platform (ERP/CRM/HRM).
|
||||
</p>
|
||||
<p>We are happy to have you on board!</p>
|
||||
<p>Best regards,</p>
|
||||
<p>{{appSignature}}</p>
|
||||
<p style="margin: 0 0 24px">We are happy to have you on board!</p>
|
||||
<p style="margin: 0">Best regards,</p>
|
||||
<p style="margin: 4px 0 0; color: #1b005d; font-weight: 600">{{appSignature}}</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section padding="0 16px 32px">
|
||||
<mj-column>
|
||||
<mj-divider
|
||||
border-color="#1B005D"
|
||||
border-color="#e6e8ee"
|
||||
border-width="1px"
|
||||
padding="24px 32px 16px"
|
||||
></mj-divider>
|
||||
<mj-text align="center" font-size="12px" font-family="helvetica">
|
||||
<mj-text align="center" color="#8a91a5" font-size="12px" line-height="18px">
|
||||
© 2019,
|
||||
<a href="{{appLink}}" style="color: #598bff">{{appName}}</a>
|
||||
<a href="{{appLink}}" style="color: #598bff; text-decoration: none">{{appName}}</a>
|
||||
by
|
||||
<a href="{{companyLink}}" style="color: #598bff">{{companyName}}</a>
|
||||
<a href="{{companyLink}}" style="color: #598bff; text-decoration: none">{{companyName}}</a>
|
||||
All rights reserved.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { IPayment, IOrganization, IOrganizationContact, IInvoice } from '@gauzy/contracts';
|
||||
|
||||
import * as moment from 'moment';
|
||||
import 'moment-timezone';
|
||||
import { formatCurrencyAmount } from './invoice-currency.util';
|
||||
|
||||
/**
|
||||
* Formats a stored date as a calendar day of the organization's time zone.
|
||||
*
|
||||
* The forms save a picked day as the browser's local midnight, so "5 October" picked in UTC+2 is
|
||||
* stored as `2026-10-04T22:00:00Z`; formatting that on a UTC server printed 4 October. The
|
||||
* organization's zone is the closest thing the server knows to the zone the day was picked in; an
|
||||
* organization without one falls back to the server zone, as the reports do.
|
||||
*/
|
||||
const formatDay = (date: Date | string, organization: IOrganization): string => {
|
||||
const timeZone = organization?.timeZone?.trim() || moment.tz.guess();
|
||||
return moment.utc(date).tz(timeZone).format(organization?.dateFormat || 'YYYY-MM-DD');
|
||||
};
|
||||
|
||||
export async function generateInvoicePaymentPdfDefinition(
|
||||
invoice: IInvoice,
|
||||
payments: IPayment[],
|
||||
@@ -19,7 +33,8 @@ export async function generateInvoicePaymentPdfDefinition(
|
||||
|
||||
for (const payment of payments) {
|
||||
const currentPayment = [
|
||||
`${moment(invoice.dueDate).format(organization.dateFormat)}`,
|
||||
// The column is headed "Payment Date": print the payment's own date, not the invoice due date.
|
||||
formatDay(payment.paymentDate ?? payment.createdAt, organization),
|
||||
amount(payment.amount),
|
||||
`${payment.createdByUser.name}`,
|
||||
`${payment.note ? payment.note : '-'}`,
|
||||
@@ -85,7 +100,7 @@ export async function generateInvoicePaymentPdfDefinition(
|
||||
bold: true,
|
||||
text: `${translatedText.dueDate}: `
|
||||
},
|
||||
`${moment(invoice.dueDate).format(organization.dateFormat)}`
|
||||
formatDay(invoice.dueDate, organization)
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -63,8 +63,8 @@ const translatedText = {
|
||||
onTime: 'On time'
|
||||
};
|
||||
|
||||
const organization = (currencyPosition?: string) =>
|
||||
({ name: 'Ever Co', dateFormat: 'YYYY-MM-DD', currencyPosition } as any);
|
||||
const organization = (currencyPosition?: string, timeZone = 'UTC') =>
|
||||
({ name: 'Ever Co', dateFormat: 'YYYY-MM-DD', currencyPosition, timeZone } as any);
|
||||
const contact = { name: 'ACME' } as any;
|
||||
|
||||
const invoice = (overrides: Record<string, any> = {}) =>
|
||||
@@ -132,8 +132,20 @@ describe('generateInvoicePdfDefinition', () => {
|
||||
|
||||
describe('generateInvoicePaymentPdfDefinition', () => {
|
||||
const payments = [
|
||||
{ amount: 3, createdByUser: { name: 'Jane' }, note: null, overdue: false },
|
||||
{ amount: 5, createdByUser: { name: 'Joe' }, note: 'wire', overdue: true }
|
||||
{
|
||||
amount: 3,
|
||||
paymentDate: new Date('2026-10-05T12:00:00Z'),
|
||||
createdByUser: { name: 'Jane' },
|
||||
note: null,
|
||||
overdue: false
|
||||
},
|
||||
{
|
||||
amount: 5,
|
||||
paymentDate: new Date('2026-10-20T12:00:00Z'),
|
||||
createdByUser: { name: 'Joe' },
|
||||
note: 'wire',
|
||||
overdue: true
|
||||
}
|
||||
] as any[];
|
||||
|
||||
it('renders each payment, the total value and the total paid with the organization currency position', async () => {
|
||||
@@ -157,4 +169,58 @@ describe('generateInvoicePaymentPdfDefinition', () => {
|
||||
);
|
||||
expect(texts(left)).toEqual(expect.arrayContaining(['USD 3', 'USD 5', 'USD 108', 'USD 8']));
|
||||
});
|
||||
|
||||
it('prints each payment\'s own date under "Payment Date", and the due date only in the header', async () => {
|
||||
const doc = await generateInvoicePaymentPdfDefinition(
|
||||
invoice(),
|
||||
payments,
|
||||
organization(),
|
||||
contact,
|
||||
8,
|
||||
translatedText
|
||||
);
|
||||
const all = texts(doc);
|
||||
|
||||
expect(all).toEqual(expect.arrayContaining(['2026-10-05', '2026-10-20']));
|
||||
expect(all.filter((text) => text === '2026-10-31')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('formats the dates as calendar days of the organization time zone', async () => {
|
||||
// 5 October picked in a UTC+2 browser is stored as the previous day, 22:00 UTC.
|
||||
const payment = { ...payments[0], paymentDate: new Date('2026-10-04T22:00:00Z') };
|
||||
|
||||
const paris = await generateInvoicePaymentPdfDefinition(
|
||||
invoice(),
|
||||
[payment],
|
||||
organization(undefined, 'Europe/Paris'),
|
||||
contact,
|
||||
3,
|
||||
translatedText
|
||||
);
|
||||
expect(texts(paris)).toContain('2026-10-05');
|
||||
|
||||
const utc = await generateInvoicePaymentPdfDefinition(
|
||||
invoice(),
|
||||
[payment],
|
||||
organization(undefined, 'UTC'),
|
||||
contact,
|
||||
3,
|
||||
translatedText
|
||||
);
|
||||
expect(texts(utc)).toContain('2026-10-04');
|
||||
});
|
||||
|
||||
it('falls back to the recording date when a payment carries no payment date', async () => {
|
||||
const createdAt = new Date('2026-10-07T12:00:00Z');
|
||||
const doc = await generateInvoicePaymentPdfDefinition(
|
||||
invoice(),
|
||||
[{ ...payments[0], paymentDate: undefined, createdAt }],
|
||||
organization(),
|
||||
contact,
|
||||
3,
|
||||
translatedText
|
||||
);
|
||||
|
||||
expect(texts(doc)).toContain('2026-10-07');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,8 @@ const InvoiceSchema = new EntitySchema({
|
||||
token: { type: 'text', nullable: true },
|
||||
tenantId: { type: 'varchar', nullable: true },
|
||||
organizationId: { type: 'varchar', nullable: true },
|
||||
status: { type: 'varchar', nullable: true }
|
||||
status: { type: 'varchar', nullable: true },
|
||||
internalNote: { type: 'text', nullable: true }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -111,6 +112,18 @@ describe('PublicInvoiceService.findOneByConditions (GET, GHSA-28wv-vrxj-rp4q)',
|
||||
await expect(service.findOneByConditions({ id: VICTIM.id, token })).resolves.toMatchObject({ id: VICTIM.id });
|
||||
});
|
||||
|
||||
it('never serves the internal note to the share-link holder — CONTROL: the stored row carries one', async () => {
|
||||
await invoices.update(VICTIM.id, { internalNote: 'Margin 40% - do not discount' });
|
||||
const token = await storedToken(VICTIM.id);
|
||||
|
||||
expect((await invoices.findOneBy({ id: VICTIM.id })).internalNote).toBe('Margin 40% - do not discount');
|
||||
|
||||
const served = await service.findOneByConditions({ id: VICTIM.id, token });
|
||||
|
||||
expect(served).toMatchObject({ id: VICTIM.id, status: 'SENT' });
|
||||
expect(served).not.toHaveProperty('internalNote');
|
||||
});
|
||||
|
||||
it('keeps accepting a link mailed before share tokens were typed (it equals the stored token)', async () => {
|
||||
const legacy = sign({ id: VICTIM.id, organizationId: ORG_B, tenantId: TENANT_B }, environment.JWT_SECRET);
|
||||
await invoices.update(VICTIM.id, { token: legacy });
|
||||
|
||||
@@ -55,7 +55,7 @@ export class PublicInvoiceService {
|
||||
|
||||
try {
|
||||
// Get invoice
|
||||
return await this.typeOrmInvoiceRepository.findOneOrFail({
|
||||
let invoice = await this.typeOrmInvoiceRepository.findOneOrFail({
|
||||
select: {
|
||||
tenant: {
|
||||
name: true,
|
||||
@@ -128,6 +128,12 @@ export class PublicInvoiceService {
|
||||
},
|
||||
...(relations ? { relations: parseFindOptionsRelations(relations) } : {})
|
||||
});
|
||||
|
||||
// `select` above names relations only, so TypeORM selects every invoice column. The internal note is
|
||||
// written for the organization, never for whoever holds the share link, so it is not served here.
|
||||
delete invoice.internalNote;
|
||||
|
||||
return invoice;
|
||||
} catch {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ describe('TaskService.findTeamTasks (MikroORM) — employee scoping', () => {
|
||||
let createQueryBuilder: jest.Mock;
|
||||
let service: TaskService;
|
||||
|
||||
const teamTasks = (members?: { id: string }) =>
|
||||
const teamTasks = (members?: { id: string }, filters?: Record<string, string[]>) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
service.findTeamTasks({ where: { organizationId: employee.organizationId, members } } as any);
|
||||
service.findTeamTasks({ where: { organizationId: employee.organizationId, members }, filters } as any);
|
||||
const where = () => findAndCount.mock.calls[0][0];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -90,6 +90,42 @@ describe('TaskService.findTeamTasks (MikroORM) — employee scoping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The advanced `filters` (projects, tags, statuses, ...) were only applied by the TypeORM branch; the
|
||||
* MikroORM branch read them and dropped them, so a filtered team board listed every team task.
|
||||
*/
|
||||
describe('advanced filters', () => {
|
||||
it('translates the column and relation filters into the MikroORM where', async () => {
|
||||
({ restore } = asTenantUser(employee, { permissions: [PermissionsEnum.CHANGE_SELECTED_EMPLOYEE] }));
|
||||
|
||||
await teamTasks(undefined, { projects: ['project-1'], tags: ['tag-1', 'tag-2'], statusIds: ['status-1'] });
|
||||
|
||||
expect(where()).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: { $in: ['project-1'] },
|
||||
tags: { id: { $in: ['tag-1', 'tag-2'] } },
|
||||
taskStatusId: { $in: ['status-1'] }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the employee team scoping next to a teams filter', async () => {
|
||||
({ restore } = asTenantUser(employee));
|
||||
|
||||
await teamTasks(undefined, { teams: ['team-1'] });
|
||||
|
||||
expect(where().teams).toEqual({ members: { employeeId: 'employee-1' }, id: { $in: ['team-1'] } });
|
||||
});
|
||||
|
||||
it('applies nothing without filters', async () => {
|
||||
({ restore } = asTenantUser(employee, { permissions: [PermissionsEnum.CHANGE_SELECTED_EMPLOYEE] }));
|
||||
|
||||
await teamTasks();
|
||||
|
||||
expect(where()).toEqual({ tenantId: employee.tenantId, organizationId: employee.organizationId, isScreeningTask: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('an ALL_ORG_VIEW holder without an employee record', () => {
|
||||
it('keeps the organization-wide team tasks on MikroORM, without picking an employee', async () => {
|
||||
({ restore } = asTenantUser(noEmployee, { permissions: [PermissionsEnum.ALL_ORG_VIEW] }));
|
||||
|
||||
@@ -38,7 +38,13 @@ import { isPostgres, isSqlite } from '@gauzy/config';
|
||||
import { TenantAwareCrudService, BaseQueryDTO } from './../core/crud';
|
||||
import { IPartialEntity } from './../core/crud/icrud.service';
|
||||
import { sanitizeRichHtml } from './../core/html-sanitizer';
|
||||
import { mikroOrmContains, MultiORMEnum, parseFindOptionsRelations, parseFindOptionsSelect } from './../core/utils';
|
||||
import {
|
||||
mikroOrmContains,
|
||||
MultiORMEnum,
|
||||
parseFindOptionsRelations,
|
||||
parseFindOptionsSelect,
|
||||
parseTypeORMFindToMikroOrm
|
||||
} from './../core/utils';
|
||||
import { addBetween, LIKE_OPERATOR } from './../core/util';
|
||||
import { RequestContext } from '../core/context';
|
||||
import { TaskViewService } from './views/view.service';
|
||||
@@ -802,6 +808,20 @@ export class TaskService extends TenantAwareCrudService<Task> {
|
||||
mikroWhere.teams = { ...mikroWhere.teams, members: { employeeId } };
|
||||
}
|
||||
|
||||
// The advanced filters (projects, tags, statuses, members, ...) were only applied by the
|
||||
// TypeORM branch; here they were read and dropped. Build the same TypeORM where and
|
||||
// translate it, merging the `teams` predicate with the team scoping above.
|
||||
if (filters) {
|
||||
const { where: advancedWhere } = parseTypeORMFindToMikroOrm<Task>({
|
||||
where: this.buildAdvancedWhereCondition(filters, where)
|
||||
});
|
||||
const { teams: advancedTeams, ...advancedRest } = advancedWhere as any;
|
||||
Object.assign(mikroWhere, advancedRest);
|
||||
if (advancedTeams) {
|
||||
mikroWhere.teams = { ...mikroWhere.teams, ...advancedTeams };
|
||||
}
|
||||
}
|
||||
|
||||
const [items, total] = await this.mikroOrmRepository.findAndCount(mikroWhere, {
|
||||
...(options.relations ? { populate: Object.keys(options.relations) as any[] } : {}),
|
||||
...(options.order ? { orderBy: options.order as any } : {}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
FileStorageProviderEnum,
|
||||
@@ -27,6 +27,7 @@ import { TypeOrmUserRepository } from '../user/repository/type-orm-user.reposito
|
||||
import { MikroOrmUserRepository } from '../user/repository/mikro-orm-user.repository';
|
||||
import { TypeOrmTenantRepository } from './repository/type-orm-tenant.repository';
|
||||
import { MikroOrmTenantRepository } from './repository/mikro-orm-tenant.repository';
|
||||
import { ImageAsset } from '../image-asset/image-asset.entity';
|
||||
import { Tenant } from './tenant.entity';
|
||||
import { StripeSubscriptionService, describeError } from '../shared/billing/stripe-subscription.service';
|
||||
|
||||
@@ -325,14 +326,21 @@ export class TenantService extends CrudService<Tenant> {
|
||||
* `persist: false`, see `column.helper.ts`), so a plain `update({ imageId })` leaves the logo
|
||||
* unchanged there. Write the relation instead; `null` clears it. TypeORM writes the column directly.
|
||||
*
|
||||
* A new logo must be an image asset of this tenant: an asset id is a bare UUID, and without the check a
|
||||
* Super Admin could point their tenant's logo at another tenant's upload.
|
||||
*
|
||||
* @param id - The tenant to update.
|
||||
* @param input - The new name and logo; an omitted `imageId` leaves the image as it is.
|
||||
* @throws BadRequestException when `imageId` names an image asset outside the tenant.
|
||||
*/
|
||||
async updateProfile(id: ID, input: ITenantUpdateInput): Promise<ITenant | UpdateResult> {
|
||||
const { imageId, ...rest } = input;
|
||||
if (imageId === undefined) {
|
||||
return await this.update(id, rest);
|
||||
}
|
||||
if (imageId) {
|
||||
await this.assertImageInTenant(imageId, id);
|
||||
}
|
||||
if (this.ormType === MultiORMEnum.MikroORM) {
|
||||
// MikroORM's nativeUpdate takes the related row's primary key for a many-to-one.
|
||||
const row: Record<string, unknown> = { ...rest, image: imageId ?? null };
|
||||
@@ -341,6 +349,24 @@ export class TenantService extends CrudService<Tenant> {
|
||||
return await this.update(id, { ...rest, imageId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses an image asset that does not belong to the given tenant.
|
||||
*
|
||||
* @param imageId - The image asset the tenant's logo would point at.
|
||||
* @param tenantId - The tenant being updated.
|
||||
* @throws BadRequestException when no image asset with that id belongs to the tenant.
|
||||
*/
|
||||
private async assertImageInTenant(imageId: ID, tenantId: ID): Promise<void> {
|
||||
const where = { id: imageId, tenantId };
|
||||
const exists =
|
||||
this.ormType === MultiORMEnum.MikroORM
|
||||
? (await this.mikroOrmRepository.getEntityManager().count(ImageAsset, where)) > 0
|
||||
: await this.typeOrmRepository.manager.getRepository(ImageAsset).existsBy(where);
|
||||
if (!exists) {
|
||||
throw new BadRequestException('The image does not belong to this tenant.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a set of update tasks for a given tenant in parallel.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 🛑 This import must stay FIRST, before any import that pulls a core service — the entity graph has
|
||||
* to finish initializing before TenantService (and the repositories it imports) are evaluated.
|
||||
*/
|
||||
import '../core/entities/internal';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ImageAsset } from '../image-asset/image-asset.entity';
|
||||
import { MultiORMEnum } from '../core/utils';
|
||||
import { TenantService } from './tenant.service';
|
||||
|
||||
/**
|
||||
* `PUT /tenant` sets the caller's tenant name and logo. The logo is an image asset id, a bare UUID, so the
|
||||
* service must refuse an asset that belongs to another tenant before it links it.
|
||||
*/
|
||||
|
||||
const TENANT_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const OWN_IMAGE_ID = '44444444-4444-4444-8444-444444444444';
|
||||
const FOREIGN_IMAGE_ID = '55555555-5555-4555-8555-555555555555';
|
||||
|
||||
/** Image assets by id, with the tenant that owns each. */
|
||||
const IMAGES: Record<string, string> = {
|
||||
[OWN_IMAGE_ID]: TENANT_ID,
|
||||
[FOREIGN_IMAGE_ID]: '22222222-2222-4222-8222-222222222222'
|
||||
};
|
||||
|
||||
const ownedBy = (where: { id: string; tenantId: string }) => IMAGES[where.id] === where.tenantId;
|
||||
|
||||
function buildService(orm: string = MultiORMEnum.TypeORM) {
|
||||
const existsBy = jest.fn(async (where: { id: string; tenantId: string }) => ownedBy(where));
|
||||
const getRepository = jest.fn(() => ({ existsBy }));
|
||||
const count = jest.fn(async (_entity: unknown, where: { id: string; tenantId: string }) => (ownedBy(where) ? 1 : 0));
|
||||
|
||||
const service: any = Object.create(TenantService.prototype);
|
||||
Object.defineProperty(service, 'ormType', { get: () => orm });
|
||||
service.typeOrmRepository = { manager: { getRepository } };
|
||||
service.mikroOrmRepository = { getEntityManager: () => ({ count }) };
|
||||
service.update = jest.fn(async () => ({ affected: 1 }));
|
||||
return { service: service as TenantService & { update: jest.Mock }, existsBy, getRepository, count };
|
||||
}
|
||||
|
||||
describe('TenantService.updateProfile — the logo must be an image of the same tenant', () => {
|
||||
describe('TypeORM', () => {
|
||||
it('links an image asset the tenant owns', async () => {
|
||||
const { service, existsBy, getRepository } = buildService();
|
||||
|
||||
await service.updateProfile(TENANT_ID, { name: 'Acme', imageId: OWN_IMAGE_ID });
|
||||
|
||||
expect(getRepository).toHaveBeenCalledWith(ImageAsset);
|
||||
expect(existsBy).toHaveBeenCalledWith({ id: OWN_IMAGE_ID, tenantId: TENANT_ID });
|
||||
expect(service.update).toHaveBeenCalledWith(TENANT_ID, { name: 'Acme', imageId: OWN_IMAGE_ID });
|
||||
});
|
||||
|
||||
it("refuses another tenant's image asset and writes nothing", async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
await expect(service.updateProfile(TENANT_ID, { name: 'Acme', imageId: FOREIGN_IMAGE_ID })).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
expect(service.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an id that names no image asset', async () => {
|
||||
const { service } = buildService();
|
||||
|
||||
await expect(
|
||||
service.updateProfile(TENANT_ID, { name: 'Acme', imageId: '66666666-6666-4666-8666-666666666666' })
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(service.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the logo with null without looking up an image', async () => {
|
||||
const { service, existsBy } = buildService();
|
||||
|
||||
await service.updateProfile(TENANT_ID, { name: 'Acme', imageId: null });
|
||||
|
||||
expect(existsBy).not.toHaveBeenCalled();
|
||||
expect(service.update).toHaveBeenCalledWith(TENANT_ID, { name: 'Acme', imageId: null });
|
||||
});
|
||||
|
||||
it('leaves the logo alone when imageId is omitted', async () => {
|
||||
const { service, existsBy } = buildService();
|
||||
|
||||
await service.updateProfile(TENANT_ID, { name: 'Acme' });
|
||||
|
||||
expect(existsBy).not.toHaveBeenCalled();
|
||||
expect(service.update).toHaveBeenCalledWith(TENANT_ID, { name: 'Acme' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('MikroORM', () => {
|
||||
it('links an image asset the tenant owns through the relation', async () => {
|
||||
const { service, count } = buildService(MultiORMEnum.MikroORM);
|
||||
|
||||
await service.updateProfile(TENANT_ID, { name: 'Acme', imageId: OWN_IMAGE_ID });
|
||||
|
||||
expect(count).toHaveBeenCalledWith(ImageAsset, { id: OWN_IMAGE_ID, tenantId: TENANT_ID });
|
||||
expect(service.update).toHaveBeenCalledWith(TENANT_ID, { name: 'Acme', image: OWN_IMAGE_ID });
|
||||
});
|
||||
|
||||
it("refuses another tenant's image asset and writes nothing", async () => {
|
||||
const { service } = buildService(MultiORMEnum.MikroORM);
|
||||
|
||||
await expect(service.updateProfile(TENANT_ID, { name: 'Acme', imageId: FOREIGN_IMAGE_ID })).rejects.toBeInstanceOf(
|
||||
BadRequestException
|
||||
);
|
||||
expect(service.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The screenshot notification used to be placed once, when its window was created, from the primary
|
||||
* display's width alone: after the displays were re-arranged while the timer ran it stayed on
|
||||
* whatever monitor that old position fell into (#7538), and a primary display not at (0, 0) put it on
|
||||
* a neighbouring monitor from the start.
|
||||
*
|
||||
* Electron cannot run here: `screen` and the desktop-core window plumbing are mocked.
|
||||
*/
|
||||
let primaryDisplay = { workArea: { x: 0, y: 0, width: 1920, height: 1040 } };
|
||||
|
||||
jest.mock('electron', () => ({ screen: { getPrimaryDisplay: () => primaryDisplay } }), { virtual: true });
|
||||
|
||||
const browserWindow = {
|
||||
setPosition: jest.fn(),
|
||||
showInactive: jest.fn(),
|
||||
setVisibleOnAllWorkspaces: jest.fn(),
|
||||
setAlwaysOnTop: jest.fn(),
|
||||
setFullScreenable: jest.fn(),
|
||||
webContents: { send: jest.fn() }
|
||||
};
|
||||
const windowOptions: any[] = [];
|
||||
|
||||
jest.mock('@gauzy/desktop-core', () => ({
|
||||
BaseWindow: class {
|
||||
get browserWindow() {
|
||||
return browserWindow;
|
||||
}
|
||||
hide() {}
|
||||
},
|
||||
DefaultWindow: class {},
|
||||
WindowConfig: class {
|
||||
constructor(_path: string, _file: string, options: any) {
|
||||
windowOptions.push(options);
|
||||
}
|
||||
},
|
||||
WindowManager: { getInstance: () => ({ overrideSystemContextMenu: jest.fn(), register: jest.fn() }) },
|
||||
RegisteredWindow: { CAPTURE: 'capture' },
|
||||
store: { get: () => ({ note: 'note' }) }
|
||||
}));
|
||||
|
||||
import { ScreenCaptureNotification } from './screen-capture-notification';
|
||||
|
||||
describe('ScreenCaptureNotification placement', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
windowOptions.length = 0;
|
||||
primaryDisplay = { workArea: { x: 0, y: 0, width: 1920, height: 1040 } };
|
||||
});
|
||||
|
||||
it('is created in the top-right corner of the primary display work area, origin included', () => {
|
||||
primaryDisplay = { workArea: { x: 2560, y: 100, width: 1920, height: 1040 } };
|
||||
|
||||
new ScreenCaptureNotification();
|
||||
|
||||
expect(windowOptions[0]).toEqual(expect.objectContaining({ x: 2560 + 1920 - (310 + 16), y: 116 }));
|
||||
});
|
||||
|
||||
it('moves back onto the current primary display before every show()', () => {
|
||||
const notification = new ScreenCaptureNotification();
|
||||
|
||||
notification.show('thumb');
|
||||
expect(browserWindow.setPosition).toHaveBeenLastCalledWith(1920 - (310 + 16), 16);
|
||||
|
||||
// The displays are re-arranged while the app runs: a new, offset primary display.
|
||||
primaryDisplay = { workArea: { x: -1920, y: 0, width: 1920, height: 1080 } };
|
||||
notification.show('thumb');
|
||||
|
||||
expect(browserWindow.setPosition).toHaveBeenLastCalledWith(-1920 + 1920 - (310 + 16), 16);
|
||||
expect(browserWindow.setPosition.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
browserWindow.showInactive.mock.invocationCallOrder[1]
|
||||
);
|
||||
expect(browserWindow.webContents.send).toHaveBeenCalledWith('show_popup_screen_capture', {
|
||||
note: 'note',
|
||||
imgUrl: 'thumb'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,8 +38,7 @@ export class ScreenCaptureNotification extends BaseWindow implements IBaseWindow
|
||||
center: false, // Prevents the window from being centered
|
||||
focusable: false, // Makes the window non-focusable
|
||||
skipTaskbar: true, // Excludes the window from the taskbar
|
||||
x: screen.getPrimaryDisplay().size.width - (ScreenCaptureNotification.WIDTH + 16), // Sets the X position
|
||||
y: 16, // Sets the Y position
|
||||
...ScreenCaptureNotification.primaryDisplayPosition(), // Top-right corner of the primary display
|
||||
...(preloadPath
|
||||
? {
|
||||
webPreferences: {
|
||||
@@ -85,6 +84,13 @@ export class ScreenCaptureNotification extends BaseWindow implements IBaseWindow
|
||||
public show(thumbUrl?: string): void {
|
||||
if (!this.browserWindow) return;
|
||||
|
||||
// The position was computed when the window was created. Displays get re-arranged while the
|
||||
// app runs (sleep/wake, a monitor plugged in or out, a new primary), after which the window
|
||||
// stayed on whatever monitor that old position now falls into (#7538): put it back on the
|
||||
// primary display every time it is shown.
|
||||
const { x, y } = ScreenCaptureNotification.primaryDisplayPosition();
|
||||
this.browserWindow.setPosition(x, y);
|
||||
|
||||
// Display the browser window in an inactive state
|
||||
this.browserWindow.showInactive();
|
||||
|
||||
@@ -95,6 +101,20 @@ export class ScreenCaptureNotification extends BaseWindow implements IBaseWindow
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-right corner of the primary display's work area, 16 px from its edges.
|
||||
*
|
||||
* Uses the work area's own origin: the primary display is not always at (0, 0) in the virtual
|
||||
* screen, and `size.width` alone landed the window on a neighbouring monitor in that case.
|
||||
*/
|
||||
private static primaryDisplayPosition(): { x: number; y: number } {
|
||||
const { workArea } = screen.getPrimaryDisplay();
|
||||
return {
|
||||
x: workArea.x + workArea.width - (ScreenCaptureNotification.WIDTH + 16),
|
||||
y: workArea.y + 16
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the browser window after a delay.
|
||||
*
|
||||
|
||||
@@ -96,7 +96,11 @@
|
||||
"FEATURES": "Features",
|
||||
"EXPIRES": "Valid until",
|
||||
"NONE": "No entitlement document yet.",
|
||||
"REFRESH": "Refresh"
|
||||
"REFRESH": "Refresh",
|
||||
"LICENCE_ACTIVE": "Licence {{ id }} active",
|
||||
"GRACE": "Entitlements could not be refreshed since {{ date }}. Ever Platform features keep working during the grace period.",
|
||||
"PAUSED": "Ever Platform features paused. Reconnect to Ever Platform.",
|
||||
"IMPORT": "Import file"
|
||||
},
|
||||
"AUDIT": {
|
||||
"MORE": "Show more"
|
||||
@@ -112,7 +116,9 @@
|
||||
"CONSENT_OPENED": "app.ever.co opened in a new tab. Reopen this page afterwards to see the result.",
|
||||
"DISABLED": "Disabled.",
|
||||
"DISABLED_OFFLINE": "Disabled here. Ever Platform has not taken it yet; it is told again until it does.",
|
||||
"KEY_ROTATED": "The connection key was replaced."
|
||||
"KEY_ROTATED": "The connection key was replaced.",
|
||||
"ENTITLEMENT_IMPORTED": "Entitlement document #{{ seq }} imported.",
|
||||
"ENTITLEMENT_UNCHANGED": "This entitlement document (#{{ seq }}) is already the current one."
|
||||
},
|
||||
"ERRORS": {
|
||||
"key_material_missing": "Set ENCRYPTION_KEY (or a strong, unique JWT_SECRET) on the API first. Nothing was sent.",
|
||||
@@ -131,6 +137,7 @@
|
||||
"entitlement_unverifiable": "Ever Platform's entitlement document could not be verified. The installation was not connected.",
|
||||
"platform_unreachable": "Ever Platform could not be reached. Please try again later.",
|
||||
"rate_limited": "Too many requests. Please try again later.",
|
||||
"generic": "The request failed ({{ code }})."
|
||||
"generic": "The request failed ({{ code }}).",
|
||||
"entitlement_invalid": "The entitlement document was refused: it does not name this installation, it is older than the current one, or it does not verify."
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -1,3 +1,4 @@
|
||||
// cspell:ignore abcdefghijk
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { Store } from '@gauzy/ui-core/core';
|
||||
@@ -133,4 +134,60 @@ describe('EverPlatformPageComponent', () => {
|
||||
expect(component.canAskConsent({ instance_wide: false, state: 'denied_by_policy' } as never)).toBe(false);
|
||||
expect(component.canAskConsent({ instance_wide: false, state: 'enabled' } as never)).toBe(false);
|
||||
});
|
||||
it('the Entitlements tab shows each licence as active, the grace notice, and the import for the operator only', () => {
|
||||
const document = {
|
||||
subject: 'link',
|
||||
status: 'stale',
|
||||
ladder: 'grace',
|
||||
licence_ids: ['EVER-GAUZY-SB-1A2B3C4D'],
|
||||
seq: 4,
|
||||
issued_at: '2026-10-01T00:00:00.000Z',
|
||||
expires_at: '2026-10-08T00:00:00.000Z',
|
||||
fetched_at: '2026-10-02T00:00:00.000Z',
|
||||
handle: 'acme',
|
||||
tier: 'paid',
|
||||
plan: 'gauzy-team',
|
||||
features: { discoverability: true },
|
||||
limits: {},
|
||||
meters: {}
|
||||
};
|
||||
api.entitlement.mockReturnValue(of({ instance: null, link: document }));
|
||||
api.status.mockReturnValue(of(STATUS));
|
||||
const { el, find } = render();
|
||||
const licences = Array.from(el.querySelectorAll('[data-test="licence"]'));
|
||||
expect(licences.map((node) => node.getAttribute('data-licence'))).toEqual(['EVER-GAUZY-SB-1A2B3C4D']);
|
||||
expect(licences[0].textContent).toContain('EVER_CONNECT.ENTITLEMENTS.LICENCE_ACTIVE');
|
||||
expect(el.textContent?.toLowerCase()).not.toContain('licence key');
|
||||
expect(find('entitlement-grace')).not.toBeNull();
|
||||
expect(find('entitlement-paused')).toBeNull();
|
||||
expect(find('import-entitlement')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('another administrator gets no import, and a paused document says so', () => {
|
||||
api.entitlement.mockReturnValue(
|
||||
of({
|
||||
instance: null,
|
||||
link: {
|
||||
subject: 'link',
|
||||
status: 'paused',
|
||||
ladder: 'paused',
|
||||
licence_ids: [],
|
||||
seq: 1,
|
||||
issued_at: null,
|
||||
expires_at: null,
|
||||
fetched_at: null,
|
||||
handle: null,
|
||||
tier: null,
|
||||
plan: null,
|
||||
features: {},
|
||||
limits: {},
|
||||
meters: {}
|
||||
}
|
||||
})
|
||||
);
|
||||
api.status.mockReturnValue(of({ ...STATUS, operator: false, connection: null }));
|
||||
const { find } = render();
|
||||
expect(find('entitlement-paused')).not.toBeNull();
|
||||
expect(find('import-entitlement')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+62
@@ -476,7 +476,33 @@ export type EverPlatformView = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
</dd>
|
||||
<dt>{{ 'EVER_CONNECT.ENTITLEMENTS.EXPIRES' | translate }}</dt>
|
||||
<dd>{{ document.expires_at | date: 'medium' }} (#{{ document.seq }})</dd>
|
||||
@for (licence of document.licence_ids ?? []; track licence) {
|
||||
<dd class="licence" data-test="licence" [attr.data-licence]="licence">
|
||||
{{
|
||||
'EVER_CONNECT.ENTITLEMENTS.LICENCE_ACTIVE'
|
||||
| translate: { id: licence }
|
||||
}}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
@if (document.ladder === 'grace') {
|
||||
<p class="hint" data-test="entitlement-grace">
|
||||
{{
|
||||
'EVER_CONNECT.ENTITLEMENTS.GRACE'
|
||||
| translate
|
||||
: {
|
||||
date:
|
||||
(document.fetched_at | date: 'mediumDate') ??
|
||||
'—'
|
||||
}
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
@if (document.ladder === 'paused') {
|
||||
<p class="hint" data-test="entitlement-paused">
|
||||
{{ 'EVER_CONNECT.ENTITLEMENTS.PAUSED' | translate }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (!entitlement?.link && !entitlement?.instance) {
|
||||
@@ -493,6 +519,17 @@ export type EverPlatformView = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
>
|
||||
{{ 'EVER_CONNECT.ENTITLEMENTS.REFRESH' | translate }}
|
||||
</button>
|
||||
@if (status?.operator && status?.connected && status?.managed_by !== 'ever_cloud') {
|
||||
<label class="import" data-test="import-entitlement">
|
||||
{{ 'EVER_CONNECT.ENTITLEMENTS.IMPORT' | translate }}
|
||||
<input
|
||||
type="file"
|
||||
accept=".jws,text/plain"
|
||||
[disabled]="busy"
|
||||
(change)="importEntitlement($event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
</nb-tab>
|
||||
<nb-tab [tabTitle]="'EVER_CONNECT.TABS.AUDIT' | translate" data-test="tab-audit">
|
||||
<table class="table" data-test="audit">
|
||||
@@ -736,6 +773,30 @@ export class EverPlatformPageComponent implements OnInit {
|
||||
this.run(this.api.refreshEntitlement(this.organizationId), () => undefined);
|
||||
}
|
||||
|
||||
/** Operator only: reads the chosen `.jws` file (at most 16 KiB) and imports it. */
|
||||
importEntitlement(event: Event): void {
|
||||
const input = event.target as HTMLInputElement | null;
|
||||
const file = input?.files?.[0];
|
||||
if (!file) return;
|
||||
if (input) input.value = '';
|
||||
if (file.size > 16 * 1024) {
|
||||
this.say('EVER_CONNECT.ERRORS.entitlement_invalid', {}, 'danger');
|
||||
return;
|
||||
}
|
||||
file.text()
|
||||
.then((jws) =>
|
||||
this.run(this.api.importEntitlement(jws), (result) =>
|
||||
this.say(
|
||||
result.status === 'stored'
|
||||
? 'EVER_CONNECT.NOTICES.ENTITLEMENT_IMPORTED'
|
||||
: 'EVER_CONNECT.NOTICES.ENTITLEMENT_UNCHANGED',
|
||||
{ seq: result.seq }
|
||||
)
|
||||
)
|
||||
)
|
||||
.catch(() => this.say('EVER_CONNECT.ERRORS.entitlement_invalid', {}, 'danger'));
|
||||
}
|
||||
|
||||
moreAudit(): void {
|
||||
if (!this.organizationId) return;
|
||||
this.api.audit(this.organizationId, this.auditPage + 1).subscribe((page) => {
|
||||
@@ -786,6 +847,7 @@ export class EverPlatformPageComponent implements OnInit {
|
||||
'denied_by_policy',
|
||||
'keys_unverifiable',
|
||||
'entitlement_unverifiable',
|
||||
'entitlement_invalid',
|
||||
'platform_unreachable',
|
||||
'rate_limited',
|
||||
'not_available',
|
||||
|
||||
@@ -85,6 +85,10 @@ export interface EverConnectIntegration {
|
||||
export interface EverConnectEntitlement {
|
||||
subject: 'instance' | 'link';
|
||||
status: 'valid' | 'stale' | 'paused';
|
||||
/** valid; grace (could not be refreshed, features still apply); paused (Ever Platform features off). */
|
||||
ladder: 'valid' | 'grace' | 'paused';
|
||||
/** Licence certificate ids the document names, shown as "Licence EVER-… active". */
|
||||
licence_ids: string[];
|
||||
seq: number | null;
|
||||
issued_at: string | null;
|
||||
expires_at: string | null;
|
||||
@@ -221,6 +225,16 @@ export class EverConnectUiService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Operator only: imports a downloaded entitlement document (`.jws`). */
|
||||
importEntitlement(
|
||||
jws: string
|
||||
): Observable<{ subject: 'instance' | 'link'; seq: number; status: 'stored' | 'unchanged' }> {
|
||||
return this.http.post<{ subject: 'instance' | 'link'; seq: number; status: 'stored' | 'unchanged' }>(
|
||||
`${BASE}/entitlement/import`,
|
||||
{ jws }
|
||||
);
|
||||
}
|
||||
|
||||
audit(organizationId: string, page: number): Observable<{ items: EverConnectAuditRow[]; total: number }> {
|
||||
return this.http.get<{ items: EverConnectAuditRow[]; total: number }>(`${BASE}/audit`, {
|
||||
params: new HttpParams().set('organizationId', organizationId).set('page', String(page)).set('limit', '20')
|
||||
|
||||
@@ -75,6 +75,14 @@ The definitions (what each one moves, why, how often, what Ever Platform keeps)
|
||||
- **Replace key** (Connection tab) makes a new connection key and installs it on Ever Platform with two proofs, one signed with each key; the installation stays connected.
|
||||
- When `ENCRYPTION_KEY` or `JWT_SECRET` changes, the connection key can no longer be read: nothing can be sent, and the Connection tab says so. Disconnect, then connect again with a new code: a new key is made.
|
||||
|
||||
### Entitlements
|
||||
|
||||
Ever Platform signs an entitlement document for the installation and one for each linked organization; the module verifies each one (issuer, key, signature, schema, this installation and the expected organization link, never older than the stored one) before it stores it, and refreshes them with the heartbeat, on the feed's entitlement events and on demand (*Refresh*, at most 6 an hour).
|
||||
|
||||
- **Grace ladder**: a document is *valid* until it expires (7 days after it was issued), then in *grace* for 30 days (or the `grace_s` it names): its Ever Platform features still apply and the Entitlements tab says since when it could not be refreshed. After that, or without a document, or once the installation was revoked, the Ever Platform features of that organization are *paused*. Nothing else of Gauzy depends on a document: every other feature, route and setting works the same with or without one.
|
||||
- **Licence line**: each licence certificate id a document names is shown as "Licence EVER-… active". It is shown, never checked by any feature.
|
||||
- **Offline import**: an installation without a route to Ever Platform imports a downloaded document, from the Entitlements tab (the operator, `POST /api/ever-connect/entitlement/import` with `{ "jws": "…" }`, at most 16 KiB) or with `EVER_ENTITLEMENT_FILE` at start. The same checks apply; the document must name this installation or one of its organization links (otherwise 422), and the installation must have been connected once (otherwise 409). A document is stored only if no other was stored while it was verified (compare and set; otherwise 409 `entitlement_changed`, import again). Stored and refused documents are audited, never the document itself.
|
||||
|
||||
### What is stored
|
||||
|
||||
Tables `ever_connect_connection`, `ever_connect_link`, `ever_connect_integration`, `ever_connect_policy`, `ever_connect_audit` and `ever_connect_lookup_cache` (see [MIGRATIONS.md](MIGRATIONS.md)); a link is also recorded as an `integration_tenant` named `Ever_Connect`. The connection key (in `ever_instance`) and the entitlement documents are stored encrypted (AES-256-GCM, from `ENCRYPTION_KEY`, else from a non-default `JWT_SECRET`); connecting is refused without one of them. The audit holds ids and states only. None of it is included in export archives.
|
||||
@@ -91,11 +99,12 @@ Tables `ever_connect_connection`, `ever_connect_link`, `ever_connect_integration
|
||||
| `EVER_CONNECT_CODE` | unset | a connect code used once at the first start; any answer uses it up, only a network failure is retried |
|
||||
| `EVER_CONNECT_INTEGRATIONS_DENY` | unset | integration keys denied for every organization (comma separated, or `*`) |
|
||||
| `EVER_CONNECT_FEED_MODE` | `longpoll` | `interval`: one read of the event feed every 15 minutes |
|
||||
| `EVER_ENTITLEMENT_FILE` | unset | a downloaded entitlement document (`.jws`), imported once when the connection starts, for an installation without a route to Ever Platform; the operator can also import one on the Entitlements tab |
|
||||
| `EVER_INSTALL_SOURCE` | `self-hosted` | how the installation is deployed, declared by the operator; `cloud` means Ever operates it |
|
||||
| `EVER_OPERATOR_USER_IDS`, `EVER_OPERATOR_EMAILS` | unset | the operators of the installation (shared with the statistics module) |
|
||||
| `ENCRYPTION_KEY` | unset | the key the connection key and documents are stored under (else a non-default `JWT_SECRET`) |
|
||||
| `CLIENT_BASE_URL` | | the web app address app.ever.co may send an administrator back to (only its origin is declared; https, or plain http on `localhost`, `127.0.0.1` or `[::1]`, else not sent) |
|
||||
| `EVER_PLATFORM_ISSUER`, `EVER_PLATFORM_ROOT_KEYS_FILE` | unset | tests against a mock platform only: honoured only when `EVER_PLATFORM_API_URL` is a loopback address (`localhost`, `127.0.0.0/8`, `::1`) |
|
||||
| `EVER_PLATFORM_ISSUER`, `EVER_PLATFORM_ROOT_KEYS_FILE` | unset | tests against a mock platform only: honoured only when `EVER_PLATFORM_API_URL` is a local or private address (`localhost`, `127.0.0.0/8`, `::1`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), as the SDK honours them; never for a public address |
|
||||
|
||||
## How to verify yourself
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"@nestjs/core": "^11.1.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ever-co/connect-contracts": "1.0.0-rc.3",
|
||||
"@ever-co/connect-sdk": "1.0.0-rc.3",
|
||||
"@ever-co/connect-contracts": "1.0.0-rc.6",
|
||||
"@ever-co/connect-sdk": "1.0.0-rc.6",
|
||||
"@gauzy/common": "^0.1.0",
|
||||
"@gauzy/constants": "^0.1.0",
|
||||
"@gauzy/contracts": "^0.1.0",
|
||||
@@ -45,7 +45,7 @@
|
||||
"typeorm": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ever-co/connect-tools": "1.0.0-rc.3",
|
||||
"@ever-co/connect-tools": "1.0.0-rc.6",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('readEverConnectConfig', () => {
|
||||
returnUrl: null,
|
||||
returnUnusable: false,
|
||||
issuer: null,
|
||||
loopback: false
|
||||
localPlatform: false
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,17 +98,23 @@ describe('readEverConnectConfig', () => {
|
||||
expect(deniedByEnv(readEverConnectConfig({ EVER_CONNECT_INTEGRATIONS_DENY: '*' }), 'webhooks')).toBe(true);
|
||||
});
|
||||
|
||||
it('the test issuer and root keys are honoured for a loopback platform only', () => {
|
||||
it('the test issuer and root keys are honoured for a local or private platform address only', () => {
|
||||
const env = { EVER_PLATFORM_ISSUER: 'https://mock-platform.test' };
|
||||
expect(readEverConnectConfig({ ...env, EVER_PLATFORM_API_URL: 'http://127.0.0.1:18081' })).toMatchObject({
|
||||
issuer: 'https://mock-platform.test',
|
||||
loopback: true
|
||||
localPlatform: true
|
||||
});
|
||||
// The egress audit's mock platform: a fixed address of a private range on its sealed network.
|
||||
expect(readEverConnectConfig({ ...env, EVER_PLATFORM_API_URL: 'http://10.231.7.252:8080' })).toMatchObject({
|
||||
issuer: 'https://mock-platform.test',
|
||||
localPlatform: true
|
||||
});
|
||||
const warn = jest.fn();
|
||||
expect(
|
||||
readEverConnectConfig({ ...env, EVER_PLATFORM_API_URL: 'http://192.168.1.20:8080' }, warn)
|
||||
).toMatchObject({ issuer: null, loopback: false });
|
||||
expect(warn.mock.calls.map(([message]) => message).join(' ')).toMatch(/loopback/);
|
||||
readEverConnectConfig({ ...env, EVER_PLATFORM_API_URL: 'https://203.0.113.10' }, warn)
|
||||
).toMatchObject({ issuer: null, localPlatform: false });
|
||||
expect(warn.mock.calls.map(([message]) => message).join(' ')).toMatch(/local or private/);
|
||||
expect(readEverConnectConfig({ ...env, EVER_PLATFORM_API_URL: 'http://192.168.1.20:8080' }).localPlatform).toBe(true);
|
||||
expect(readEverConnectConfig({ ...env, EVER_PLATFORM_API_URL: 'https://api-dev.ever.co' }).issuer).toBeNull();
|
||||
});
|
||||
|
||||
@@ -123,11 +129,14 @@ describe('readEverConnectConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('the return address is the web app origin (https, or http on a local host); nothing else', () => {
|
||||
it('the return address is the Ever Platform page of the web app (https, or http on a local host); nothing else', () => {
|
||||
expect(readEverConnectConfig({ CLIENT_BASE_URL: 'https://app.example.test/' })).toMatchObject({
|
||||
returnOrigin: 'https://app.example.test',
|
||||
returnUrl: 'https://app.example.test/'
|
||||
returnUrl: 'https://app.example.test/#/pages/integrations/ever-connect'
|
||||
});
|
||||
expect(readEverConnectConfig({ CLIENT_BASE_URL: 'https://example.test/gauzy/' }).returnUrl).toBe(
|
||||
'https://example.test/gauzy/#/pages/integrations/ever-connect'
|
||||
);
|
||||
expect(readEverConnectConfig({ CLIENT_BASE_URL: 'http://localhost:4200' }).returnOrigin).toBe(
|
||||
'http://localhost:4200'
|
||||
);
|
||||
|
||||
@@ -39,21 +39,23 @@ export interface EverConnectConfig {
|
||||
* only a digest of it.
|
||||
*/
|
||||
returnOrigin: string | null;
|
||||
/** The web app address app.ever.co sends an administrator back to (no fragment), or `null`. */
|
||||
/** The web app page app.ever.co sends an administrator back to (the Ever Platform page, in the fragment), or `null`. */
|
||||
returnUrl: string | null;
|
||||
/** `CLIENT_BASE_URL` is set but is not one that may be sent (plain http on another host). */
|
||||
returnUnusable: boolean;
|
||||
/**
|
||||
* `EVER_PLATFORM_ISSUER`: the issuer Ever Platform's documents name, when it differs from the
|
||||
* origin of `EVER_PLATFORM_API_URL` (a mock platform in tests). Honoured only when
|
||||
* `EVER_PLATFORM_API_URL` is a loopback address (`localhost`, `127.0.0.0/8`, `::1`).
|
||||
* `EVER_PLATFORM_API_URL` is a local address (see `localPlatform`).
|
||||
*/
|
||||
issuer: string | null;
|
||||
/**
|
||||
* `EVER_PLATFORM_API_URL` is a loopback address: only then are `EVER_PLATFORM_ISSUER` and
|
||||
* `EVER_PLATFORM_ROOT_KEYS_FILE` (test keys) honoured.
|
||||
* `EVER_PLATFORM_API_URL` is a local address, one of the contract's `root_keys_file_hosts`
|
||||
* (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`, `10.0.0.0/8`, `172.16.0.0/12`,
|
||||
* `192.168.0.0/16`): only then are `EVER_PLATFORM_ISSUER` and `EVER_PLATFORM_ROOT_KEYS_FILE`
|
||||
* (test keys) honoured, as the SDK honours them. A public address never is.
|
||||
*/
|
||||
loopback: boolean;
|
||||
localPlatform: boolean;
|
||||
}
|
||||
|
||||
/** `localhost` (and `*.localhost`), `127.0.0.0/8` and `::1`. */
|
||||
@@ -146,6 +148,9 @@ export function releaseVersion(raw: string | undefined): string {
|
||||
return match ? `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}` : '0.0.0';
|
||||
}
|
||||
|
||||
/** The page of the web app a consent given in app.ever.co returns to (the web app routes in its fragment). */
|
||||
export const CONSENT_RETURN_FRAGMENT = '#/pages/integrations/ever-connect';
|
||||
|
||||
/** The hosts plain http may return to (the contract's `return_origins`). */
|
||||
const HTTP_RETURN_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||
|
||||
@@ -169,9 +174,9 @@ function parseReturn(raw: string | undefined): { origin: string; url: string } |
|
||||
return null;
|
||||
}
|
||||
const base = `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
||||
// Ever Platform takes no fragment in a return address, and the web app routes in its fragment:
|
||||
// the consent screen returns to the web app itself; the Ever Platform page reads the result.
|
||||
return { origin: url.origin, url: `${base}/` };
|
||||
// The web app routes in its fragment, and a return address may name a page there: the consent
|
||||
// screen returns to the Ever Platform page, which reads the result.
|
||||
return { origin: url.origin, url: `${base}/${CONSENT_RETURN_FRAGMENT}` };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,11 +187,11 @@ export function readEverConnectConfig(env: Env = process.env, warn: Warn = () =>
|
||||
const installSource = parseInstallSource(env, warn);
|
||||
const ret = parseReturn(env['CLIENT_BASE_URL']);
|
||||
const apiUrl = parseApiUrl(env, warn);
|
||||
const loopback = apiUrl !== null && isLoopbackHost(new URL(apiUrl).hostname);
|
||||
const localPlatform = apiUrl !== null && isLocalHost(new URL(apiUrl).hostname);
|
||||
const issuer = env['EVER_PLATFORM_ISSUER']?.trim() || null;
|
||||
if (!loopback && (issuer || env['EVER_PLATFORM_ROOT_KEYS_FILE']?.trim())) {
|
||||
if (!localPlatform && (issuer || env['EVER_PLATFORM_ROOT_KEYS_FILE']?.trim())) {
|
||||
warn(
|
||||
'EVER_PLATFORM_ISSUER and EVER_PLATFORM_ROOT_KEYS_FILE are for a test platform on a loopback address only; they are ignored.'
|
||||
'EVER_PLATFORM_ISSUER and EVER_PLATFORM_ROOT_KEYS_FILE are for a test platform on a local or private address only; they are ignored.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
@@ -201,8 +206,8 @@ export function readEverConnectConfig(env: Env = process.env, warn: Warn = () =>
|
||||
returnOrigin: ret?.origin ?? null,
|
||||
returnUrl: ret?.url ?? null,
|
||||
returnUnusable: Boolean(env['CLIENT_BASE_URL']?.trim()) && !ret,
|
||||
issuer: loopback ? issuer : null,
|
||||
loopback
|
||||
issuer: localPlatform ? issuer : null,
|
||||
localPlatform
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
ENTITLEMENT_FILE_MAX_BYTES,
|
||||
entitlementLadder,
|
||||
EverConnectEntitlementService
|
||||
} from './ever-connect-entitlement.service';
|
||||
import { EntitlementError } from './sdk';
|
||||
|
||||
const NOW_S = 1_800_000_000;
|
||||
const DAY = 86_400;
|
||||
const ENV = { ENCRYPTION_KEY: 'a'.repeat(64) };
|
||||
const INSTANCE = 'ins_01J00000000000000000000000';
|
||||
const LINK = 'evl_01J00000000000000000000000';
|
||||
|
||||
const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
|
||||
/** A compact JWS with the given claims (the signature is never checked here: the verifier is a fake). */
|
||||
function jwsOf(claims: Record<string, unknown>): string {
|
||||
return `${b64({ alg: 'EdDSA', typ: 'ever-entitlement+jwt', kid: 'ent-test-1' })}.${b64(claims)}.${Buffer.from('sig').toString('base64url')}`;
|
||||
}
|
||||
|
||||
function claimsFor(sub: string, seq: number, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
iss: 'https://mock-platform.test',
|
||||
sub,
|
||||
iat: NOW_S - 60,
|
||||
exp: NOW_S + 7 * DAY,
|
||||
ever: {
|
||||
seq,
|
||||
grace_s: 30 * DAY,
|
||||
handle: 'acme',
|
||||
org_id: 'org_1',
|
||||
tier: 'paid',
|
||||
plan: { code: 'gauzy-team', source: 'subscription' },
|
||||
licence_ids: ['EVER-GAUZY-SB-1A2B3C4D'],
|
||||
features: { discoverability: true, ever_id_login: true },
|
||||
...extra
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setup(options: { connected?: boolean; storedSeq?: number | null; link?: boolean; verifyError?: Error } = {}) {
|
||||
const connection = {
|
||||
status: options.connected === false ? 'disconnected' : 'connected',
|
||||
platformInstanceId: INSTANCE,
|
||||
instanceEntitlementSeq: options.storedSeq ?? null,
|
||||
instanceEntitlementIat: options.storedSeq != null ? NOW_S - 3600 : null,
|
||||
instanceEntitlementJwsEncrypted: null
|
||||
};
|
||||
const link = options.link
|
||||
? {
|
||||
linkId: LINK,
|
||||
status: 'linked',
|
||||
tenantId: 'tenant-1',
|
||||
organizationId: 'org-1',
|
||||
entitlementSeq: null,
|
||||
entitlementIat: null,
|
||||
entitlementJwsEncrypted: null as string | null,
|
||||
entitlementFetchedAt: null,
|
||||
everHandle: null,
|
||||
integrationTenantId: 'it-1' as string | null
|
||||
}
|
||||
: null;
|
||||
const store = {
|
||||
connection: jest.fn(async () => connection),
|
||||
updateConnection: jest.fn(async (values: Record<string, unknown>, where: Record<string, unknown> = {}) => {
|
||||
if (
|
||||
'instanceEntitlementSeq' in where &&
|
||||
where['instanceEntitlementSeq'] !== connection.instanceEntitlementSeq
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
Object.assign(connection, values);
|
||||
return 1;
|
||||
}),
|
||||
linkById: jest.fn(async (id: string) => (link && id === link.linkId ? link : null)),
|
||||
linkOf: jest.fn(async () => link),
|
||||
updateLink: jest.fn(async (_id: string, values: Record<string, unknown>) => Object.assign(link ?? {}, values)),
|
||||
updateLinkIf: jest.fn(async (_id: string, values: Record<string, unknown>, where: Record<string, unknown>) => {
|
||||
if (!link || where['entitlementSeq'] !== link.entitlementSeq) return false;
|
||||
Object.assign(link, values);
|
||||
return true;
|
||||
}),
|
||||
updateLinkRecordSettings: jest.fn(async () => undefined)
|
||||
};
|
||||
const audit = { record: jest.fn(async (_entry: unknown) => undefined) };
|
||||
const platform = {
|
||||
verify: jest.fn(async (jws: string, subject: string) => {
|
||||
if (options.verifyError) throw options.verifyError;
|
||||
const claims = JSON.parse(Buffer.from(jws.split('.')[1], 'base64url').toString('utf8'));
|
||||
if (claims.sub !== subject) throw new EntitlementError('subject_mismatch');
|
||||
return { jws, seq: claims.ever.seq, claims };
|
||||
})
|
||||
};
|
||||
const service = new EverConnectEntitlementService(
|
||||
platform as never,
|
||||
store as never,
|
||||
audit as never,
|
||||
{ revoked$: { next: jest.fn() } } as never,
|
||||
ENV,
|
||||
{ now: () => NOW_S * 1000 }
|
||||
);
|
||||
return { service, store, audit, platform, connection, link };
|
||||
}
|
||||
|
||||
const answerOf = async (promise: Promise<unknown>) => {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException)
|
||||
return { status: error.getStatus(), body: error.getResponse() as Record<string, unknown> };
|
||||
throw error;
|
||||
}
|
||||
throw new Error('expected an HttpException');
|
||||
};
|
||||
|
||||
describe('entitlementLadder', () => {
|
||||
const claims = (expOffsetS: number, grace_s?: number) => ({ exp: NOW_S + expOffsetS, ever: { grace_s } });
|
||||
|
||||
it.each([
|
||||
['valid before exp', 3600, undefined, 'valid'],
|
||||
['grace 1 s after exp', -1, undefined, 'grace'],
|
||||
['grace 1 day after exp', -DAY, undefined, 'grace'],
|
||||
['grace 29 days 23 h after exp', -(30 * DAY - 3600), undefined, 'grace'],
|
||||
['paused 30 days after exp', -30 * DAY, undefined, 'paused'],
|
||||
['paused when the document says a shorter grace', -2 * DAY, DAY, 'paused']
|
||||
])('%s', (_name, offset, grace, expected) => {
|
||||
expect(entitlementLadder(claims(offset as number, grace as number | undefined), NOW_S)).toBe(expected);
|
||||
});
|
||||
|
||||
it('pauses without a document, and when the connection was revoked', () => {
|
||||
expect(entitlementLadder(null, NOW_S)).toBe('paused');
|
||||
expect(entitlementLadder(claims(3600), NOW_S, true)).toBe('paused');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EverConnectEntitlementService.importDocument', () => {
|
||||
const operator = { actorLabel: 'operator' as const, actorUserId: 'user-1' };
|
||||
|
||||
it('stores a valid instance document and audits it as a file import', async () => {
|
||||
const { service, store, audit } = setup();
|
||||
const result = await service.importDocument(jwsOf(claimsFor(`instance:${INSTANCE}`, 7)), operator);
|
||||
expect(result).toEqual({ subject: 'instance', seq: 7, status: 'stored' });
|
||||
expect(store.updateConnection).toHaveBeenCalledWith(expect.objectContaining({ instanceEntitlementSeq: 7 }), {
|
||||
instanceEntitlementSeq: null
|
||||
});
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'entitlement.refresh',
|
||||
details: expect.objectContaining({ subject: 'instance', seq: 7, status: 'stored', source: 'file' })
|
||||
})
|
||||
);
|
||||
// No token, no document in the audit row.
|
||||
expect(JSON.stringify(audit.record.mock.calls)).not.toMatch(/eyJ/);
|
||||
});
|
||||
|
||||
it('stores a link document of this installation on its link', async () => {
|
||||
const { service, store } = setup({ link: true });
|
||||
const result = await service.importDocument(jwsOf(claimsFor(`link:${LINK}`, 3)), operator);
|
||||
expect(result).toEqual({ subject: 'link', seq: 3, status: 'stored' });
|
||||
expect(store.updateLinkIf).toHaveBeenCalledWith(LINK, expect.objectContaining({ entitlementSeq: 3 }), {
|
||||
entitlementSeq: null
|
||||
});
|
||||
// The link record's handle follows the document, as on a refresh.
|
||||
expect(store.updateLinkRecordSettings).toHaveBeenCalledWith('it-1', { EVER_HANDLE: 'acme' });
|
||||
});
|
||||
|
||||
it('answers 409 not_connected before a connection exists, and changes nothing', async () => {
|
||||
const { service, store } = setup({ connected: false });
|
||||
const answer = await answerOf(service.importDocument(jwsOf(claimsFor(`instance:${INSTANCE}`, 1)), operator));
|
||||
expect(answer).toMatchObject({ status: 409, body: { code: 'not_connected' } });
|
||||
expect(store.updateConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 413 for a document over 16 KiB', async () => {
|
||||
const { service } = setup();
|
||||
const answer = await answerOf(service.importDocument('x'.repeat(ENTITLEMENT_FILE_MAX_BYTES + 1), operator));
|
||||
expect(answer.status).toBe(413);
|
||||
});
|
||||
|
||||
it('refuses a document of another installation (422) and audits the refusal', async () => {
|
||||
const { service, store, audit } = setup();
|
||||
const answer = await answerOf(service.importDocument(jwsOf(claimsFor('instance:ins_other', 9)), operator));
|
||||
expect(answer).toMatchObject({
|
||||
status: 422,
|
||||
body: { code: 'entitlement_invalid', reason: 'subject_mismatch' }
|
||||
});
|
||||
expect(store.updateConnection).not.toHaveBeenCalled();
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ details: expect.objectContaining({ status: 'refused', source: 'file' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a link this installation does not have', async () => {
|
||||
const { service } = setup();
|
||||
const answer = await answerOf(service.importDocument(jwsOf(claimsFor(`link:${LINK}`, 2)), operator));
|
||||
expect(answer).toMatchObject({ status: 422, body: { reason: 'subject_mismatch' } });
|
||||
});
|
||||
|
||||
it('refuses what the verifier refuses (a tampered signature) and keeps the stored document', async () => {
|
||||
const { service, store } = setup({ storedSeq: 4, verifyError: new EntitlementError('bad_signature') });
|
||||
const answer = await answerOf(service.importDocument(jwsOf(claimsFor(`instance:${INSTANCE}`, 9)), operator));
|
||||
expect(answer).toMatchObject({ status: 422, body: { code: 'entitlement_invalid' } });
|
||||
expect(store.updateConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses an older document (entitlement_stale) and keeps the stored one', async () => {
|
||||
const { service, store, audit } = setup({ storedSeq: 5 });
|
||||
const answer = await answerOf(service.importDocument(jwsOf(claimsFor(`instance:${INSTANCE}`, 4)), operator));
|
||||
expect(answer).toMatchObject({ status: 422, body: { reason: 'entitlement_stale' } });
|
||||
expect(store.updateConnection).not.toHaveBeenCalled();
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ details: expect.objectContaining({ status: 'stale' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('answers unchanged for the stored document again (a restart with the same file)', async () => {
|
||||
const { service, store, audit, connection } = setup({ storedSeq: 5 });
|
||||
connection.instanceEntitlementIat = NOW_S - 60;
|
||||
const result = await service.importDocument(jwsOf(claimsFor(`instance:${INSTANCE}`, 5)), operator);
|
||||
expect(result).toEqual({ subject: 'instance', seq: 5, status: 'unchanged' });
|
||||
expect(store.updateConnection).not.toHaveBeenCalled();
|
||||
expect(audit.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 409 entitlement_changed when another document was stored while this one was verified', async () => {
|
||||
const { service, platform, connection, audit } = setup({ storedSeq: 5 });
|
||||
platform.verify.mockImplementationOnce(async (jws: string) => {
|
||||
// A refresh stores document #8 meanwhile.
|
||||
connection.instanceEntitlementSeq = 8;
|
||||
const claims = JSON.parse(Buffer.from(jws.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { jws, seq: claims.ever.seq, claims };
|
||||
});
|
||||
const answer = await answerOf(service.importDocument(jwsOf(claimsFor(`instance:${INSTANCE}`, 6)), operator));
|
||||
expect(answer).toMatchObject({ status: 409, body: { code: 'entitlement_changed' } });
|
||||
expect(connection.instanceEntitlementSeq).toBe(8);
|
||||
expect(audit.record).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ details: expect.objectContaining({ status: 'stored' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('audits an empty or oversized upload as refused, without the input', async () => {
|
||||
const { service, audit } = setup();
|
||||
await answerOf(service.importDocument('', operator));
|
||||
await answerOf(service.importDocument('y'.repeat(ENTITLEMENT_FILE_MAX_BYTES + 1), operator));
|
||||
const reasons = audit.record.mock.calls.map(
|
||||
(call) => (call[0] as { details: { reason: string } }).details.reason
|
||||
);
|
||||
expect(reasons).toEqual(['malformed', 'too_large']);
|
||||
expect(JSON.stringify(audit.record.mock.calls)).not.toContain('yyyy');
|
||||
});
|
||||
|
||||
it('refuses something that is not a document', async () => {
|
||||
const { service } = setup();
|
||||
expect((await answerOf(service.importDocument('', operator))).status).toBe(422);
|
||||
expect((await answerOf(service.importDocument({ jws: 1 }, operator))).status).toBe(422);
|
||||
expect((await answerOf(service.importDocument('not.a.jws', operator))).status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EVER_ENTITLEMENT_FILE', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => (dir = mkdtempSync(join(tmpdir(), 'ever-ent-'))));
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('imports the file once connected, and a bad file changes nothing', async () => {
|
||||
const file = join(dir, 'entitlement.jws');
|
||||
writeFileSync(file, `${jwsOf(claimsFor(`instance:${INSTANCE}`, 2))}\n`);
|
||||
const { service, store } = setup();
|
||||
(service as unknown as { env: Record<string, string> }).env['EVER_ENTITLEMENT_FILE'] = file;
|
||||
expect(await service.importFromEnvFile()).toEqual({ subject: 'instance', seq: 2, status: 'stored' });
|
||||
expect(store.updateConnection).toHaveBeenCalledTimes(1);
|
||||
|
||||
writeFileSync(file, 'garbage');
|
||||
expect(await service.importFromEnvFile()).toBeNull();
|
||||
(service as unknown as { env: Record<string, string> }).env['EVER_ENTITLEMENT_FILE'] = join(dir, 'missing.jws');
|
||||
expect(await service.importFromEnvFile()).toBeNull();
|
||||
expect(store.updateConnection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing when unset', async () => {
|
||||
const { service, store } = setup();
|
||||
expect(await service.importFromEnvFile()).toBeNull();
|
||||
expect(store.connection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EverConnectEntitlementService.features and the summary', () => {
|
||||
it('answers the document features while valid or in grace, all off when paused', async () => {
|
||||
const { service, link } = setup({ link: true });
|
||||
const seal = (claims: Record<string, unknown>) =>
|
||||
(service as unknown as { secrets: { seal: (v: string) => string } }).secrets.seal(jwsOf(claims));
|
||||
|
||||
link!.entitlementJwsEncrypted = seal(claimsFor(`link:${LINK}`, 1));
|
||||
expect(await service.features('tenant-1', 'org-1')).toEqual({
|
||||
ladder: 'valid',
|
||||
features: { discoverability: true, ever_id_login: true }
|
||||
});
|
||||
|
||||
link!.entitlementJwsEncrypted = seal({ ...claimsFor(`link:${LINK}`, 2), exp: NOW_S - DAY });
|
||||
expect((await service.features('tenant-1', 'org-1')).ladder).toBe('grace');
|
||||
|
||||
link!.entitlementJwsEncrypted = seal({ ...claimsFor(`link:${LINK}`, 3), exp: NOW_S - 31 * DAY });
|
||||
expect(await service.features('tenant-1', 'org-1')).toEqual({
|
||||
ladder: 'paused',
|
||||
features: { discoverability: false, ever_id_login: false }
|
||||
});
|
||||
|
||||
link!.entitlementJwsEncrypted = null;
|
||||
expect(await service.features('tenant-1', 'org-1')).toEqual({ ladder: 'paused', features: {} });
|
||||
});
|
||||
|
||||
it('shows the licence ids and the ladder in the summary', async () => {
|
||||
const { service, link } = setup({ link: true });
|
||||
link!.entitlementJwsEncrypted = (
|
||||
service as unknown as { secrets: { seal: (v: string) => string } }
|
||||
).secrets.seal(jwsOf(claimsFor(`link:${LINK}`, 1)));
|
||||
const summary = await service.summary('tenant-1', 'org-1', false);
|
||||
expect(summary.link).toMatchObject({ ladder: 'valid', licence_ids: ['EVER-GAUZY-SB-1A2B3C4D'] });
|
||||
expect(summary.instance).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { HttpException, HttpStatus, Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { HttpException, HttpStatus, Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { ActorLabel, EverConnectAuditService } from './ever-connect-audit.service';
|
||||
import { EVER_CONNECT_CLOCK, EVER_CONNECT_ENV, ENTITLEMENT_REFRESHES_PER_HOUR } from './ever-connect.constants';
|
||||
import { EverConnectPlatformService, isCredentialRevoked } from './ever-connect-platform.service';
|
||||
@@ -14,10 +16,64 @@ import {
|
||||
VerifiedEntitlement
|
||||
} from './sdk';
|
||||
|
||||
/**
|
||||
* Where a document stands on the grace ladder: `valid` until it expires, `grace` for `grace_s` after
|
||||
* that (30 days unless the document says otherwise; its features still apply), then `paused` (no
|
||||
* document, a revoked connection, or the grace over): Ever Platform features pause, nothing else.
|
||||
*/
|
||||
export type EntitlementLadder = 'valid' | 'grace' | 'paused';
|
||||
|
||||
/** The ladder of a verified document's claims at `nowS` (seconds); `revoked` pauses whatever the dates say. */
|
||||
export function entitlementLadder(
|
||||
claims: { exp: number; ever: { grace_s?: number } } | null | undefined,
|
||||
nowS: number,
|
||||
revoked = false
|
||||
): EntitlementLadder {
|
||||
if (revoked || !claims) {
|
||||
return 'paused';
|
||||
}
|
||||
const status = entitlementStatus(claims as never, nowS);
|
||||
if (status === 'valid') {
|
||||
return 'valid';
|
||||
}
|
||||
return status === 'stale' ? 'grace' : 'paused';
|
||||
}
|
||||
|
||||
/** The 422 of a refused import, with the verifier's reason. */
|
||||
function importInvalid(reason: string): HttpException {
|
||||
return new HttpException(
|
||||
{ statusCode: 422, code: 'entitlement_invalid', reason, message: 'The entitlement document was refused.' },
|
||||
HttpStatus.UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
/** The `sub` claim of a document, read only to route it (the verifier then requires it), or null. */
|
||||
function subjectOf(document: string): string | null {
|
||||
try {
|
||||
const sub = (claimsOfVerifiedJws(document) as { sub?: unknown } | null)?.sub;
|
||||
return typeof sub === 'string' ? sub : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The largest entitlement document accepted from a file (the operator's upload or EVER_ENTITLEMENT_FILE). */
|
||||
export const ENTITLEMENT_FILE_MAX_BYTES = 16 * 1024;
|
||||
|
||||
/** What an imported document was: its subject, its sequence, and whether it replaced the stored one. */
|
||||
export interface EntitlementImportResult {
|
||||
subject: 'instance' | 'link';
|
||||
seq: number;
|
||||
status: 'stored' | 'unchanged';
|
||||
}
|
||||
|
||||
/** What the Entitlements tab shows of one stored document (decoded; never the document itself). */
|
||||
export interface EntitlementSummary {
|
||||
subject: 'instance' | 'link';
|
||||
status: EntitlementStatus;
|
||||
ladder: EntitlementLadder;
|
||||
/** The licence certificate ids the document names (`EVER-GAUZY-SB-1A2B3C4D`): shown, never checked. */
|
||||
licence_ids: string[];
|
||||
seq: number | null;
|
||||
issued_at: string | null;
|
||||
expires_at: string | null;
|
||||
@@ -65,6 +121,8 @@ const isoMs = (ms: number | null | undefined) => (typeof ms === 'number' ? new D
|
||||
*/
|
||||
@Injectable()
|
||||
export class EverConnectEntitlementService {
|
||||
private readonly logger = new Logger('EverConnect');
|
||||
private readonly env: Record<string, string | undefined>;
|
||||
private readonly secrets: EverConnectSecretStore;
|
||||
private readonly now: () => number;
|
||||
/** On-demand refreshes of the last hour, per document (`instance`, or a link id). */
|
||||
@@ -78,7 +136,8 @@ export class EverConnectEntitlementService {
|
||||
@Optional() @Inject(EVER_CONNECT_ENV) env?: Record<string, string | undefined>,
|
||||
@Optional() @Inject(EVER_CONNECT_CLOCK) clock?: { now: () => number }
|
||||
) {
|
||||
this.secrets = new EverConnectSecretStore(env ?? process.env);
|
||||
this.env = env ?? process.env;
|
||||
this.secrets = new EverConnectSecretStore(this.env);
|
||||
this.now = clock?.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
@@ -102,17 +161,47 @@ export class EverConnectEntitlementService {
|
||||
return this.platform.verify(answer.document, `link:${linkId}`);
|
||||
}
|
||||
|
||||
/** Stores the installation's verified document (encrypted). */
|
||||
async storeInstanceDocument(verified: VerifiedEntitlement): Promise<void> {
|
||||
await this.store.updateConnection({
|
||||
instanceEntitlementJwsEncrypted: this.secrets.seal(verified.jws),
|
||||
instanceEntitlementSeq: verified.seq,
|
||||
instanceEntitlementIat: verified.claims.iat,
|
||||
instanceEntitlementExp: verified.claims.exp,
|
||||
instanceEntitlementFetchedAt: this.now(),
|
||||
ownerOrgId: verified.claims.ever.org_id,
|
||||
ownerHandle: verified.claims.ever.handle
|
||||
/**
|
||||
* Stores the installation's verified document (encrypted). With `expectedSeq` (the sequence read
|
||||
* before verifying, `null` for none), only while the stored one is still that (compare and set):
|
||||
* answers whether it was stored, so a document verified concurrently never replaces a newer one.
|
||||
*/
|
||||
async storeInstanceDocument(verified: VerifiedEntitlement, expectedSeq?: number | null): Promise<boolean> {
|
||||
const where = expectedSeq === undefined ? {} : { instanceEntitlementSeq: expectedSeq };
|
||||
const changed = await this.store.updateConnection(
|
||||
{
|
||||
instanceEntitlementJwsEncrypted: this.secrets.seal(verified.jws),
|
||||
instanceEntitlementSeq: verified.seq,
|
||||
instanceEntitlementIat: verified.claims.iat,
|
||||
instanceEntitlementExp: verified.claims.exp,
|
||||
instanceEntitlementFetchedAt: this.now(),
|
||||
ownerOrgId: verified.claims.ever.org_id,
|
||||
ownerHandle: verified.claims.ever.handle
|
||||
},
|
||||
where
|
||||
);
|
||||
return changed !== 0;
|
||||
}
|
||||
|
||||
/** Stores a link's verified document only while its stored sequence is still `expectedSeq` (compare and set). */
|
||||
private async storeLinkDocument(
|
||||
link: LinkRecord,
|
||||
verified: VerifiedEntitlement,
|
||||
expectedSeq: number | null
|
||||
): Promise<boolean> {
|
||||
const previousHandle = link.everHandle;
|
||||
const stored = await this.store.updateLinkIf(link.linkId, this.linkDocumentColumns(verified), {
|
||||
entitlementSeq: expectedSeq
|
||||
});
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
if (link.integrationTenantId && verified.claims.ever.handle && verified.claims.ever.handle !== previousHandle) {
|
||||
await this.store.updateLinkRecordSettings(link.integrationTenantId, {
|
||||
EVER_HANDLE: verified.claims.ever.handle
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The columns of a link row that hold its verified document. */
|
||||
@@ -196,7 +285,10 @@ export class EverConnectEntitlementService {
|
||||
`instance:${connection.platformInstanceId}`,
|
||||
cached
|
||||
);
|
||||
await this.storeInstanceDocument(verified);
|
||||
// Another process (or an import) stored a document meanwhile: keep it; the next refresh compares.
|
||||
if (!(await this.storeInstanceDocument(verified, connection.instanceEntitlementSeq))) {
|
||||
return;
|
||||
}
|
||||
await this.audit.record({
|
||||
action: 'entitlement.refresh',
|
||||
actorLabel: actor.actorLabel,
|
||||
@@ -229,15 +321,9 @@ export class EverConnectEntitlementService {
|
||||
try {
|
||||
const verified = await this.platform.verify(answer.document, `link:${link.linkId}`, cached);
|
||||
checkLinkBinding(verified, link);
|
||||
await this.store.updateLink(link.linkId, this.linkDocumentColumns(verified));
|
||||
if (
|
||||
link.integrationTenantId &&
|
||||
verified.claims.ever.handle &&
|
||||
verified.claims.ever.handle !== link.everHandle
|
||||
) {
|
||||
await this.store.updateLinkRecordSettings(link.integrationTenantId, {
|
||||
EVER_HANDLE: verified.claims.ever.handle
|
||||
});
|
||||
// Another process (or an import) stored a document meanwhile: keep it; the next refresh compares.
|
||||
if (!(await this.storeLinkDocument(link, verified, link.entitlementSeq))) {
|
||||
return;
|
||||
}
|
||||
await this.audit.record({
|
||||
action: 'entitlement.refresh',
|
||||
@@ -261,6 +347,235 @@ export class EverConnectEntitlementService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ever Platform features one organization may use now, from its link's stored document only
|
||||
* (no request is made): the document's `features` while it is valid or in grace, every feature off
|
||||
* when it is paused (no document, the grace over, the connection not connected). Nothing outside the
|
||||
* Ever Platform module reads this: the product's own features never depend on it.
|
||||
*/
|
||||
async features(
|
||||
tenantId: string,
|
||||
organizationId: string
|
||||
): Promise<{ ladder: EntitlementLadder; features: Record<string, boolean> }> {
|
||||
const connection = await this.store.connection();
|
||||
const link = await this.store.linkOf(tenantId, organizationId);
|
||||
const jws = link && connection.status === 'connected' ? this.secrets.open(link.entitlementJwsEncrypted) : null;
|
||||
const claims = jws
|
||||
? (claimsOfVerifiedJws(jws) as {
|
||||
exp: number;
|
||||
ever: { grace_s?: number; features?: Record<string, boolean> };
|
||||
} | null)
|
||||
: null;
|
||||
const ladder = entitlementLadder(claims, Math.floor(this.now() / 1000));
|
||||
const features: Record<string, boolean> = { ...(claims?.ever?.features ?? {}) };
|
||||
if (ladder === 'paused') {
|
||||
for (const key of Object.keys(features)) features[key] = false;
|
||||
}
|
||||
return { ladder, features };
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a downloaded entitlement document (for an installation without a route to Ever
|
||||
* Platform): the same checks as a refresh (issuer, key, signature, schema, this installation, a
|
||||
* subject of this installation, never older than the stored one), then stored as a refresh stores
|
||||
* it. Throws an `HttpException`: 409 `not_connected` (or `entitlement_changed` when a document was
|
||||
* stored meanwhile), 413 `too_large`, 422 `entitlement_invalid`.
|
||||
*/
|
||||
async importDocument(
|
||||
jws: unknown,
|
||||
actor: { actorLabel: ActorLabel; actorUserId?: string | null }
|
||||
): Promise<EntitlementImportResult> {
|
||||
const document = await this.checkImportInput(jws, actor);
|
||||
const connection = await this.store.connection();
|
||||
if (connection.status !== 'connected' || !connection.platformInstanceId) {
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: 409,
|
||||
code: 'not_connected',
|
||||
message: 'Connect this installation to Ever Platform first.'
|
||||
},
|
||||
HttpStatus.CONFLICT
|
||||
);
|
||||
}
|
||||
const link = await this.importSubject(document, connection.platformInstanceId, actor);
|
||||
const subject = link ? 'link' : 'instance';
|
||||
const storedSeq = link ? link.entitlementSeq : connection.instanceEntitlementSeq;
|
||||
const storedIat = (link ? link.entitlementIat : connection.instanceEntitlementIat) ?? 0;
|
||||
const verified = await this.verifyImported(document, connection.platformInstanceId, link, actor);
|
||||
if (storedSeq !== null && verified.seq === storedSeq && verified.claims.iat === storedIat) {
|
||||
return { subject, seq: verified.seq, status: 'unchanged' };
|
||||
}
|
||||
if (storedSeq !== null && (verified.seq <= storedSeq || verified.claims.iat < storedIat)) {
|
||||
await this.importRefused('entitlement_stale', actor, link);
|
||||
throw importInvalid('entitlement_stale');
|
||||
}
|
||||
const stored = link
|
||||
? await this.storeLinkDocument(link, verified, storedSeq)
|
||||
: await this.storeInstanceDocument(verified, storedSeq);
|
||||
if (!stored) {
|
||||
// A document was stored while this one was verified: it may be newer, so this one is not.
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: 409,
|
||||
code: 'entitlement_changed',
|
||||
message: 'Another entitlement document was stored meanwhile; import again to compare with it.'
|
||||
},
|
||||
HttpStatus.CONFLICT
|
||||
);
|
||||
}
|
||||
await this.audit.record({
|
||||
action: 'entitlement.refresh',
|
||||
actorLabel: actor.actorLabel,
|
||||
actorUserId: actor.actorUserId,
|
||||
tenantId: link?.tenantId ?? null,
|
||||
organizationId: link?.organizationId ?? null,
|
||||
details: {
|
||||
subject,
|
||||
...(link ? { link_id: link.linkId } : {}),
|
||||
seq: verified.seq,
|
||||
status: 'stored',
|
||||
source: 'file'
|
||||
}
|
||||
});
|
||||
return { subject, seq: verified.seq, status: 'stored' };
|
||||
}
|
||||
|
||||
/** The document of an import, trimmed: 422 when there is none, 413 above 16 KiB (both audited, never the input). */
|
||||
private async checkImportInput(
|
||||
jws: unknown,
|
||||
actor: { actorLabel: ActorLabel; actorUserId?: string | null }
|
||||
): Promise<string> {
|
||||
if (typeof jws !== 'string' || jws.trim() === '') {
|
||||
await this.importRefused('malformed', actor);
|
||||
throw importInvalid('malformed');
|
||||
}
|
||||
const document = jws.trim();
|
||||
if (Buffer.byteLength(document, 'utf8') > ENTITLEMENT_FILE_MAX_BYTES) {
|
||||
await this.importRefused('too_large', actor);
|
||||
throw new HttpException(
|
||||
{ statusCode: 413, code: 'too_large', message: 'An entitlement document is at most 16 KiB.' },
|
||||
HttpStatus.PAYLOAD_TOO_LARGE
|
||||
);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which stored document an import replaces: `null` for the installation's, else the link it names.
|
||||
* The subject only routes the document; the verifier then requires exactly that subject. Anything
|
||||
* else (another installation, a link this installation does not have) is refused and audited.
|
||||
*/
|
||||
private async importSubject(
|
||||
document: string,
|
||||
platformInstanceId: string,
|
||||
actor: { actorLabel: ActorLabel; actorUserId?: string | null }
|
||||
): Promise<LinkRecord | null> {
|
||||
const sub = subjectOf(document);
|
||||
if (sub === `instance:${platformInstanceId}`) {
|
||||
return null;
|
||||
}
|
||||
const link = sub?.startsWith('link:') ? await this.store.linkById(sub.slice('link:'.length)) : null;
|
||||
if (link && link.status !== 'unlinked') {
|
||||
return link;
|
||||
}
|
||||
const reason = sub === null ? 'malformed' : 'subject_mismatch';
|
||||
await this.importRefused(reason, actor);
|
||||
throw importInvalid(reason);
|
||||
}
|
||||
|
||||
/** Verifies an imported document for its subject (no cached floor: the caller compares with the stored one). */
|
||||
private async verifyImported(
|
||||
document: string,
|
||||
platformInstanceId: string,
|
||||
link: LinkRecord | null,
|
||||
actor: { actorLabel: ActorLabel; actorUserId?: string | null }
|
||||
): Promise<VerifiedEntitlement> {
|
||||
try {
|
||||
const verified = await this.platform.verify(
|
||||
document,
|
||||
link ? `link:${link.linkId}` : `instance:${platformInstanceId}`
|
||||
);
|
||||
if (link) checkLinkBinding(verified, link);
|
||||
return verified;
|
||||
} catch (error) {
|
||||
if (error instanceof EntitlementError || error instanceof LinkBindingError) {
|
||||
await this.importRefused(error.code, actor, link);
|
||||
throw importInvalid(error.code);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `EVER_ENTITLEMENT_FILE`: a downloaded document imported once the connection starts. A file that
|
||||
* cannot be read or is refused logs a short reason and changes nothing; the same document again
|
||||
* (a restart) changes nothing either.
|
||||
*/
|
||||
async importFromEnvFile(): Promise<EntitlementImportResult | null> {
|
||||
const path = (this.env['EVER_ENTITLEMENT_FILE'] ?? '').trim();
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
let document: string;
|
||||
try {
|
||||
// At most the limit and one byte are read, whatever the file is.
|
||||
const file = await open(path, 'r');
|
||||
const buffer = Buffer.alloc(ENTITLEMENT_FILE_MAX_BYTES + 1);
|
||||
let length = 0;
|
||||
try {
|
||||
length = (await file.read(buffer, 0, buffer.length, 0)).bytesRead;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
if (length > ENTITLEMENT_FILE_MAX_BYTES) {
|
||||
this.logger.warn('EVER_ENTITLEMENT_FILE is larger than 16 KiB; it is not imported.');
|
||||
return null;
|
||||
}
|
||||
document = buffer.subarray(0, length).toString('utf8');
|
||||
} catch {
|
||||
this.logger.warn('EVER_ENTITLEMENT_FILE could not be read; it is not imported.');
|
||||
return null;
|
||||
}
|
||||
const digest = createHash('sha256').update(document.trim()).digest('hex').slice(0, 12);
|
||||
try {
|
||||
const result = await this.importDocument(document, { actorLabel: 'system' });
|
||||
if (result.status === 'stored') {
|
||||
this.logger.log(
|
||||
`EVER_ENTITLEMENT_FILE imported (${result.subject} document #${result.seq}, ${digest}).`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
const answer =
|
||||
error instanceof HttpException ? (error.getResponse() as { reason?: string; code?: string }) : null;
|
||||
this.logger.warn(
|
||||
`EVER_ENTITLEMENT_FILE was not imported (${answer?.reason ?? answer?.code ?? 'error'}, ${digest}).`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async importRefused(
|
||||
reason: string,
|
||||
actor: { actorLabel: ActorLabel; actorUserId?: string | null },
|
||||
link?: LinkRecord | null
|
||||
): Promise<void> {
|
||||
await this.audit.record({
|
||||
action: 'entitlement.refresh',
|
||||
actorLabel: actor.actorLabel,
|
||||
actorUserId: actor.actorUserId,
|
||||
tenantId: link?.tenantId ?? null,
|
||||
organizationId: link?.organizationId ?? null,
|
||||
details: {
|
||||
subject: link ? 'link' : 'instance',
|
||||
...(link ? { link_id: link.linkId } : {}),
|
||||
status: reason === 'entitlement_stale' ? 'stale' : 'refused',
|
||||
reason,
|
||||
source: 'file'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The decoded summary of the stored documents for one organization (and the installation). */
|
||||
async summary(
|
||||
tenantId: string,
|
||||
@@ -274,11 +589,17 @@ export class EverConnectEntitlementService {
|
||||
? this.summarize(
|
||||
'instance',
|
||||
this.secrets.open(connection.instanceEntitlementJwsEncrypted),
|
||||
connection.instanceEntitlementFetchedAt
|
||||
connection.instanceEntitlementFetchedAt,
|
||||
connection.status === 'revoked'
|
||||
)
|
||||
: null,
|
||||
link: link
|
||||
? this.summarize('link', this.secrets.open(link.entitlementJwsEncrypted), link.entitlementFetchedAt)
|
||||
? this.summarize(
|
||||
'link',
|
||||
this.secrets.open(link.entitlementJwsEncrypted),
|
||||
link.entitlementFetchedAt,
|
||||
connection.status === 'revoked'
|
||||
)
|
||||
: null
|
||||
};
|
||||
}
|
||||
@@ -286,7 +607,8 @@ export class EverConnectEntitlementService {
|
||||
private summarize(
|
||||
subject: 'instance' | 'link',
|
||||
jws: string | null,
|
||||
fetchedAt: number | null
|
||||
fetchedAt: number | null,
|
||||
revoked = false
|
||||
): EntitlementSummary | null {
|
||||
// Stored documents were verified before they were stored: their claims are read for display only.
|
||||
const payload = jws ? claimsOfVerifiedJws(jws) : null;
|
||||
@@ -295,9 +617,15 @@ export class EverConnectEntitlementService {
|
||||
}
|
||||
const claims = payload as { iat?: number; exp?: number; ever?: Record<string, unknown> };
|
||||
const ever = (claims.ever ?? {}) as Record<string, unknown> & { plan?: { code?: unknown } };
|
||||
const nowS = Math.floor(this.now() / 1000);
|
||||
const licenceIds = Array.isArray(ever['licence_ids'])
|
||||
? (ever['licence_ids'] as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||
: [];
|
||||
return {
|
||||
subject,
|
||||
status: entitlementStatus(claims as never, Math.floor(this.now() / 1000)),
|
||||
status: entitlementStatus(claims as never, nowS),
|
||||
ladder: entitlementLadder(claims as never, nowS, revoked),
|
||||
licence_ids: licenceIds,
|
||||
seq: typeof ever['seq'] === 'number' ? ever['seq'] : null,
|
||||
issued_at: iso(claims.iat),
|
||||
expires_at: iso(claims.exp),
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { RolesEnum } from '@gauzy/contracts';
|
||||
import { RoleGuard, Roles } from '@gauzy/core';
|
||||
import { ConnectResult, EverConnectConnectionService } from './ever-connect-connection.service';
|
||||
import { EntitlementImportResult, EverConnectEntitlementService } from './ever-connect-entitlement.service';
|
||||
import { EverConnectIntegrationStateService, IntegrationView } from './ever-connect-integration-state.service';
|
||||
import type { RequestWithUser } from './ever-connect-request';
|
||||
import { EverConnectStore } from './ever-connect.store';
|
||||
@@ -38,7 +39,8 @@ export class EverConnectInstanceController {
|
||||
constructor(
|
||||
private readonly connection: EverConnectConnectionService,
|
||||
private readonly states: EverConnectIntegrationStateService,
|
||||
private readonly store: EverConnectStore
|
||||
private readonly store: EverConnectStore,
|
||||
private readonly entitlements: EverConnectEntitlementService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -144,6 +146,24 @@ export class EverConnectInstanceController {
|
||||
return this.states.accept(key, body.accepted, request.user?.id ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports a downloaded entitlement document, for an installation without a route to Ever Platform:
|
||||
* `{ "jws": "<the document>" }` (at most 16 KiB). The checks of a refresh apply; the document must
|
||||
* name this installation or one of its organization links. 409 when not connected, 422 when refused.
|
||||
*/
|
||||
@Post('entitlement/import')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Header('Cache-Control', 'no-store')
|
||||
async importEntitlement(
|
||||
@Req() request: RequestWithUser,
|
||||
@Body() body: { jws?: unknown }
|
||||
): Promise<EntitlementImportResult> {
|
||||
return this.entitlements.importDocument(body?.jws, {
|
||||
actorLabel: 'operator',
|
||||
actorUserId: request.user?.id ?? null
|
||||
});
|
||||
}
|
||||
|
||||
/** The installation's public address, once "Installation address" is enabled: `{ "url": "https://…" }`. */
|
||||
@Put('public-url')
|
||||
@Header('Cache-Control', 'no-store')
|
||||
|
||||
@@ -102,8 +102,8 @@ export class EverConnectPlatformService {
|
||||
registryInstanceId: () => this.registryId,
|
||||
fetch: this.fetchImpl,
|
||||
...(this.config.issuer ? { issuer: this.config.issuer } : {}),
|
||||
// Test root keys only for a platform on a loopback address.
|
||||
env: this.config.loopback ? this.env : withoutTestKeys(this.env),
|
||||
// Test root keys only for a platform on a local or private address (the contract's list).
|
||||
env: this.config.localPlatform ? this.env : withoutTestKeys(this.env),
|
||||
onWarning: (message) => this.logger.warn(message)
|
||||
});
|
||||
this.clientKid = signer.kid;
|
||||
@@ -187,7 +187,7 @@ export class EverConnectPlatformService {
|
||||
}
|
||||
}
|
||||
|
||||
/** The environment without the test root keys file (honoured for a loopback platform only). */
|
||||
/** The environment without the test root keys file (honoured for a local platform only). */
|
||||
function withoutTestKeys(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
const copy = { ...env };
|
||||
delete copy['EVER_PLATFORM_ROOT_KEYS_FILE'];
|
||||
|
||||
@@ -187,7 +187,8 @@ describe.each(TEST_TARGETS)('Ever Platform routes on $name', (target) => {
|
||||
['get', '/api/ever-connect/policy', undefined],
|
||||
['put', '/api/ever-connect/policy/stats_link', { allowed: false }],
|
||||
['put', '/api/ever-connect/public-url', { url: 'https://gauzy.acme.example' }],
|
||||
['post', '/api/ever-connect/integrations/stats_link/accept', { accepted: true }]
|
||||
['post', '/api/ever-connect/integrations/stats_link/accept', { accepted: true }],
|
||||
['post', '/api/ever-connect/entitlement/import', { jws: 'a.b.c' }]
|
||||
];
|
||||
|
||||
it('loaded but not connected: nothing is sent, nothing is scheduled, no client exists', async () => {
|
||||
@@ -268,6 +269,10 @@ describe.each(TEST_TARGETS)('Ever Platform routes on $name', (target) => {
|
||||
(await call('put', '/api/ever-connect/public-url', 'operator', { url: 'https://gauzy.acme.example' })).body
|
||||
.code
|
||||
).toBe('consent_required');
|
||||
// An entitlement document cannot be imported before the installation is connected (no call).
|
||||
const notConnected = await call('post', '/api/ever-connect/entitlement/import', 'operator', { jws: 'a.b.c' });
|
||||
expect(notConnected.status).toBe(409);
|
||||
expect(notConnected.body.code).toBe('not_connected');
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export class EverConnectScheduler implements OnModuleInit, OnModuleDestroy {
|
||||
private feedAbort: AbortController | null = null;
|
||||
private running = false;
|
||||
private feedFailures = 0;
|
||||
private entitlementFileImported = false;
|
||||
private readonly subscriptions: Subscription[] = [];
|
||||
/** Events handled by this process (their ids), so a page read twice is handled once. */
|
||||
private readonly seen = new Set<string>();
|
||||
@@ -99,6 +100,11 @@ export class EverConnectScheduler implements OnModuleInit, OnModuleDestroy {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.feedFailures = 0;
|
||||
// EVER_ENTITLEMENT_FILE: imported once per process, when the connection first starts.
|
||||
if (!this.entitlementFileImported) {
|
||||
this.entitlementFileImported = true;
|
||||
this.entitlements.importFromEnvFile().catch(() => undefined);
|
||||
}
|
||||
this.scheduleHeartbeat(FIRST_HEARTBEAT_DELAY_MS);
|
||||
this.scheduleFeed(0);
|
||||
}
|
||||
|
||||
@@ -312,7 +312,17 @@ export class EverConnectStore {
|
||||
await this.sql.update(LINK, { ...values, ...extra, updatedAt: this.now() }, { linkId });
|
||||
}
|
||||
|
||||
/** Removes a link row that was never completed (its Gauzy record could not be written). */
|
||||
/** Updates a link only while it still matches `where` (compare and set); answers whether it did. */
|
||||
async updateLinkIf(
|
||||
linkId: string,
|
||||
values: Partial<Record<keyof LinkRecord, unknown>>,
|
||||
where: Partial<Record<keyof LinkRecord, unknown>>
|
||||
): Promise<boolean> {
|
||||
const changed = await this.sql.update(LINK, { ...values, updatedAt: this.now() }, { ...where, linkId } as Row);
|
||||
return changed !== 0;
|
||||
}
|
||||
|
||||
/** Removes a link row that was never completed (its Gauzy record could not be written). */
|
||||
async deleteLink(linkId: string): Promise<void> {
|
||||
await this.sql.run(`DELETE FROM ${this.sql.q(LINK)} WHERE ${this.sql.q('linkId')} = ${this.sql.ph(1)}`, [linkId]);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
*
|
||||
* The mock's issuer is an https name (`MOCK_ISSUER`, `https://mock-platform.test`: Ever Platform's
|
||||
* documents always name an https issuer) served on a local address; `EVER_PLATFORM_ISSUER` tells the
|
||||
* plugin, which honours it for a loopback address only.
|
||||
* plugin, which honours it for a local or private address only.
|
||||
*
|
||||
* Without `EVER_CONNECT_MOCK_PLATFORM_URL` the suite is skipped, unless
|
||||
* `EVER_CONNECT_MOCK_PLATFORM_REQUIRED=true` (CI), where a missing mock fails it.
|
||||
@@ -351,7 +351,7 @@ suite('Ever Platform connection against the mock platform', () => {
|
||||
);
|
||||
expect(link.status).toBe(200);
|
||||
expect(link.body.url).toMatch(/^https:\/\/app\.ever\.co\/connect\/consent\?/);
|
||||
expect(new URL(link.body.url).searchParams.get('return')).toBe('http://localhost:4200/');
|
||||
expect(new URL(link.body.url).searchParams.get('return')).toBe('http://localhost:4200/#/pages/integrations/ever-connect');
|
||||
expect(
|
||||
(
|
||||
await call(
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ const STATUS: EverStatsStatus = {
|
||||
next_send_at: '2026-10-03T12:00:00.000Z',
|
||||
last_attempt: null,
|
||||
key_warning: 'encryption_key_unset',
|
||||
schema_url: 'https://github.com/ever-co/ever-connect-sdk/blob/a9844bd44c9fdb4d7f8be404b88331e578d8180c/contracts/schemas/ever.stats.v1.json'
|
||||
schema_url: 'https://github.com/ever-co/ever-connect-sdk/blob/93fbb1ef2b37f60205ab193d973467a176eec8df/contracts/schemas/ever.stats.v1.json'
|
||||
};
|
||||
const PAYLOAD = '{"schema":"ever.stats.v1","counts":{"tenants":1}}';
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('UsageStatisticsSettingsComponent', () => {
|
||||
const { component, find, el } = render();
|
||||
expect(component.view).toBe('managed');
|
||||
expect(find('managed')).not.toBeNull();
|
||||
expect(find('managed')?.querySelector('a')?.getAttribute('href')).toBe('https://github.com/ever-co/ever-connect-sdk/blob/a9844bd44c9fdb4d7f8be404b88331e578d8180c/contracts/schemas/ever.stats.v1.json');
|
||||
expect(find('managed')?.querySelector('a')?.getAttribute('href')).toBe('https://github.com/ever-co/ever-connect-sdk/blob/93fbb1ef2b37f60205ab193d973467a176eec8df/contracts/schemas/ever.stats.v1.json');
|
||||
expect(find('managed-operators')).not.toBeNull();
|
||||
expect(find('operator')).toBeNull();
|
||||
expect(el.querySelector('pre')).toBeNull();
|
||||
|
||||
@@ -51,10 +51,10 @@ export interface EverStatsSendResult {
|
||||
|
||||
/**
|
||||
* The published schema, shown to everyone who is not the operator: the file in the public Ever
|
||||
* Platform SDK repository, at the release of the contracts package the API pins (1.0.0-rc.3).
|
||||
* Platform SDK repository, at the release of the contracts package the API pins (1.0.0-rc.6).
|
||||
*/
|
||||
export const EVER_STATS_SCHEMA_URL =
|
||||
'https://github.com/ever-co/ever-connect-sdk/blob/a9844bd44c9fdb4d7f8be404b88331e578d8180c/contracts/schemas/ever.stats.v1.json';
|
||||
'https://github.com/ever-co/ever-connect-sdk/blob/93fbb1ef2b37f60205ab193d973467a176eec8df/contracts/schemas/ever.stats.v1.json';
|
||||
|
||||
/** The operator routes of the anonymous usage statistics. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
@@ -32,7 +32,7 @@ Only `EVER_STATS_ENABLED=false` switches the module off. Unset, empty or `true`
|
||||
| | |
|
||||
|---|---|
|
||||
| Schedule | Once per UTC day, at a second drawn at random for each report. The first report goes out one day after the first start (ten minutes after a start when the last report is more than a day old). On days 1 to 3 of a month the previous month is sent once more with `final: true`. Several API processes on one database send one report: a lease in the database lets one of them send. |
|
||||
| Body | One JSON document, `ever.stats.v1`, at most 16 KiB, integers only. The schema is published in the public Ever Platform SDK repository ([`ever.stats.v1.json`](https://github.com/ever-co/ever-connect-sdk/blob/a9844bd44c9fdb4d7f8be404b88331e578d8180c/contracts/schemas/ever.stats.v1.json)); this module reads it from the SDK's contracts package, `@ever-co/connect-contracts` (its SHA-256 is pinned in `src/lib/schema/stats-schema.ts`). |
|
||||
| Body | One JSON document, `ever.stats.v1`, at most 16 KiB, integers only. The schema is published in the public Ever Platform SDK repository ([`ever.stats.v1.json`](https://github.com/ever-co/ever-connect-sdk/blob/93fbb1ef2b37f60205ab193d973467a176eec8df/contracts/schemas/ever.stats.v1.json)); this module reads it from the SDK's contracts package, `@ever-co/connect-contracts` (its SHA-256 is pinned in `src/lib/schema/stats-schema.ts`). |
|
||||
| Headers | `Ever-Stats-Key`: the public part of this installation's statistics key (Ed25519, base64url); `Ever-Stats-Signature: ed25519=<signature over the exact body>`; `Ever-Stats-Key-Id`; `User-Agent: gauzy-ever-stats/<module version> (gauzy/<version>)`. No cookie, no credential, no redirect followed, 10 s timeout. |
|
||||
| Retries | `202`: done. `429`, `5xx` or a connection error: again after 1 h, 4 h, 12 h, then the next day. `404` (the endpoint is not available), a redirect or any other answer: the next day. The answer body is read up to 64 KiB. |
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"@nestjs/core": "^11.1.26"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ever-co/connect-contracts": "1.0.0-rc.3",
|
||||
"@ever-co/connect-sdk": "1.0.0-rc.3",
|
||||
"@ever-co/connect-contracts": "1.0.0-rc.6",
|
||||
"@ever-co/connect-sdk": "1.0.0-rc.6",
|
||||
"@gauzy/common": "^0.1.0",
|
||||
"@gauzy/config": "^0.1.0",
|
||||
"@gauzy/constants": "^0.1.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"typeorm": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ever-co/connect-tools": "1.0.0-rc.3",
|
||||
"@ever-co/connect-tools": "1.0.0-rc.6",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -41,10 +41,10 @@ export const STATS_LEASE_MS = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The published schema, for operators and reviewers: the file in the public Ever Platform SDK
|
||||
* repository, at the release of `@ever-co/connect-contracts` this plugin pins (ts-v1.0.0-rc.3).
|
||||
* repository, at the release of `@ever-co/connect-contracts` this plugin pins (ts-v1.0.0-rc.6).
|
||||
*/
|
||||
export const STATS_SCHEMA_URL =
|
||||
'https://github.com/ever-co/ever-connect-sdk/blob/a9844bd44c9fdb4d7f8be404b88331e578d8180c/contracts/schemas/ever.stats.v1.json';
|
||||
'https://github.com/ever-co/ever-connect-sdk/blob/93fbb1ef2b37f60205ab193d973467a176eec8df/contracts/schemas/ever.stats.v1.json';
|
||||
|
||||
/** The largest answer body read from Ever Platform; a longer one is not read further. */
|
||||
export const MAX_STATS_RESPONSE_BYTES = 64 * 1024;
|
||||
|
||||
@@ -15,7 +15,7 @@ export const SCHEMA_SHA256 = '0cd746f7dec75117a6b812b7a832f9ceca4c97a6ecf65d22d6
|
||||
/** Where the schema, its fixtures and the checks come from: the SDK release the packages pin. */
|
||||
export const SCHEMA_SOURCE = Object.freeze({
|
||||
repository: 'ever-co/ever-connect-sdk',
|
||||
commit: 'a9844bd',
|
||||
commit: '93fbb1e',
|
||||
schema: 'contracts/schemas/ever.stats.v1.json',
|
||||
fixtures: 'contracts/fixtures/stats/',
|
||||
checks: 'packages/ts/connect-sdk/src/stats/checks.ts'
|
||||
|
||||
@@ -4648,7 +4648,10 @@
|
||||
"employee-join": "crwdns10529:0crwdne10529:0",
|
||||
"TEMPLATE_NAMES": "crwdns9828:0crwdne9828:0"
|
||||
},
|
||||
"HTML_EDITOR": "crwdns11166:0crwdne11166:0"
|
||||
"HTML_EDITOR": "crwdns11166:0crwdne11166:0",
|
||||
"FULL_PREVIEW": "Full preview",
|
||||
"VIEW_GRID": "Grid",
|
||||
"VIEW_EDITOR": "Editor"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "crwdns4740:0crwdne4740:0",
|
||||
@@ -4697,8 +4700,75 @@
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "crwdns8136:0crwdne8136:0",
|
||||
"SHOW_SECRET": "Show secret",
|
||||
"HIDE_SECRET": "Hide secret",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Current configuration",
|
||||
"DESCRIPTION": "Last saved settings — what new uploads use right now.",
|
||||
"PROVIDER": "Provider",
|
||||
"BUCKET": "Bucket / cloud name",
|
||||
"REGION": "Region",
|
||||
"ENDPOINT": "Endpoint",
|
||||
"CREDENTIALS": "Credentials",
|
||||
"CREDENTIALS_SAVED": "Saved",
|
||||
"CREDENTIALS_INCOMPLETE": "Incomplete",
|
||||
"CREDENTIALS_NONE": "Not set",
|
||||
"CREDENTIALS_NOT_NEEDED": "Not needed",
|
||||
"SERVER_DEFAULT": "Server default"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Where to get the keys",
|
||||
"LOCAL": {
|
||||
"NAME": "Local disk",
|
||||
"DESCRIPTION": "files are saved on the API server's own file system."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "files are saved to an AWS S3 bucket. Use an IAM access key that can read and write the bucket."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "S3-compatible cloud storage. The credentials are verified with Wasabi when you save."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "image and video hosting. The cloud name and API keys are in the Cloudinary console under API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "S3-compatible object storage with an optional CDN. Create a Spaces access key in the DigitalOcean control panel."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Local storage",
|
||||
"WHERE": "Uploaded files are written to the API server's file system.",
|
||||
"NO_CONFIG": "Nothing to configure — local storage works out of the box.",
|
||||
"WARNING": "Files are lost if the server is redeployed without a persistent volume, and they are not shared between several API instances. Use a cloud provider in production."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Check configuration",
|
||||
"OK": "All required fields are filled in and the URLs are valid. The keys themselves are only verified by the provider.",
|
||||
"OK_LOCAL": "Local storage needs no configuration.",
|
||||
"OK_SERVER_DEFAULTS": "Nothing is missing. Left empty, so the server's own configuration is used:",
|
||||
"MISSING": "Missing:",
|
||||
"INVALID_URL": "Not a valid http(s) URL:",
|
||||
"HTTPS_REQUIRED": "Must use https:// — the keys are sent to this address:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "crwdns10956:0crwdne10956:0",
|
||||
"SUBTITLE": "Store uploaded files in an Amazon S3 bucket.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credentials",
|
||||
"CREDENTIALS_HINT": "An IAM access key that can read and write the bucket.",
|
||||
"BUCKET": "Bucket",
|
||||
"BUCKET_HINT": "Where uploaded files are written."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "The public half of the key pair.",
|
||||
"SECRET_ACCESS_KEY": "Shown only once, when the key is created. Saved values come back masked.",
|
||||
"REGION": "The AWS region the bucket lives in, e.g. us-east-1 or eu-west-1.",
|
||||
"BUCKET": "The bucket name exactly as it appears in the S3 console."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "crwdns10958:0crwdne10958:0",
|
||||
"SECRET_ACCESS_KEY": "crwdns10960:0crwdne10960:0",
|
||||
@@ -4714,12 +4784,32 @@
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "crwdns10974:0crwdne10974:0",
|
||||
"SUBTITLE": "Store uploaded files in a Wasabi bucket through its S3-compatible API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credentials",
|
||||
"CREDENTIALS_HINT": "An access key pair from the Wasabi console.",
|
||||
"BUCKET": "Bucket & endpoint",
|
||||
"BUCKET_HINT": "Where uploaded files are written.",
|
||||
"OPTIONS": "Options"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "The public half of the key pair.",
|
||||
"SECRET_ACCESS_KEY": "Shown only once, when the key is created. Saved values come back masked.",
|
||||
"REGION": "The region the bucket lives in, e.g. us-east-1 or eu-central-1.",
|
||||
"SERVICE_URL": "The S3 endpoint for that region.",
|
||||
"BUCKET": "Lowercase letters, numbers, dots and hyphens. Names are unique across all of Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Address files as endpoint/bucket/file instead of bucket.endpoint/file. Leave on unless you need virtual-hosted URLs."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Endpoint for this region:",
|
||||
"USE_SUGGESTION": "Use it",
|
||||
"SAVE_NOTE": "When you save, Gauzy uses these credentials to create the bucket in Wasabi. If Wasabi rejects the request, the settings are not saved.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "crwdns10976:0crwdne10976:0",
|
||||
"SECRET_ACCESS_KEY": "crwdns10978:0crwdne10978:0",
|
||||
"REGION": "crwdns10980:0crwdne10980:0",
|
||||
"BUCKET": "crwdns10982:0crwdne10982:0",
|
||||
"SERVICE_URL": "crwdns10984:0crwdne10984:0"
|
||||
"SERVICE_URL": "crwdns10984:0crwdne10984:0",
|
||||
"FORCE_PATH_STYLE": "Force path-style URLs"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "crwdns10986:0crwdne10986:0",
|
||||
@@ -4728,6 +4818,74 @@
|
||||
"BUCKET": "crwdns10992:0crwdne10992:0",
|
||||
"SERVICE_URL": "crwdns10994:0crwdne10994:0"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Cloudinary Configuration",
|
||||
"SUBTITLE": "Store uploaded files in Cloudinary and serve them from its delivery network.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Account",
|
||||
"ACCOUNT_HINT": "All three values are in the Cloudinary console under API Keys.",
|
||||
"DELIVERY": "Delivery",
|
||||
"DELIVERY_HINT": "How links to uploaded files are built."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "The name shown at the top of your Cloudinary console.",
|
||||
"ACCESS_API_KEY": "Identifies the account; used together with the API secret.",
|
||||
"ACCESS_API_SECRET": "Saved values come back masked.",
|
||||
"DELIVERY_URL": "Base address of file links. Keep https://res.cloudinary.com unless you use a custom domain.",
|
||||
"SECURE": "Build file links with https://. Leave on True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Cloud name",
|
||||
"ACCESS_API_KEY": "API key",
|
||||
"ACCESS_API_SECRET": "API secret",
|
||||
"DELIVERY_URL": "Delivery URL",
|
||||
"SECURE": "Secure (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Cloud name",
|
||||
"ACCESS_API_KEY": "API key",
|
||||
"ACCESS_API_SECRET": "API secret",
|
||||
"DELIVERY_URL": "Delivery URL",
|
||||
"SECURE": "Secure (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "DigitalOcean Configuration",
|
||||
"SUBTITLE": "Store uploaded files in a DigitalOcean Space through its S3-compatible API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credentials",
|
||||
"CREDENTIALS_HINT": "A Spaces access key from the DigitalOcean control panel.",
|
||||
"BUCKET": "Space & endpoint",
|
||||
"BUCKET_HINT": "Where uploaded files are written.",
|
||||
"OPTIONS": "Options"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "The public half of the key pair.",
|
||||
"SECRET_ACCESS_KEY": "Shown only once, when the key is created. Saved values come back masked.",
|
||||
"SERVICE_URL": "The endpoint of your Space's datacenter, e.g. https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Fixed to us-east-1, as Spaces expects; the datacenter comes from the endpoint.",
|
||||
"BUCKET": "The name of the Space.",
|
||||
"CDN_URL": "Optional. The Space's CDN endpoint, if the CDN is enabled.",
|
||||
"FORCE_PATH_STYLE": "Address files as endpoint/space/file instead of space.endpoint/file."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Access key ID",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service URL",
|
||||
"CDN_URL": "CDN URL",
|
||||
"FORCE_PATH_STYLE": "Force path-style URLs"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Access key ID",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service URL",
|
||||
"CDN_URL": "CDN URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS_MONITORING": {
|
||||
@@ -4823,7 +4981,11 @@
|
||||
"invoice": "crwdns10178:0crwdne10178:0",
|
||||
"estimate": "crwdns10180:0crwdne10180:0",
|
||||
"receipt": "crwdns10236:0crwdne10236:0"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Grid",
|
||||
"VIEW_EDITOR": "Editor",
|
||||
"FULL_PREVIEW": "Full preview",
|
||||
"SAVED": "Template '{{ templateName }}' was saved"
|
||||
},
|
||||
"LOGIN_PAGE": {
|
||||
"TITLE": "crwdns10825:0crwdne10825:0",
|
||||
|
||||
@@ -5280,7 +5280,10 @@
|
||||
"payment-receipt": "إيصال الدفع",
|
||||
"reject-candidate": "رفض المرشح"
|
||||
},
|
||||
"HTML_EDITOR": "محرر HTML"
|
||||
"HTML_EDITOR": "محرر HTML",
|
||||
"FULL_PREVIEW": "معاينة كاملة",
|
||||
"VIEW_GRID": "شبكة",
|
||||
"VIEW_EDITOR": "المحرر"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "الأنابيب",
|
||||
@@ -5330,54 +5333,192 @@
|
||||
"QUICK_ACTIONS": "إجراءات سريعة"
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "مزود الملفات",
|
||||
"FILE_PROVIDER": "مزوّد الملفات",
|
||||
"SHOW_SECRET": "إظهار السر",
|
||||
"HIDE_SECRET": "إخفاء السر",
|
||||
"SUMMARY": {
|
||||
"TITLE": "الإعداد الحالي",
|
||||
"DESCRIPTION": "آخر إعدادات محفوظة — تستخدمها عمليات الرفع الجديدة.",
|
||||
"PROVIDER": "المزوّد",
|
||||
"BUCKET": "الحاوية / اسم السحابة",
|
||||
"REGION": "المنطقة",
|
||||
"ENDPOINT": "نقطة النهاية",
|
||||
"CREDENTIALS": "بيانات الاعتماد",
|
||||
"CREDENTIALS_SAVED": "محفوظة",
|
||||
"CREDENTIALS_INCOMPLETE": "غير مكتملة",
|
||||
"CREDENTIALS_NONE": "غير معيّنة",
|
||||
"CREDENTIALS_NOT_NEEDED": "غير مطلوبة",
|
||||
"SERVER_DEFAULT": "الإعداد الافتراضي للخادم"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "أين تحصل على المفاتيح",
|
||||
"LOCAL": {
|
||||
"NAME": "القرص المحلي",
|
||||
"DESCRIPTION": "تُحفظ الملفات في نظام ملفات خادم API."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "تُحفظ الملفات في حاوية AWS S3. استخدم مفتاح وصول IAM يملك صلاحية القراءة والكتابة على الحاوية."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "تخزين سحابي متوافق مع S3. يتم التحقق من بيانات الاعتماد مع Wasabi عند الحفظ."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "استضافة الصور والفيديو. تجد اسم السحابة ومفاتيح API في لوحة Cloudinary ضمن API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "تخزين كائنات متوافق مع S3 مع شبكة CDN اختيارية. أنشئ مفتاح وصول Spaces من لوحة تحكم DigitalOcean."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "التخزين المحلي",
|
||||
"WHERE": "تُكتب الملفات المرفوعة في نظام ملفات خادم API.",
|
||||
"NO_CONFIG": "لا شيء لإعداده — التخزين المحلي يعمل مباشرة.",
|
||||
"WARNING": "تُفقد الملفات إذا أُعيد نشر الخادم بدون وحدة تخزين دائمة، ولا تتم مشاركتها بين عدة نسخ من API. استخدم مزوّدًا سحابيًا في بيئة الإنتاج."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "التحقق من الإعداد",
|
||||
"OK": "جميع الحقول المطلوبة مملوءة وعناوين URL صالحة. المفاتيح نفسها يتحقق منها المزوّد فقط.",
|
||||
"OK_LOCAL": "التخزين المحلي لا يحتاج إلى إعداد.",
|
||||
"OK_SERVER_DEFAULTS": "لا شيء ناقص. هذه الحقول الفارغة تستخدم إعدادات الخادم:",
|
||||
"MISSING": "ناقص:",
|
||||
"INVALID_URL": "عنوان http(s) غير صالح:",
|
||||
"HTTPS_REQUIRED": "يجب استخدام https:// — تُرسل المفاتيح إلى هذا العنوان:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "تكوين S3",
|
||||
"HEADER": "إعداد S3",
|
||||
"SUBTITLE": "خزّن الملفات المرفوعة في حاوية Amazon S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "بيانات الاعتماد",
|
||||
"CREDENTIALS_HINT": "مفتاح وصول IAM يملك صلاحية القراءة والكتابة على الحاوية.",
|
||||
"BUCKET": "الحاوية",
|
||||
"BUCKET_HINT": "مكان كتابة الملفات المرفوعة."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "الجزء العام من زوج المفاتيح.",
|
||||
"SECRET_ACCESS_KEY": "يُعرض مرة واحدة فقط عند إنشاء المفتاح. تعود القيم المحفوظة مقنّعة.",
|
||||
"REGION": "منطقة AWS التي توجد فيها الحاوية، مثل us-east-1 أو eu-west-1.",
|
||||
"BUCKET": "اسم الحاوية تمامًا كما يظهر في لوحة S3."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "معرّف مفتاح الوصول",
|
||||
"SECRET_ACCESS_KEY": "مفتاح الوصول السري",
|
||||
"REGION": "المنطقة",
|
||||
"BUCKET": "دلو"
|
||||
"BUCKET": "الحاوية"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "معرف مفتاح الوصول",
|
||||
"ACCESS_KEY_ID": "معرّف مفتاح الوصول",
|
||||
"SECRET_ACCESS_KEY": "مفتاح الوصول السري",
|
||||
"REGION": "المنطقة",
|
||||
"BUCKET": "دلو"
|
||||
"BUCKET": "الحاوية"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "تكوين واسابي",
|
||||
"HEADER": "إعداد Wasabi",
|
||||
"SUBTITLE": "خزّن الملفات المرفوعة في حاوية Wasabi عبر واجهة API المتوافقة مع S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "بيانات الاعتماد",
|
||||
"CREDENTIALS_HINT": "زوج مفاتيح وصول من لوحة Wasabi.",
|
||||
"BUCKET": "الحاوية ونقطة النهاية",
|
||||
"BUCKET_HINT": "مكان كتابة الملفات المرفوعة.",
|
||||
"OPTIONS": "خيارات"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "الجزء العام من زوج المفاتيح.",
|
||||
"SECRET_ACCESS_KEY": "يُعرض مرة واحدة فقط عند إنشاء المفتاح. تعود القيم المحفوظة مقنّعة.",
|
||||
"REGION": "منطقة الحاوية، مثل us-east-1 أو eu-central-1.",
|
||||
"SERVICE_URL": "نقطة نهاية S3 لتلك المنطقة.",
|
||||
"BUCKET": "أحرف صغيرة وأرقام ونقاط وشرطات. الأسماء فريدة على مستوى Wasabi بالكامل.",
|
||||
"FORCE_PATH_STYLE": "عنونة الملفات بصيغة endpoint/bucket/file بدلًا من bucket.endpoint/file. اتركه مفعّلًا ما لم تحتج إلى عناوين virtual-hosted."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "نقطة النهاية لهذه المنطقة:",
|
||||
"USE_SUGGESTION": "استخدمها",
|
||||
"SAVE_NOTE": "عند الحفظ، يستخدم Gauzy بيانات الاعتماد هذه لإنشاء الحاوية في Wasabi. إذا رفضت Wasabi الطلب، فلن تُحفظ الإعدادات.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "معرّف مفتاح الوصول",
|
||||
"SECRET_ACCESS_KEY": "المفتاح السري للوصول",
|
||||
"REGION": "المنطقة",
|
||||
"BUCKET": "دلو",
|
||||
"SERVICE_URL": "عنوان URL للخدمة"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "مُعرّف المفتاح الوصول",
|
||||
"SECRET_ACCESS_KEY": "مفتاح الوصول السري",
|
||||
"REGION": "المنطقة",
|
||||
"BUCKET": "دلو",
|
||||
"BUCKET": "الحاوية",
|
||||
"SERVICE_URL": "عنوان URL للخدمة",
|
||||
"FORCE_PATH_STYLE": "فرض عناوين URL بنمط المسار"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "معرّف مفتاح الوصول",
|
||||
"SECRET_ACCESS_KEY": "مفتاح الوصول السري",
|
||||
"REGION": "المنطقة",
|
||||
"BUCKET": "الحاوية",
|
||||
"SERVICE_URL": "عنوان URL للخدمة"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "تكوين كلاوديناري",
|
||||
"HEADER": "إعداد Cloudinary",
|
||||
"SUBTITLE": "خزّن الملفات المرفوعة في Cloudinary وقدّمها عبر شبكة التوزيع الخاصة به.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "الحساب",
|
||||
"ACCOUNT_HINT": "القيم الثلاث موجودة في لوحة Cloudinary ضمن API Keys.",
|
||||
"DELIVERY": "التسليم",
|
||||
"DELIVERY_HINT": "كيفية إنشاء روابط الملفات المرفوعة."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "الاسم الظاهر أعلى لوحة Cloudinary.",
|
||||
"ACCESS_API_KEY": "يحدد الحساب؛ يُستخدم مع سر API.",
|
||||
"ACCESS_API_SECRET": "تعود القيم المحفوظة مقنّعة.",
|
||||
"DELIVERY_URL": "العنوان الأساسي لروابط الملفات. احتفظ بـ https://res.cloudinary.com ما لم تستخدم نطاقًا مخصصًا.",
|
||||
"SECURE": "إنشاء روابط الملفات باستخدام https://. اتركه على True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "اسم كلاوديناري",
|
||||
"ACCESS_API_KEY": "معرف مفتاح الوصول",
|
||||
"ACCESS_API_SECRET": "مفتاح الوصول السري",
|
||||
"DELIVERY_URL": "عنوان التسليم",
|
||||
"SECURE": "آمن"
|
||||
"CLOUD_NAME": "اسم السحابة",
|
||||
"ACCESS_API_KEY": "مفتاح API",
|
||||
"ACCESS_API_SECRET": "سر API",
|
||||
"DELIVERY_URL": "عنوان URL للتسليم",
|
||||
"SECURE": "آمن (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "اسم كلاوديناري",
|
||||
"ACCESS_API_KEY": "معرّف مفتاح الوصول",
|
||||
"ACCESS_API_SECRET": "مفتاح الوصول السري",
|
||||
"DELIVERY_URL": "رابط التوصيل",
|
||||
"SECURE": "آمن"
|
||||
"CLOUD_NAME": "اسم السحابة",
|
||||
"ACCESS_API_KEY": "مفتاح API",
|
||||
"ACCESS_API_SECRET": "سر API",
|
||||
"DELIVERY_URL": "عنوان URL للتسليم",
|
||||
"SECURE": "آمن (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "إعداد DigitalOcean",
|
||||
"SUBTITLE": "خزّن الملفات المرفوعة في DigitalOcean Space عبر واجهة API المتوافقة مع S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "بيانات الاعتماد",
|
||||
"CREDENTIALS_HINT": "مفتاح وصول Spaces من لوحة تحكم DigitalOcean.",
|
||||
"BUCKET": "Space ونقطة النهاية",
|
||||
"BUCKET_HINT": "مكان كتابة الملفات المرفوعة.",
|
||||
"OPTIONS": "خيارات"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "الجزء العام من زوج المفاتيح.",
|
||||
"SECRET_ACCESS_KEY": "يُعرض مرة واحدة فقط عند إنشاء المفتاح. تعود القيم المحفوظة مقنّعة.",
|
||||
"SERVICE_URL": "نقطة نهاية مركز البيانات الخاص بـ Space، مثل https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "مثبّتة على us-east-1 كما يتطلب Spaces؛ ويُحدَّد مركز البيانات من نقطة النهاية.",
|
||||
"BUCKET": "اسم الـ Space.",
|
||||
"CDN_URL": "اختياري. نقطة نهاية CDN الخاصة بالـ Space إذا كانت CDN مفعّلة.",
|
||||
"FORCE_PATH_STYLE": "عنونة الملفات بصيغة endpoint/space/file بدلًا من space.endpoint/file."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "معرّف مفتاح الوصول",
|
||||
"SECRET_ACCESS_KEY": "مفتاح الوصول السري",
|
||||
"REGION": "المنطقة",
|
||||
"BUCKET": "الحاوية",
|
||||
"SERVICE_URL": "عنوان URL للخدمة",
|
||||
"CDN_URL": "عنوان URL لشبكة CDN",
|
||||
"FORCE_PATH_STYLE": "فرض عناوين URL بنمط المسار"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "معرّف مفتاح الوصول",
|
||||
"SECRET_ACCESS_KEY": "مفتاح الوصول السري",
|
||||
"REGION": "المنطقة",
|
||||
"BUCKET": "الحاوية",
|
||||
"SERVICE_URL": "عنوان URL للخدمة",
|
||||
"CDN_URL": "عنوان URL لشبكة CDN"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5476,7 +5617,11 @@
|
||||
"invoice": "فاتورة",
|
||||
"estimate": "تقدير",
|
||||
"receipt": "إيصال"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "شبكة",
|
||||
"VIEW_EDITOR": "المحرر",
|
||||
"FULL_PREVIEW": "معاينة كاملة",
|
||||
"SAVED": "تم حفظ القالب '{{ templateName }}'"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "تسجيل",
|
||||
|
||||
@@ -5341,7 +5341,10 @@
|
||||
"payment-receipt": "Касова бележка",
|
||||
"reject-candidate": "Отхвърли кандидат"
|
||||
},
|
||||
"HTML_EDITOR": "HTML редактор"
|
||||
"HTML_EDITOR": "HTML редактор",
|
||||
"FULL_PREVIEW": "Пълен преглед",
|
||||
"VIEW_GRID": "Мрежа",
|
||||
"VIEW_EDITOR": "Редактор"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Тръбопроводи",
|
||||
@@ -5392,37 +5395,192 @@
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "Доставчик на файлове",
|
||||
"SHOW_SECRET": "Покажи тайната",
|
||||
"HIDE_SECRET": "Скрий тайната",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Текуща конфигурация",
|
||||
"DESCRIPTION": "Последно запазени настройки — използват се за новите качвания.",
|
||||
"PROVIDER": "Доставчик",
|
||||
"BUCKET": "Бъкет / име на облака",
|
||||
"REGION": "Регион",
|
||||
"ENDPOINT": "Крайна точка",
|
||||
"CREDENTIALS": "Идентификационни данни",
|
||||
"CREDENTIALS_SAVED": "Запазени",
|
||||
"CREDENTIALS_INCOMPLETE": "Непълни",
|
||||
"CREDENTIALS_NONE": "Не са зададени",
|
||||
"CREDENTIALS_NOT_NEEDED": "Не са нужни",
|
||||
"SERVER_DEFAULT": "По подразбиране от сървъра"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Откъде да вземете ключовете",
|
||||
"LOCAL": {
|
||||
"NAME": "Локален диск",
|
||||
"DESCRIPTION": "файловете се записват във файловата система на API сървъра."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "файловете се записват в AWS S3 бъкет. Използвайте IAM ключ за достъп с права за четене и запис в бъкета."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "S3-съвместимо облачно хранилище. Данните за достъп се проверяват в Wasabi при запазване."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "хостинг на изображения и видео. Името на облака и API ключовете са в конзолата на Cloudinary, в раздел API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "S3-съвместимо обектно хранилище с незадължителен CDN. Създайте ключ за достъп до Spaces в контролния панел на DigitalOcean."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Локално хранилище",
|
||||
"WHERE": "Качените файлове се записват във файловата система на API сървъра.",
|
||||
"NO_CONFIG": "Няма нищо за настройване — локалното хранилище работи веднага.",
|
||||
"WARNING": "Файловете се губят, ако сървърът бъде преинсталиран без постоянен том, и не се споделят между няколко API инстанции. В продукция използвайте облачен доставчик."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Провери конфигурацията",
|
||||
"OK": "Всички задължителни полета са попълнени и URL адресите са валидни. Самите ключове се проверяват само от доставчика.",
|
||||
"OK_LOCAL": "Локалното хранилище не изисква настройка.",
|
||||
"OK_SERVER_DEFAULTS": "Нищо не липсва. Тези празни полета използват конфигурацията на сървъра:",
|
||||
"MISSING": "Липсва:",
|
||||
"INVALID_URL": "Невалиден http(s) URL:",
|
||||
"HTTPS_REQUIRED": "Изисква се https:// — ключовете се изпращат на този адрес:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "S3 Конфигурация",
|
||||
"HEADER": "Конфигурация на S3",
|
||||
"SUBTITLE": "Съхранявайте качените файлове в Amazon S3 бъкет.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Данни за достъп",
|
||||
"CREDENTIALS_HINT": "IAM ключ за достъп с права за четене и запис в бъкета.",
|
||||
"BUCKET": "Бъкет",
|
||||
"BUCKET_HINT": "Къде се записват качените файлове."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Публичната част от двойката ключове.",
|
||||
"SECRET_ACCESS_KEY": "Показва се само веднъж, при създаване на ключа. Запазените стойности се връщат маскирани.",
|
||||
"REGION": "AWS регионът на бъкета, напр. us-east-1 или eu-west-1.",
|
||||
"BUCKET": "Името на бъкета точно както е в конзолата на S3."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Идентификатор на ключ за достъп",
|
||||
"ACCESS_KEY_ID": "ID на ключ за достъп",
|
||||
"SECRET_ACCESS_KEY": "Таен ключ за достъп",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Кофа"
|
||||
"BUCKET": "Бъкет"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Идентификатор на ключ за достъп",
|
||||
"ACCESS_KEY_ID": "ID на ключ за достъп",
|
||||
"SECRET_ACCESS_KEY": "Таен ключ за достъп",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Кофа"
|
||||
"BUCKET": "Бъкет"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Wasabi Конфигурация",
|
||||
"HEADER": "Конфигурация на Wasabi",
|
||||
"SUBTITLE": "Съхранявайте качените файлове в Wasabi бъкет чрез неговия S3-съвместим API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Данни за достъп",
|
||||
"CREDENTIALS_HINT": "Двойка ключове за достъп от конзолата на Wasabi.",
|
||||
"BUCKET": "Бъкет и крайна точка",
|
||||
"BUCKET_HINT": "Къде се записват качените файлове.",
|
||||
"OPTIONS": "Опции"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Публичната част от двойката ключове.",
|
||||
"SECRET_ACCESS_KEY": "Показва се само веднъж, при създаване на ключа. Запазените стойности се връщат маскирани.",
|
||||
"REGION": "Регионът на бъкета, напр. us-east-1 или eu-central-1.",
|
||||
"SERVICE_URL": "S3 крайната точка за този регион.",
|
||||
"BUCKET": "Малки букви, цифри, точки и тирета. Имената са уникални в целия Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Адресира файловете като endpoint/бъкет/файл вместо бъкет.endpoint/файл. Оставете включено, освен ако не ви трябват virtual-hosted адреси."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Крайна точка за този регион:",
|
||||
"USE_SUGGESTION": "Използвай",
|
||||
"SAVE_NOTE": "При запазване Gauzy използва тези данни, за да създаде бъкета в Wasabi. Ако Wasabi откаже заявката, настройките не се запазват.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Идентификатор на ключ за достъп",
|
||||
"ACCESS_KEY_ID": "ID на ключ за достъп",
|
||||
"SECRET_ACCESS_KEY": "Таен ключ за достъп",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Кофа",
|
||||
"SERVICE_URL": "URL на услугата"
|
||||
"BUCKET": "Бъкет",
|
||||
"SERVICE_URL": "URL на услугата",
|
||||
"FORCE_PATH_STYLE": "URL адреси в стил път"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Идентификатор на ключ за достъп",
|
||||
"ACCESS_KEY_ID": "ID на ключ за достъп",
|
||||
"SECRET_ACCESS_KEY": "Таен ключ за достъп",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Кофа",
|
||||
"BUCKET": "Бъкет",
|
||||
"SERVICE_URL": "URL на услугата"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Конфигурация на Cloudinary",
|
||||
"SUBTITLE": "Съхранявайте качените файлове в Cloudinary и ги доставяйте чрез неговата мрежа.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Акаунт",
|
||||
"ACCOUNT_HINT": "И трите стойности са в конзолата на Cloudinary, в раздел API Keys.",
|
||||
"DELIVERY": "Доставка",
|
||||
"DELIVERY_HINT": "Как се изграждат връзките към качените файлове."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "Името, показано в горната част на конзолата на Cloudinary.",
|
||||
"ACCESS_API_KEY": "Идентифицира акаунта; използва се заедно с API тайната.",
|
||||
"ACCESS_API_SECRET": "Запазените стойности се връщат маскирани.",
|
||||
"DELIVERY_URL": "Базов адрес на връзките. Оставете https://res.cloudinary.com, освен ако не ползвате собствен домейн.",
|
||||
"SECURE": "Създава връзките с https://. Оставете True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Име на облака",
|
||||
"ACCESS_API_KEY": "API ключ",
|
||||
"ACCESS_API_SECRET": "API тайна",
|
||||
"DELIVERY_URL": "URL за доставка",
|
||||
"SECURE": "Защитена връзка (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Име на облака",
|
||||
"ACCESS_API_KEY": "API ключ",
|
||||
"ACCESS_API_SECRET": "API тайна",
|
||||
"DELIVERY_URL": "URL за доставка",
|
||||
"SECURE": "Защитена връзка (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "Конфигурация на DigitalOcean",
|
||||
"SUBTITLE": "Съхранявайте качените файлове в DigitalOcean Space чрез неговия S3-съвместим API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Данни за достъп",
|
||||
"CREDENTIALS_HINT": "Ключ за достъп до Spaces от контролния панел на DigitalOcean.",
|
||||
"BUCKET": "Space и крайна точка",
|
||||
"BUCKET_HINT": "Къде се записват качените файлове.",
|
||||
"OPTIONS": "Опции"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Публичната част от двойката ключове.",
|
||||
"SECRET_ACCESS_KEY": "Показва се само веднъж, при създаване на ключа. Запазените стойности се връщат маскирани.",
|
||||
"SERVICE_URL": "Крайната точка на центъра за данни на вашия Space, напр. https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Фиксиран на us-east-1, както изисква Spaces; центърът за данни се определя от крайната точка.",
|
||||
"BUCKET": "Името на Space.",
|
||||
"CDN_URL": "По избор. CDN крайната точка на Space, ако CDN е включен.",
|
||||
"FORCE_PATH_STYLE": "Адресира файловете като endpoint/space/файл вместо space.endpoint/файл."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID на ключ за достъп",
|
||||
"SECRET_ACCESS_KEY": "Таен ключ за достъп",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Бъкет",
|
||||
"SERVICE_URL": "URL на услугата",
|
||||
"CDN_URL": "URL на CDN",
|
||||
"FORCE_PATH_STYLE": "URL адреси в стил път"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID на ключ за достъп",
|
||||
"SECRET_ACCESS_KEY": "Таен ключ за достъп",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Бъкет",
|
||||
"SERVICE_URL": "URL на услугата",
|
||||
"CDN_URL": "URL на CDN"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS_MONITORING": {
|
||||
@@ -5519,7 +5677,11 @@
|
||||
"invoice": "Фактура",
|
||||
"estimate": "Оферта",
|
||||
"receipt": "Касова бележка"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Мрежа",
|
||||
"VIEW_EDITOR": "Редактор",
|
||||
"FULL_PREVIEW": "Пълен преглед",
|
||||
"SAVED": "Шаблонът '{{ templateName }}' беше запазен"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "Регистрация",
|
||||
|
||||
@@ -5291,7 +5291,10 @@
|
||||
"payment-receipt": "Zahlungseingang",
|
||||
"reject-candidate": "Kandidat ablehnen"
|
||||
},
|
||||
"HTML_EDITOR": "HTML-Editor"
|
||||
"HTML_EDITOR": "HTML-Editor",
|
||||
"FULL_PREVIEW": "Vollständige Vorschau",
|
||||
"VIEW_GRID": "Raster",
|
||||
"VIEW_EDITOR": "Editor"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Pipelines",
|
||||
@@ -5342,53 +5345,191 @@
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "Dateianbieter",
|
||||
"SHOW_SECRET": "Geheimnis anzeigen",
|
||||
"HIDE_SECRET": "Geheimnis verbergen",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Aktuelle Konfiguration",
|
||||
"DESCRIPTION": "Zuletzt gespeicherte Einstellungen — werden für neue Uploads verwendet.",
|
||||
"PROVIDER": "Anbieter",
|
||||
"BUCKET": "Bucket / Cloud-Name",
|
||||
"REGION": "Region",
|
||||
"ENDPOINT": "Endpunkt",
|
||||
"CREDENTIALS": "Zugangsdaten",
|
||||
"CREDENTIALS_SAVED": "Gespeichert",
|
||||
"CREDENTIALS_INCOMPLETE": "Unvollständig",
|
||||
"CREDENTIALS_NONE": "Nicht festgelegt",
|
||||
"CREDENTIALS_NOT_NEEDED": "Nicht erforderlich",
|
||||
"SERVER_DEFAULT": "Server-Standard"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Wo Sie die Schlüssel erhalten",
|
||||
"LOCAL": {
|
||||
"NAME": "Lokaler Datenträger",
|
||||
"DESCRIPTION": "Dateien werden im Dateisystem des API-Servers gespeichert."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "Dateien werden in einem AWS-S3-Bucket gespeichert. Verwenden Sie einen IAM-Zugriffsschlüssel mit Lese- und Schreibrechten für den Bucket."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "S3-kompatibler Cloud-Speicher. Die Zugangsdaten werden beim Speichern bei Wasabi überprüft."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "Bild- und Video-Hosting. Cloud-Name und API-Schlüssel finden Sie in der Cloudinary-Konsole unter API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "S3-kompatibler Objektspeicher mit optionalem CDN. Erstellen Sie einen Spaces-Zugriffsschlüssel im DigitalOcean-Control-Panel."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Lokaler Speicher",
|
||||
"WHERE": "Hochgeladene Dateien werden in das Dateisystem des API-Servers geschrieben.",
|
||||
"NO_CONFIG": "Nichts zu konfigurieren — lokaler Speicher funktioniert sofort.",
|
||||
"WARNING": "Dateien gehen verloren, wenn der Server ohne persistentes Volume neu bereitgestellt wird, und werden nicht zwischen mehreren API-Instanzen geteilt. Verwenden Sie in der Produktion einen Cloud-Anbieter."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Konfiguration prüfen",
|
||||
"OK": "Alle Pflichtfelder sind ausgefüllt und die URLs sind gültig. Die Schlüssel selbst werden nur vom Anbieter überprüft.",
|
||||
"OK_LOCAL": "Lokaler Speicher erfordert keine Konfiguration.",
|
||||
"OK_SERVER_DEFAULTS": "Nichts fehlt. Diese leeren Felder verwenden die Konfiguration des Servers:",
|
||||
"MISSING": "Fehlt:",
|
||||
"INVALID_URL": "Keine gültige http(s)-URL:",
|
||||
"HTTPS_REQUIRED": "Muss https:// verwenden — die Schlüssel werden an diese Adresse gesendet:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "S3-Konfiguration",
|
||||
"SUBTITLE": "Hochgeladene Dateien in einem Amazon-S3-Bucket speichern.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Zugangsdaten",
|
||||
"CREDENTIALS_HINT": "Ein IAM-Zugriffsschlüssel mit Lese- und Schreibrechten für den Bucket.",
|
||||
"BUCKET": "Bucket",
|
||||
"BUCKET_HINT": "Wohin hochgeladene Dateien geschrieben werden."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Der öffentliche Teil des Schlüsselpaars.",
|
||||
"SECRET_ACCESS_KEY": "Wird nur einmal beim Erstellen des Schlüssels angezeigt. Gespeicherte Werte kommen maskiert zurück.",
|
||||
"REGION": "Die AWS-Region des Buckets, z. B. us-east-1 oder eu-west-1.",
|
||||
"BUCKET": "Der Bucket-Name genau wie in der S3-Konsole."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Zugriffsschlüssel ID",
|
||||
"ACCESS_KEY_ID": "Zugriffsschlüssel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheimer Zugriffsschlüssel",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Eimer"
|
||||
"BUCKET": "Bucket"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Zugriffsschlüssel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheimer Zugriffsschlüssel",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Eimer"
|
||||
"BUCKET": "Bucket"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Wasabi-Konfiguration",
|
||||
"SUBTITLE": "Hochgeladene Dateien in einem Wasabi-Bucket über dessen S3-kompatible API speichern.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Zugangsdaten",
|
||||
"CREDENTIALS_HINT": "Ein Zugriffsschlüsselpaar aus der Wasabi-Konsole.",
|
||||
"BUCKET": "Bucket und Endpunkt",
|
||||
"BUCKET_HINT": "Wohin hochgeladene Dateien geschrieben werden.",
|
||||
"OPTIONS": "Optionen"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Der öffentliche Teil des Schlüsselpaars.",
|
||||
"SECRET_ACCESS_KEY": "Wird nur einmal beim Erstellen des Schlüssels angezeigt. Gespeicherte Werte kommen maskiert zurück.",
|
||||
"REGION": "Die Region des Buckets, z. B. us-east-1 oder eu-central-1.",
|
||||
"SERVICE_URL": "Der S3-Endpunkt dieser Region.",
|
||||
"BUCKET": "Kleinbuchstaben, Ziffern, Punkte und Bindestriche. Namen sind in ganz Wasabi eindeutig.",
|
||||
"FORCE_PATH_STYLE": "Dateien als Endpunkt/Bucket/Datei statt Bucket.Endpunkt/Datei adressieren. Aktiviert lassen, sofern Sie keine Virtual-Hosted-URLs benötigen."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Endpunkt für diese Region:",
|
||||
"USE_SUGGESTION": "Übernehmen",
|
||||
"SAVE_NOTE": "Beim Speichern legt Gauzy mit diesen Zugangsdaten den Bucket in Wasabi an. Lehnt Wasabi die Anfrage ab, werden die Einstellungen nicht gespeichert.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Zugriffsschlüssel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheimer Zugangsschlüssel",
|
||||
"SECRET_ACCESS_KEY": "Geheimer Zugriffsschlüssel",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Eimer",
|
||||
"SERVICE_URL": "Service-URL"
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service-URL",
|
||||
"FORCE_PATH_STYLE": "Pfad-URLs erzwingen"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Zugriffsschlüssel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheimer Zugriffsschlüssel",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Eimer",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service-URL"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Cloudinary Konfiguration",
|
||||
"HEADER": "Cloudinary-Konfiguration",
|
||||
"SUBTITLE": "Hochgeladene Dateien in Cloudinary speichern und über dessen Auslieferungsnetz bereitstellen.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Konto",
|
||||
"ACCOUNT_HINT": "Alle drei Werte finden Sie in der Cloudinary-Konsole unter API Keys.",
|
||||
"DELIVERY": "Auslieferung",
|
||||
"DELIVERY_HINT": "Wie Links zu hochgeladenen Dateien gebildet werden."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "Der Name oben in Ihrer Cloudinary-Konsole.",
|
||||
"ACCESS_API_KEY": "Identifiziert das Konto; wird zusammen mit dem API-Secret verwendet.",
|
||||
"ACCESS_API_SECRET": "Gespeicherte Werte kommen maskiert zurück.",
|
||||
"DELIVERY_URL": "Basisadresse der Datei-Links. Behalten Sie https://res.cloudinary.com, sofern Sie keine eigene Domain nutzen.",
|
||||
"SECURE": "Datei-Links mit https:// erzeugen. Auf True lassen."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Cloudinary Name",
|
||||
"ACCESS_API_KEY": "Zugriffsschlüssel-ID",
|
||||
"ACCESS_API_SECRET": "Geheimer Zugriffsschlüssel",
|
||||
"DELIVERY_URL": "Liefer-URL",
|
||||
"SECURE": "Sicher"
|
||||
"CLOUD_NAME": "Cloud-Name",
|
||||
"ACCESS_API_KEY": "API-Schlüssel",
|
||||
"ACCESS_API_SECRET": "API-Secret",
|
||||
"DELIVERY_URL": "Auslieferungs-URL",
|
||||
"SECURE": "Sicher (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Cloudinary Name",
|
||||
"ACCESS_API_KEY": "Zugriffsschlüssel-ID",
|
||||
"ACCESS_API_SECRET": "Geheimer Zugriffsschlüssel",
|
||||
"DELIVERY_URL": "Liefer-URL",
|
||||
"SECURE": "Sicher"
|
||||
"CLOUD_NAME": "Cloud-Name",
|
||||
"ACCESS_API_KEY": "API-Schlüssel",
|
||||
"ACCESS_API_SECRET": "API-Secret",
|
||||
"DELIVERY_URL": "Auslieferungs-URL",
|
||||
"SECURE": "Sicher (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "DigitalOcean-Konfiguration",
|
||||
"SUBTITLE": "Hochgeladene Dateien in einem DigitalOcean Space über dessen S3-kompatible API speichern.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Zugangsdaten",
|
||||
"CREDENTIALS_HINT": "Ein Spaces-Zugriffsschlüssel aus dem DigitalOcean-Control-Panel.",
|
||||
"BUCKET": "Space und Endpunkt",
|
||||
"BUCKET_HINT": "Wohin hochgeladene Dateien geschrieben werden.",
|
||||
"OPTIONS": "Optionen"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Der öffentliche Teil des Schlüsselpaars.",
|
||||
"SECRET_ACCESS_KEY": "Wird nur einmal beim Erstellen des Schlüssels angezeigt. Gespeicherte Werte kommen maskiert zurück.",
|
||||
"SERVICE_URL": "Der Endpunkt des Rechenzentrums Ihres Space, z. B. https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Fest auf us-east-1, wie von Spaces erwartet; das Rechenzentrum ergibt sich aus dem Endpunkt.",
|
||||
"BUCKET": "Der Name des Space.",
|
||||
"CDN_URL": "Optional. Der CDN-Endpunkt des Space, falls das CDN aktiviert ist.",
|
||||
"FORCE_PATH_STYLE": "Dateien als Endpunkt/Space/Datei statt Space.Endpunkt/Datei adressieren."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Zugriffsschlüssel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheimer Zugriffsschlüssel",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service-URL",
|
||||
"CDN_URL": "CDN-URL",
|
||||
"FORCE_PATH_STYLE": "Pfad-URLs erzwingen"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Zugriffsschlüssel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheimer Zugriffsschlüssel",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service-URL",
|
||||
"CDN_URL": "CDN-URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5487,7 +5628,11 @@
|
||||
"invoice": "Rechnung",
|
||||
"estimate": "Schätzung",
|
||||
"receipt": "Quittung"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Raster",
|
||||
"VIEW_EDITOR": "Editor",
|
||||
"FULL_PREVIEW": "Vollständige Vorschau",
|
||||
"SAVED": "Vorlage '{{ templateName }}' wurde gespeichert"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "Registrieren",
|
||||
|
||||
@@ -5891,7 +5891,10 @@
|
||||
"payment-receipt": "Payment Receipt",
|
||||
"reject-candidate": "Reject Candidate"
|
||||
},
|
||||
"HTML_EDITOR": "HTML Editor"
|
||||
"HTML_EDITOR": "HTML Editor",
|
||||
"FULL_PREVIEW": "Full preview",
|
||||
"VIEW_GRID": "Grid",
|
||||
"VIEW_EDITOR": "Editor"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Pipelines",
|
||||
@@ -5942,16 +5945,83 @@
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "File provider",
|
||||
"SHOW_SECRET": "Show secret",
|
||||
"HIDE_SECRET": "Hide secret",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Current configuration",
|
||||
"DESCRIPTION": "Last saved settings — what new uploads use right now.",
|
||||
"PROVIDER": "Provider",
|
||||
"BUCKET": "Bucket / cloud name",
|
||||
"REGION": "Region",
|
||||
"ENDPOINT": "Endpoint",
|
||||
"CREDENTIALS": "Credentials",
|
||||
"CREDENTIALS_SAVED": "Saved",
|
||||
"CREDENTIALS_INCOMPLETE": "Incomplete",
|
||||
"CREDENTIALS_NONE": "Not set",
|
||||
"CREDENTIALS_NOT_NEEDED": "Not needed",
|
||||
"SERVER_DEFAULT": "Server default"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Where to get the keys",
|
||||
"LOCAL": {
|
||||
"NAME": "Local disk",
|
||||
"DESCRIPTION": "files are saved on the API server's own file system."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "files are saved to an AWS S3 bucket. Use an IAM access key that can read and write the bucket."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "S3-compatible cloud storage. The credentials are verified with Wasabi when you save."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "image and video hosting. The cloud name and API keys are in the Cloudinary console under API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "S3-compatible object storage with an optional CDN. Create a Spaces access key in the DigitalOcean control panel."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Local storage",
|
||||
"WHERE": "Uploaded files are written to the API server's file system.",
|
||||
"NO_CONFIG": "Nothing to configure — local storage works out of the box.",
|
||||
"WARNING": "Files are lost if the server is redeployed without a persistent volume, and they are not shared between several API instances. Use a cloud provider in production."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Check configuration",
|
||||
"OK": "All required fields are filled in and the URLs are valid. The keys themselves are only verified by the provider.",
|
||||
"OK_LOCAL": "Local storage needs no configuration.",
|
||||
"OK_SERVER_DEFAULTS": "Nothing is missing. Left empty, so the server's own configuration is used:",
|
||||
"MISSING": "Missing:",
|
||||
"INVALID_URL": "Not a valid http(s) URL:",
|
||||
"HTTPS_REQUIRED": "Must use https:// — the keys are sent to this address:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "S3 Configuration",
|
||||
"SUBTITLE": "Store uploaded files in an Amazon S3 bucket.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credentials",
|
||||
"CREDENTIALS_HINT": "An IAM access key that can read and write the bucket.",
|
||||
"BUCKET": "Bucket",
|
||||
"BUCKET_HINT": "Where uploaded files are written."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "The public half of the key pair.",
|
||||
"SECRET_ACCESS_KEY": "Shown only once, when the key is created. Saved values come back masked.",
|
||||
"REGION": "The AWS region the bucket lives in, e.g. us-east-1 or eu-west-1.",
|
||||
"BUCKET": "The bucket name exactly as it appears in the S3 console."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"ACCESS_KEY_ID": "Access key ID",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"ACCESS_KEY_ID": "Access key ID",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket"
|
||||
@@ -5959,16 +6029,35 @@
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Wasabi Configuration",
|
||||
"SUBTITLE": "Store uploaded files in a Wasabi bucket through its S3-compatible API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credentials",
|
||||
"CREDENTIALS_HINT": "An access key pair from the Wasabi console.",
|
||||
"BUCKET": "Bucket & endpoint",
|
||||
"BUCKET_HINT": "Where uploaded files are written.",
|
||||
"OPTIONS": "Options"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "The public half of the key pair.",
|
||||
"SECRET_ACCESS_KEY": "Shown only once, when the key is created. Saved values come back masked.",
|
||||
"REGION": "The region the bucket lives in, e.g. us-east-1 or eu-central-1.",
|
||||
"SERVICE_URL": "The S3 endpoint for that region.",
|
||||
"BUCKET": "Lowercase letters, numbers, dots and hyphens. Names are unique across all of Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Address files as endpoint/bucket/file instead of bucket.endpoint/file. Leave on unless you need virtual-hosted URLs."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Endpoint for this region:",
|
||||
"USE_SUGGESTION": "Use it",
|
||||
"SAVE_NOTE": "When you save, Gauzy uses these credentials to create the bucket in Wasabi. If Wasabi rejects the request, the settings are not saved.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"ACCESS_KEY_ID": "Access key ID",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service URL",
|
||||
"FORCE_PATH_STYLE": "Force path style URLs"
|
||||
"FORCE_PATH_STYLE": "Force path-style URLs"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"ACCESS_KEY_ID": "Access key ID",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
@@ -5977,34 +6066,65 @@
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Cloudinary Configuration",
|
||||
"SUBTITLE": "Store uploaded files in Cloudinary and serve them from its delivery network.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Account",
|
||||
"ACCOUNT_HINT": "All three values are in the Cloudinary console under API Keys.",
|
||||
"DELIVERY": "Delivery",
|
||||
"DELIVERY_HINT": "How links to uploaded files are built."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "The name shown at the top of your Cloudinary console.",
|
||||
"ACCESS_API_KEY": "Identifies the account; used together with the API secret.",
|
||||
"ACCESS_API_SECRET": "Saved values come back masked.",
|
||||
"DELIVERY_URL": "Base address of file links. Keep https://res.cloudinary.com unless you use a custom domain.",
|
||||
"SECURE": "Build file links with https://. Leave on True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Cloudinary name",
|
||||
"ACCESS_API_KEY": "Access key id",
|
||||
"ACCESS_API_SECRET": "Secret access key",
|
||||
"CLOUD_NAME": "Cloud name",
|
||||
"ACCESS_API_KEY": "API key",
|
||||
"ACCESS_API_SECRET": "API secret",
|
||||
"DELIVERY_URL": "Delivery URL",
|
||||
"SECURE": "Secure"
|
||||
"SECURE": "Secure (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Cloudinary name",
|
||||
"ACCESS_API_KEY": "Access key id",
|
||||
"ACCESS_API_SECRET": "Secret access key",
|
||||
"CLOUD_NAME": "Cloud name",
|
||||
"ACCESS_API_KEY": "API key",
|
||||
"ACCESS_API_SECRET": "API secret",
|
||||
"DELIVERY_URL": "Delivery URL",
|
||||
"SECURE": "Secure"
|
||||
"SECURE": "Secure (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "DigitalOcean Configuration",
|
||||
"SUBTITLE": "Store uploaded files in a DigitalOcean Space through its S3-compatible API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credentials",
|
||||
"CREDENTIALS_HINT": "A Spaces access key from the DigitalOcean control panel.",
|
||||
"BUCKET": "Space & endpoint",
|
||||
"BUCKET_HINT": "Where uploaded files are written.",
|
||||
"OPTIONS": "Options"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "The public half of the key pair.",
|
||||
"SECRET_ACCESS_KEY": "Shown only once, when the key is created. Saved values come back masked.",
|
||||
"SERVICE_URL": "The endpoint of your Space's datacenter, e.g. https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Fixed to us-east-1, as Spaces expects; the datacenter comes from the endpoint.",
|
||||
"BUCKET": "The name of the Space.",
|
||||
"CDN_URL": "Optional. The Space's CDN endpoint, if the CDN is enabled.",
|
||||
"FORCE_PATH_STYLE": "Address files as endpoint/space/file instead of space.endpoint/file."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"ACCESS_KEY_ID": "Access key ID",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service URL",
|
||||
"CDN_URL": "CDN URL",
|
||||
"FORCE_PATH_STYLE": "Force path style URLs"
|
||||
"FORCE_PATH_STYLE": "Force path-style URLs"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"ACCESS_KEY_ID": "Access key ID",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
@@ -6179,7 +6299,11 @@
|
||||
"invoice": "Invoice",
|
||||
"estimate": "Estimate",
|
||||
"receipt": "Receipt"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Grid",
|
||||
"VIEW_EDITOR": "Editor",
|
||||
"FULL_PREVIEW": "Full preview",
|
||||
"SAVED": "Template '{{ templateName }}' was saved"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "Register",
|
||||
|
||||
@@ -5297,7 +5297,10 @@
|
||||
"payment-receipt": "Recibo de pago",
|
||||
"reject-candidate": "Rechazar Candidato"
|
||||
},
|
||||
"HTML_EDITOR": "Editor HTML"
|
||||
"HTML_EDITOR": "Editor HTML",
|
||||
"FULL_PREVIEW": "Vista previa completa",
|
||||
"VIEW_GRID": "Cuadrícula",
|
||||
"VIEW_EDITOR": "Editor"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Tuberías.",
|
||||
@@ -5348,53 +5351,191 @@
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "Proveedor de archivos",
|
||||
"SHOW_SECRET": "Mostrar secreto",
|
||||
"HIDE_SECRET": "Ocultar secreto",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Configuración actual",
|
||||
"DESCRIPTION": "Últimos ajustes guardados: los que usan las nuevas cargas.",
|
||||
"PROVIDER": "Proveedor",
|
||||
"BUCKET": "Bucket / nombre de la nube",
|
||||
"REGION": "Región",
|
||||
"ENDPOINT": "Endpoint",
|
||||
"CREDENTIALS": "Credenciales",
|
||||
"CREDENTIALS_SAVED": "Guardadas",
|
||||
"CREDENTIALS_INCOMPLETE": "Incompletas",
|
||||
"CREDENTIALS_NONE": "Sin definir",
|
||||
"CREDENTIALS_NOT_NEEDED": "No necesarias",
|
||||
"SERVER_DEFAULT": "Predeterminado del servidor"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Dónde obtener las claves",
|
||||
"LOCAL": {
|
||||
"NAME": "Disco local",
|
||||
"DESCRIPTION": "los archivos se guardan en el sistema de archivos del servidor de la API."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "los archivos se guardan en un bucket de AWS S3. Use una clave de acceso de IAM que pueda leer y escribir en el bucket."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "almacenamiento en la nube compatible con S3. Las credenciales se verifican con Wasabi al guardar."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "alojamiento de imágenes y vídeos. El nombre de la nube y las claves de API están en la consola de Cloudinary, en API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "almacenamiento de objetos compatible con S3 con CDN opcional. Cree una clave de acceso de Spaces en el panel de control de DigitalOcean."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Almacenamiento local",
|
||||
"WHERE": "Los archivos subidos se escriben en el sistema de archivos del servidor de la API.",
|
||||
"NO_CONFIG": "Nada que configurar: el almacenamiento local funciona de inmediato.",
|
||||
"WARNING": "Los archivos se pierden si el servidor se vuelve a desplegar sin un volumen persistente y no se comparten entre varias instancias de la API. Use un proveedor en la nube en producción."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Comprobar configuración",
|
||||
"OK": "Todos los campos obligatorios están completos y las URL son válidas. Las claves solo las verifica el proveedor.",
|
||||
"OK_LOCAL": "El almacenamiento local no necesita configuración.",
|
||||
"OK_SERVER_DEFAULTS": "No falta nada. Estos campos vacíos usan la configuración del servidor:",
|
||||
"MISSING": "Falta:",
|
||||
"INVALID_URL": "URL http(s) no válida:",
|
||||
"HTTPS_REQUIRED": "Debe usar https://: las claves se envían a esta dirección:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "Configuración de S3",
|
||||
"SUBTITLE": "Guarde los archivos subidos en un bucket de Amazon S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credenciales",
|
||||
"CREDENTIALS_HINT": "Una clave de acceso de IAM que pueda leer y escribir en el bucket.",
|
||||
"BUCKET": "Bucket",
|
||||
"BUCKET_HINT": "Dónde se escriben los archivos subidos."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "La parte pública del par de claves.",
|
||||
"SECRET_ACCESS_KEY": "Se muestra una sola vez, al crear la clave. Los valores guardados vuelven enmascarados.",
|
||||
"REGION": "La región de AWS del bucket, p. ej. us-east-1 o eu-west-1.",
|
||||
"BUCKET": "El nombre del bucket tal como aparece en la consola de S3."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID de clave de acceso",
|
||||
"SECRET_ACCESS_KEY": "Clave de acceso secreta.",
|
||||
"REGION": "Región",
|
||||
"BUCKET": "Cubo"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Access key id: Identificador de clave de acceso",
|
||||
"SECRET_ACCESS_KEY": "Clave de acceso secreta",
|
||||
"REGION": "Región",
|
||||
"BUCKET": "Cubo"
|
||||
"BUCKET": "Bucket"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID de clave de acceso",
|
||||
"SECRET_ACCESS_KEY": "Clave de acceso secreta",
|
||||
"REGION": "Región",
|
||||
"BUCKET": "Bucket"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Configuración de Wasabi",
|
||||
"SUBTITLE": "Guarde los archivos subidos en un bucket de Wasabi mediante su API compatible con S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credenciales",
|
||||
"CREDENTIALS_HINT": "Un par de claves de acceso de la consola de Wasabi.",
|
||||
"BUCKET": "Bucket y endpoint",
|
||||
"BUCKET_HINT": "Dónde se escriben los archivos subidos.",
|
||||
"OPTIONS": "Opciones"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "La parte pública del par de claves.",
|
||||
"SECRET_ACCESS_KEY": "Se muestra una sola vez, al crear la clave. Los valores guardados vuelven enmascarados.",
|
||||
"REGION": "La región del bucket, p. ej. us-east-1 o eu-central-1.",
|
||||
"SERVICE_URL": "El endpoint S3 de esa región.",
|
||||
"BUCKET": "Minúsculas, números, puntos y guiones. Los nombres son únicos en todo Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Direcciona los archivos como endpoint/bucket/archivo en lugar de bucket.endpoint/archivo. Déjelo activado salvo que necesite URL de tipo virtual-hosted."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Endpoint para esta región:",
|
||||
"USE_SUGGESTION": "Usarlo",
|
||||
"SAVE_NOTE": "Al guardar, Gauzy usa estas credenciales para crear el bucket en Wasabi. Si Wasabi rechaza la solicitud, la configuración no se guarda.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Identificador de clave de acceso.",
|
||||
"SECRET_ACCESS_KEY": "Clave de acceso secreta.",
|
||||
"ACCESS_KEY_ID": "ID de clave de acceso",
|
||||
"SECRET_ACCESS_KEY": "Clave de acceso secreta",
|
||||
"REGION": "Región",
|
||||
"BUCKET": "Cubo",
|
||||
"SERVICE_URL": "URL de servicio"
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL del servicio",
|
||||
"FORCE_PATH_STYLE": "Forzar URL de estilo ruta"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID de clave de acceso",
|
||||
"SECRET_ACCESS_KEY": "Clave de acceso secreta",
|
||||
"REGION": "Región",
|
||||
"BUCKET": "Cubo",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL del servicio"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Configuración de Cloudinary",
|
||||
"SUBTITLE": "Guarde los archivos subidos en Cloudinary y sírvalos desde su red de distribución.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Cuenta",
|
||||
"ACCOUNT_HINT": "Los tres valores están en la consola de Cloudinary, en API Keys.",
|
||||
"DELIVERY": "Entrega",
|
||||
"DELIVERY_HINT": "Cómo se construyen los enlaces a los archivos subidos."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "El nombre que aparece en la parte superior de su consola de Cloudinary.",
|
||||
"ACCESS_API_KEY": "Identifica la cuenta; se usa junto con el secreto de API.",
|
||||
"ACCESS_API_SECRET": "Los valores guardados vuelven enmascarados.",
|
||||
"DELIVERY_URL": "Dirección base de los enlaces. Mantenga https://res.cloudinary.com salvo que use un dominio propio.",
|
||||
"SECURE": "Genera los enlaces con https://. Déjelo en True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "nombre de Cloudinary",
|
||||
"ACCESS_API_KEY": "ID de clave de acceso",
|
||||
"ACCESS_API_SECRET": "Clave de acceso secreta",
|
||||
"CLOUD_NAME": "Nombre de la nube",
|
||||
"ACCESS_API_KEY": "Clave de API",
|
||||
"ACCESS_API_SECRET": "Secreto de API",
|
||||
"DELIVERY_URL": "URL de entrega",
|
||||
"SECURE": "Seguro"
|
||||
"SECURE": "Seguro (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Nombre de Cloudinary",
|
||||
"ACCESS_API_KEY": "Clave de acceso ID",
|
||||
"ACCESS_API_SECRET": "Clave de acceso secreta",
|
||||
"CLOUD_NAME": "Nombre de la nube",
|
||||
"ACCESS_API_KEY": "Clave de API",
|
||||
"ACCESS_API_SECRET": "Secreto de API",
|
||||
"DELIVERY_URL": "URL de entrega",
|
||||
"SECURE": "Seguro"
|
||||
"SECURE": "Seguro (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "Configuración de DigitalOcean",
|
||||
"SUBTITLE": "Guarde los archivos subidos en un Space de DigitalOcean mediante su API compatible con S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credenciales",
|
||||
"CREDENTIALS_HINT": "Una clave de acceso de Spaces del panel de control de DigitalOcean.",
|
||||
"BUCKET": "Space y endpoint",
|
||||
"BUCKET_HINT": "Dónde se escriben los archivos subidos.",
|
||||
"OPTIONS": "Opciones"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "La parte pública del par de claves.",
|
||||
"SECRET_ACCESS_KEY": "Se muestra una sola vez, al crear la clave. Los valores guardados vuelven enmascarados.",
|
||||
"SERVICE_URL": "El endpoint del centro de datos de su Space, p. ej. https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Fijado en us-east-1, como espera Spaces; el centro de datos lo determina el endpoint.",
|
||||
"BUCKET": "El nombre del Space.",
|
||||
"CDN_URL": "Opcional. El endpoint CDN del Space, si el CDN está activado.",
|
||||
"FORCE_PATH_STYLE": "Direcciona los archivos como endpoint/space/archivo en lugar de space.endpoint/archivo."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID de clave de acceso",
|
||||
"SECRET_ACCESS_KEY": "Clave de acceso secreta",
|
||||
"REGION": "Región",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL del servicio",
|
||||
"CDN_URL": "URL del CDN",
|
||||
"FORCE_PATH_STYLE": "Forzar URL de estilo ruta"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID de clave de acceso",
|
||||
"SECRET_ACCESS_KEY": "Clave de acceso secreta",
|
||||
"REGION": "Región",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL del servicio",
|
||||
"CDN_URL": "URL del CDN"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5493,7 +5634,11 @@
|
||||
"invoice": "Factura",
|
||||
"estimate": "Estimado.",
|
||||
"receipt": "Recibo"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Cuadrícula",
|
||||
"VIEW_EDITOR": "Editor",
|
||||
"FULL_PREVIEW": "Vista previa completa",
|
||||
"SAVED": "La plantilla '{{ templateName }}' se ha guardado"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "Registrarse",
|
||||
|
||||
@@ -5286,7 +5286,10 @@
|
||||
"payment-receipt": "Reçu de paiement",
|
||||
"reject-candidate": "Rejeter le Candidat"
|
||||
},
|
||||
"HTML_EDITOR": "Éditeur HTML"
|
||||
"HTML_EDITOR": "Éditeur HTML",
|
||||
"FULL_PREVIEW": "Aperçu complet",
|
||||
"VIEW_GRID": "Grille",
|
||||
"VIEW_EDITOR": "Éditeur"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Les pipelines.",
|
||||
@@ -5337,53 +5340,191 @@
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "Fournisseur de fichiers",
|
||||
"SHOW_SECRET": "Afficher le secret",
|
||||
"HIDE_SECRET": "Masquer le secret",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Configuration actuelle",
|
||||
"DESCRIPTION": "Derniers paramètres enregistrés — utilisés par les nouveaux envois.",
|
||||
"PROVIDER": "Fournisseur",
|
||||
"BUCKET": "Bucket / nom du cloud",
|
||||
"REGION": "Région",
|
||||
"ENDPOINT": "Point de terminaison",
|
||||
"CREDENTIALS": "Identifiants",
|
||||
"CREDENTIALS_SAVED": "Enregistrés",
|
||||
"CREDENTIALS_INCOMPLETE": "Incomplets",
|
||||
"CREDENTIALS_NONE": "Non définis",
|
||||
"CREDENTIALS_NOT_NEEDED": "Non requis",
|
||||
"SERVER_DEFAULT": "Valeur par défaut du serveur"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Où obtenir les clés",
|
||||
"LOCAL": {
|
||||
"NAME": "Disque local",
|
||||
"DESCRIPTION": "les fichiers sont enregistrés sur le système de fichiers du serveur API."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "les fichiers sont enregistrés dans un bucket AWS S3. Utilisez une clé d'accès IAM pouvant lire et écrire dans le bucket."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "stockage cloud compatible S3. Les identifiants sont vérifiés auprès de Wasabi lors de l'enregistrement."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "hébergement d'images et de vidéos. Le nom du cloud et les clés API se trouvent dans la console Cloudinary, sous API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "stockage d'objets compatible S3 avec CDN optionnel. Créez une clé d'accès Spaces dans le panneau de contrôle DigitalOcean."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Stockage local",
|
||||
"WHERE": "Les fichiers envoyés sont écrits sur le système de fichiers du serveur API.",
|
||||
"NO_CONFIG": "Rien à configurer — le stockage local fonctionne immédiatement.",
|
||||
"WARNING": "Les fichiers sont perdus si le serveur est redéployé sans volume persistant, et ils ne sont pas partagés entre plusieurs instances de l'API. Utilisez un fournisseur cloud en production."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Vérifier la configuration",
|
||||
"OK": "Tous les champs obligatoires sont remplis et les URL sont valides. Les clés elles-mêmes ne sont vérifiées que par le fournisseur.",
|
||||
"OK_LOCAL": "Le stockage local ne nécessite aucune configuration.",
|
||||
"OK_SERVER_DEFAULTS": "Rien ne manque. Laissés vides, ces champs utilisent la configuration du serveur :",
|
||||
"MISSING": "Manquant :",
|
||||
"INVALID_URL": "URL http(s) non valide :",
|
||||
"HTTPS_REQUIRED": "Doit utiliser https:// — les clés sont envoyées à cette adresse :"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "Configuration S3",
|
||||
"SUBTITLE": "Stockez les fichiers envoyés dans un bucket Amazon S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Identifiants",
|
||||
"CREDENTIALS_HINT": "Une clé d'accès IAM pouvant lire et écrire dans le bucket.",
|
||||
"BUCKET": "Bucket",
|
||||
"BUCKET_HINT": "Où les fichiers envoyés sont écrits."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "La partie publique de la paire de clés.",
|
||||
"SECRET_ACCESS_KEY": "Affichée une seule fois, à la création de la clé. Les valeurs enregistrées sont renvoyées masquées.",
|
||||
"REGION": "La région AWS du bucket, par ex. us-east-1 ou eu-west-1.",
|
||||
"BUCKET": "Le nom du bucket tel qu'il apparaît dans la console S3."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID de clé d'accès",
|
||||
"SECRET_ACCESS_KEY": "Clé d'accès secrète",
|
||||
"REGION": "Région",
|
||||
"BUCKET": "Seau"
|
||||
"BUCKET": "Bucket"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Identifiant d'accès clé",
|
||||
"ACCESS_KEY_ID": "ID de clé d'accès",
|
||||
"SECRET_ACCESS_KEY": "Clé d'accès secrète",
|
||||
"REGION": "Région",
|
||||
"BUCKET": "Seau"
|
||||
"BUCKET": "Bucket"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Configuration Wasabi",
|
||||
"SUBTITLE": "Stockez les fichiers envoyés dans un bucket Wasabi via son API compatible S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Identifiants",
|
||||
"CREDENTIALS_HINT": "Une paire de clés d'accès issue de la console Wasabi.",
|
||||
"BUCKET": "Bucket et point de terminaison",
|
||||
"BUCKET_HINT": "Où les fichiers envoyés sont écrits.",
|
||||
"OPTIONS": "Options"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "La partie publique de la paire de clés.",
|
||||
"SECRET_ACCESS_KEY": "Affichée une seule fois, à la création de la clé. Les valeurs enregistrées sont renvoyées masquées.",
|
||||
"REGION": "La région du bucket, par ex. us-east-1 ou eu-central-1.",
|
||||
"SERVICE_URL": "Le point de terminaison S3 de cette région.",
|
||||
"BUCKET": "Lettres minuscules, chiffres, points et tirets. Les noms sont uniques sur tout Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Adresse les fichiers sous la forme endpoint/bucket/fichier au lieu de bucket.endpoint/fichier. Laissez activé sauf besoin d'URL de type virtual-hosted."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Point de terminaison pour cette région :",
|
||||
"USE_SUGGESTION": "L'utiliser",
|
||||
"SAVE_NOTE": "À l'enregistrement, Gauzy utilise ces identifiants pour créer le bucket dans Wasabi. Si Wasabi refuse la requête, les paramètres ne sont pas enregistrés.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Identifiant de clé d'accès",
|
||||
"ACCESS_KEY_ID": "ID de clé d'accès",
|
||||
"SECRET_ACCESS_KEY": "Clé d'accès secrète",
|
||||
"REGION": "Région",
|
||||
"BUCKET": "Seau",
|
||||
"SERVICE_URL": "URL de service"
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL du service",
|
||||
"FORCE_PATH_STYLE": "Forcer les URL de type chemin"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID de la clé d'accès",
|
||||
"ACCESS_KEY_ID": "ID de clé d'accès",
|
||||
"SECRET_ACCESS_KEY": "Clé d'accès secrète",
|
||||
"REGION": "Région",
|
||||
"BUCKET": "Seau",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL du service"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Configuration Cloudinary",
|
||||
"SUBTITLE": "Stockez les fichiers envoyés dans Cloudinary et diffusez-les via son réseau de distribution.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Compte",
|
||||
"ACCOUNT_HINT": "Ces trois valeurs se trouvent dans la console Cloudinary, sous API Keys.",
|
||||
"DELIVERY": "Diffusion",
|
||||
"DELIVERY_HINT": "Comment les liens vers les fichiers sont construits."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "Le nom affiché en haut de votre console Cloudinary.",
|
||||
"ACCESS_API_KEY": "Identifie le compte ; utilisée avec le secret API.",
|
||||
"ACCESS_API_SECRET": "Les valeurs enregistrées sont renvoyées masquées.",
|
||||
"DELIVERY_URL": "Adresse de base des liens. Gardez https://res.cloudinary.com sauf si vous utilisez un domaine personnalisé.",
|
||||
"SECURE": "Construit les liens en https://. Laissez sur True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Nom de Cloudinary",
|
||||
"ACCESS_API_KEY": "Identifiant de clé d'accès",
|
||||
"ACCESS_API_SECRET": "Clé d'accès secrète",
|
||||
"DELIVERY_URL": "URL de livraison",
|
||||
"SECURE": "Sécurisé"
|
||||
"CLOUD_NAME": "Nom du cloud",
|
||||
"ACCESS_API_KEY": "Clé API",
|
||||
"ACCESS_API_SECRET": "Secret API",
|
||||
"DELIVERY_URL": "URL de diffusion",
|
||||
"SECURE": "Sécurisé (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Le nom de Cloudinary",
|
||||
"ACCESS_API_KEY": "Clé d'accès identifiant",
|
||||
"ACCESS_API_SECRET": "Clé d'accès secrète",
|
||||
"DELIVERY_URL": "URL de livraison",
|
||||
"SECURE": "Sécurisé"
|
||||
"CLOUD_NAME": "Nom du cloud",
|
||||
"ACCESS_API_KEY": "Clé API",
|
||||
"ACCESS_API_SECRET": "Secret API",
|
||||
"DELIVERY_URL": "URL de diffusion",
|
||||
"SECURE": "Sécurisé (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "Configuration DigitalOcean",
|
||||
"SUBTITLE": "Stockez les fichiers envoyés dans un Space DigitalOcean via son API compatible S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Identifiants",
|
||||
"CREDENTIALS_HINT": "Une clé d'accès Spaces issue du panneau de contrôle DigitalOcean.",
|
||||
"BUCKET": "Space et point de terminaison",
|
||||
"BUCKET_HINT": "Où les fichiers envoyés sont écrits.",
|
||||
"OPTIONS": "Options"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "La partie publique de la paire de clés.",
|
||||
"SECRET_ACCESS_KEY": "Affichée une seule fois, à la création de la clé. Les valeurs enregistrées sont renvoyées masquées.",
|
||||
"SERVICE_URL": "Le point de terminaison du datacenter de votre Space, par ex. https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Fixé à us-east-1, comme l'attend Spaces ; le datacenter est déterminé par le point de terminaison.",
|
||||
"BUCKET": "Le nom du Space.",
|
||||
"CDN_URL": "Facultatif. Le point de terminaison CDN du Space, si le CDN est activé.",
|
||||
"FORCE_PATH_STYLE": "Adresse les fichiers sous la forme endpoint/space/fichier au lieu de space.endpoint/fichier."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID de clé d'accès",
|
||||
"SECRET_ACCESS_KEY": "Clé d'accès secrète",
|
||||
"REGION": "Région",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL du service",
|
||||
"CDN_URL": "URL du CDN",
|
||||
"FORCE_PATH_STYLE": "Forcer les URL de type chemin"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID de clé d'accès",
|
||||
"SECRET_ACCESS_KEY": "Clé d'accès secrète",
|
||||
"REGION": "Région",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL du service",
|
||||
"CDN_URL": "URL du CDN"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5482,7 +5623,11 @@
|
||||
"invoice": "Facture",
|
||||
"estimate": "Estimation",
|
||||
"receipt": "Reçu"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Grille",
|
||||
"VIEW_EDITOR": "Éditeur",
|
||||
"FULL_PREVIEW": "Aperçu complet",
|
||||
"SAVED": "Le modèle '{{ templateName }}' a été enregistré"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "S'inscrire",
|
||||
|
||||
@@ -5304,7 +5304,10 @@
|
||||
"payment-receipt": "קבלת תשלום",
|
||||
"reject-candidate": "לדחות את המועמד"
|
||||
},
|
||||
"HTML_EDITOR": "HTML Editor"
|
||||
"HTML_EDITOR": "HTML Editor",
|
||||
"FULL_PREVIEW": "תצוגה מלאה",
|
||||
"VIEW_GRID": "רשת",
|
||||
"VIEW_EDITOR": "עורך"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Pipelines",
|
||||
@@ -5355,36 +5358,191 @@
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "ספק קבצים",
|
||||
"SHOW_SECRET": "הצג סוד",
|
||||
"HIDE_SECRET": "הסתר סוד",
|
||||
"SUMMARY": {
|
||||
"TITLE": "תצורה נוכחית",
|
||||
"DESCRIPTION": "ההגדרות האחרונות שנשמרו — בשימוש בהעלאות חדשות.",
|
||||
"PROVIDER": "ספק",
|
||||
"BUCKET": "באקט / שם ענן",
|
||||
"REGION": "אזור",
|
||||
"ENDPOINT": "נקודת קצה",
|
||||
"CREDENTIALS": "פרטי גישה",
|
||||
"CREDENTIALS_SAVED": "נשמרו",
|
||||
"CREDENTIALS_INCOMPLETE": "חלקיים",
|
||||
"CREDENTIALS_NONE": "לא הוגדרו",
|
||||
"CREDENTIALS_NOT_NEEDED": "לא נדרשים",
|
||||
"SERVER_DEFAULT": "ברירת המחדל של השרת"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "היכן להשיג את המפתחות",
|
||||
"LOCAL": {
|
||||
"NAME": "דיסק מקומי",
|
||||
"DESCRIPTION": "הקבצים נשמרים במערכת הקבצים של שרת ה-API."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "הקבצים נשמרים בבאקט AWS S3. השתמשו במפתח גישה של IAM עם הרשאות קריאה וכתיבה לבאקט."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "אחסון ענן תואם S3. פרטי הגישה נבדקים מול Wasabi בעת השמירה."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "אירוח תמונות ווידאו. שם הענן ומפתחות ה-API נמצאים בקונסולת Cloudinary תחת API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "אחסון אובייקטים תואם S3 עם CDN אופציונלי. צרו מפתח גישה ל-Spaces בלוח הבקרה של DigitalOcean."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "אחסון מקומי",
|
||||
"WHERE": "קבצים שהועלו נכתבים למערכת הקבצים של שרת ה-API.",
|
||||
"NO_CONFIG": "אין מה להגדיר — האחסון המקומי עובד מיד.",
|
||||
"WARNING": "הקבצים יאבדו אם השרת ייפרס מחדש ללא נפח קבוע, והם אינם משותפים בין כמה מופעי API. בסביבת ייצור השתמשו בספק ענן."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "בדוק תצורה",
|
||||
"OK": "כל השדות הנדרשים מולאו וכתובות ה-URL תקינות. המפתחות עצמם נבדקים רק על ידי הספק.",
|
||||
"OK_LOCAL": "אחסון מקומי אינו דורש הגדרה.",
|
||||
"OK_SERVER_DEFAULTS": "לא חסר דבר. השדות הריקים האלה משתמשים בתצורת השרת:",
|
||||
"MISSING": "חסר:",
|
||||
"INVALID_URL": "כתובת http(s) לא תקינה:",
|
||||
"HTTPS_REQUIRED": "חובה להשתמש ב-https:// — המפתחות נשלחים לכתובת זו:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "S3 Configuration",
|
||||
"HEADER": "תצורת S3",
|
||||
"SUBTITLE": "אחסנו קבצים שהועלו בבאקט של Amazon S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "פרטי גישה",
|
||||
"CREDENTIALS_HINT": "מפתח גישה של IAM עם הרשאות קריאה וכתיבה לבאקט.",
|
||||
"BUCKET": "באקט",
|
||||
"BUCKET_HINT": "לאן נכתבים הקבצים שהועלו."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "החלק הציבורי של זוג המפתחות.",
|
||||
"SECRET_ACCESS_KEY": "מוצג פעם אחת בלבד, בעת יצירת המפתח. ערכים שמורים חוזרים מוסתרים.",
|
||||
"REGION": "אזור ה-AWS של הבאקט, למשל us-east-1 או eu-west-1.",
|
||||
"BUCKET": "שם הבאקט בדיוק כפי שהוא מופיע בקונסולת S3."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket"
|
||||
"ACCESS_KEY_ID": "מזהה מפתח גישה",
|
||||
"SECRET_ACCESS_KEY": "מפתח גישה סודי",
|
||||
"REGION": "אזור",
|
||||
"BUCKET": "באקט"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket"
|
||||
"ACCESS_KEY_ID": "מזהה מפתח גישה",
|
||||
"SECRET_ACCESS_KEY": "מפתח גישה סודי",
|
||||
"REGION": "אזור",
|
||||
"BUCKET": "באקט"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Wasabi Configuration",
|
||||
"HEADER": "תצורת Wasabi",
|
||||
"SUBTITLE": "אחסנו קבצים שהועלו בבאקט של Wasabi דרך ה-API התואם S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "פרטי גישה",
|
||||
"CREDENTIALS_HINT": "זוג מפתחות גישה מקונסולת Wasabi.",
|
||||
"BUCKET": "באקט ונקודת קצה",
|
||||
"BUCKET_HINT": "לאן נכתבים הקבצים שהועלו.",
|
||||
"OPTIONS": "אפשרויות"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "החלק הציבורי של זוג המפתחות.",
|
||||
"SECRET_ACCESS_KEY": "מוצג פעם אחת בלבד, בעת יצירת המפתח. ערכים שמורים חוזרים מוסתרים.",
|
||||
"REGION": "האזור של הבאקט, למשל us-east-1 או eu-central-1.",
|
||||
"SERVICE_URL": "נקודת הקצה של S3 עבור אותו אזור.",
|
||||
"BUCKET": "אותיות קטנות, ספרות, נקודות ומקפים. השמות ייחודיים בכל Wasabi.",
|
||||
"FORCE_PATH_STYLE": "פנייה לקבצים בתבנית endpoint/bucket/file במקום bucket.endpoint/file. השאירו מופעל אלא אם אתם צריכים כתובות virtual-hosted."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "נקודת קצה לאזור זה:",
|
||||
"USE_SUGGESTION": "השתמש",
|
||||
"SAVE_NOTE": "בעת השמירה, Gauzy משתמש בפרטי הגישה האלה כדי ליצור את הבאקט ב-Wasabi. אם Wasabi דוחה את הבקשה, ההגדרות אינן נשמרות.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service URL"
|
||||
"ACCESS_KEY_ID": "מזהה מפתח גישה",
|
||||
"SECRET_ACCESS_KEY": "מפתח גישה סודי",
|
||||
"REGION": "אזור",
|
||||
"BUCKET": "באקט",
|
||||
"SERVICE_URL": "כתובת URL של השירות",
|
||||
"FORCE_PATH_STYLE": "אילוץ כתובות URL בסגנון נתיב"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Access key id",
|
||||
"SECRET_ACCESS_KEY": "Secret access key",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service URL"
|
||||
"ACCESS_KEY_ID": "מזהה מפתח גישה",
|
||||
"SECRET_ACCESS_KEY": "מפתח גישה סודי",
|
||||
"REGION": "אזור",
|
||||
"BUCKET": "באקט",
|
||||
"SERVICE_URL": "כתובת URL של השירות"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "תצורת Cloudinary",
|
||||
"SUBTITLE": "אחסנו קבצים שהועלו ב-Cloudinary והגישו אותם דרך רשת ההפצה שלו.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "חשבון",
|
||||
"ACCOUNT_HINT": "שלושת הערכים נמצאים בקונסולת Cloudinary תחת API Keys.",
|
||||
"DELIVERY": "הפצה",
|
||||
"DELIVERY_HINT": "כיצד נבנים הקישורים לקבצים שהועלו."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "השם המוצג בראש קונסולת Cloudinary.",
|
||||
"ACCESS_API_KEY": "מזהה את החשבון; משמש יחד עם סוד ה-API.",
|
||||
"ACCESS_API_SECRET": "ערכים שמורים חוזרים מוסתרים.",
|
||||
"DELIVERY_URL": "כתובת הבסיס של הקישורים. השאירו https://res.cloudinary.com אלא אם אתם משתמשים בדומיין מותאם.",
|
||||
"SECURE": "יצירת קישורים עם https://. השאירו על True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "שם הענן",
|
||||
"ACCESS_API_KEY": "מפתח API",
|
||||
"ACCESS_API_SECRET": "סוד API",
|
||||
"DELIVERY_URL": "כתובת URL למסירה",
|
||||
"SECURE": "מאובטח (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "שם הענן",
|
||||
"ACCESS_API_KEY": "מפתח API",
|
||||
"ACCESS_API_SECRET": "סוד API",
|
||||
"DELIVERY_URL": "כתובת URL למסירה",
|
||||
"SECURE": "מאובטח (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "תצורת DigitalOcean",
|
||||
"SUBTITLE": "אחסנו קבצים שהועלו ב-DigitalOcean Space דרך ה-API התואם S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "פרטי גישה",
|
||||
"CREDENTIALS_HINT": "מפתח גישה ל-Spaces מלוח הבקרה של DigitalOcean.",
|
||||
"BUCKET": "Space ונקודת קצה",
|
||||
"BUCKET_HINT": "לאן נכתבים הקבצים שהועלו.",
|
||||
"OPTIONS": "אפשרויות"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "החלק הציבורי של זוג המפתחות.",
|
||||
"SECRET_ACCESS_KEY": "מוצג פעם אחת בלבד, בעת יצירת המפתח. ערכים שמורים חוזרים מוסתרים.",
|
||||
"SERVICE_URL": "נקודת הקצה של מרכז הנתונים של ה-Space, למשל https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "קבוע על us-east-1, כפי ש-Spaces מצפה; מרכז הנתונים נקבע לפי נקודת הקצה.",
|
||||
"BUCKET": "שם ה-Space.",
|
||||
"CDN_URL": "אופציונלי. נקודת הקצה של ה-CDN של ה-Space, אם ה-CDN מופעל.",
|
||||
"FORCE_PATH_STYLE": "פנייה לקבצים בתבנית endpoint/space/file במקום space.endpoint/file."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "מזהה מפתח גישה",
|
||||
"SECRET_ACCESS_KEY": "מפתח גישה סודי",
|
||||
"REGION": "אזור",
|
||||
"BUCKET": "באקט",
|
||||
"SERVICE_URL": "כתובת URL של השירות",
|
||||
"CDN_URL": "כתובת URL של ה-CDN",
|
||||
"FORCE_PATH_STYLE": "אילוץ כתובות URL בסגנון נתיב"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "מזהה מפתח גישה",
|
||||
"SECRET_ACCESS_KEY": "מפתח גישה סודי",
|
||||
"REGION": "אזור",
|
||||
"BUCKET": "באקט",
|
||||
"SERVICE_URL": "כתובת URL של השירות",
|
||||
"CDN_URL": "כתובת URL של ה-CDN"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5482,7 +5640,11 @@
|
||||
"invoice": "Invoice",
|
||||
"estimate": "Estimate",
|
||||
"receipt": "Receipt"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "רשת",
|
||||
"VIEW_EDITOR": "עורך",
|
||||
"FULL_PREVIEW": "תצוגה מלאה",
|
||||
"SAVED": "התבנית '{{ templateName }}' נשמרה"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "הרשמה",
|
||||
|
||||
@@ -5295,7 +5295,10 @@
|
||||
"payment-receipt": "Ricevuta di pagamento",
|
||||
"reject-candidate": "Rifiuta Candidato"
|
||||
},
|
||||
"HTML_EDITOR": "Editor di HTML"
|
||||
"HTML_EDITOR": "Editor di HTML",
|
||||
"FULL_PREVIEW": "Anteprima completa",
|
||||
"VIEW_GRID": "Griglia",
|
||||
"VIEW_EDITOR": "Editor"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Pipeline",
|
||||
@@ -5345,54 +5348,192 @@
|
||||
"QUICK_ACTIONS": "Azioni rapide"
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "Fornitore di file",
|
||||
"FILE_PROVIDER": "Provider di file",
|
||||
"SHOW_SECRET": "Mostra segreto",
|
||||
"HIDE_SECRET": "Nascondi segreto",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Configurazione attuale",
|
||||
"DESCRIPTION": "Ultime impostazioni salvate: quelle usate dai nuovi caricamenti.",
|
||||
"PROVIDER": "Provider",
|
||||
"BUCKET": "Bucket / nome cloud",
|
||||
"REGION": "Regione",
|
||||
"ENDPOINT": "Endpoint",
|
||||
"CREDENTIALS": "Credenziali",
|
||||
"CREDENTIALS_SAVED": "Salvate",
|
||||
"CREDENTIALS_INCOMPLETE": "Incomplete",
|
||||
"CREDENTIALS_NONE": "Non impostate",
|
||||
"CREDENTIALS_NOT_NEEDED": "Non necessarie",
|
||||
"SERVER_DEFAULT": "Predefinito del server"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Dove ottenere le chiavi",
|
||||
"LOCAL": {
|
||||
"NAME": "Disco locale",
|
||||
"DESCRIPTION": "i file vengono salvati nel file system del server API."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "i file vengono salvati in un bucket AWS S3. Usa una chiave di accesso IAM con permessi di lettura e scrittura sul bucket."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "storage cloud compatibile con S3. Le credenziali vengono verificate con Wasabi al salvataggio."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "hosting di immagini e video. Il nome cloud e le chiavi API si trovano nella console Cloudinary, in API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "object storage compatibile con S3 con CDN opzionale. Crea una chiave di accesso Spaces nel pannello di controllo DigitalOcean."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Archiviazione locale",
|
||||
"WHERE": "I file caricati vengono scritti nel file system del server API.",
|
||||
"NO_CONFIG": "Niente da configurare: l'archiviazione locale funziona subito.",
|
||||
"WARNING": "I file vanno persi se il server viene ridistribuito senza un volume persistente e non sono condivisi tra più istanze dell'API. In produzione usa un provider cloud."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Verifica configurazione",
|
||||
"OK": "Tutti i campi obbligatori sono compilati e gli URL sono validi. Le chiavi vengono verificate solo dal provider.",
|
||||
"OK_LOCAL": "L'archiviazione locale non richiede configurazione.",
|
||||
"OK_SERVER_DEFAULTS": "Non manca nulla. Questi campi vuoti usano la configurazione del server:",
|
||||
"MISSING": "Mancante:",
|
||||
"INVALID_URL": "URL http(s) non valido:",
|
||||
"HTTPS_REQUIRED": "Deve usare https://: le chiavi vengono inviate a questo indirizzo:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "Configurazione S3",
|
||||
"SUBTITLE": "Archivia i file caricati in un bucket Amazon S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credenziali",
|
||||
"CREDENTIALS_HINT": "Una chiave di accesso IAM con permessi di lettura e scrittura sul bucket.",
|
||||
"BUCKET": "Bucket",
|
||||
"BUCKET_HINT": "Dove vengono scritti i file caricati."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "La parte pubblica della coppia di chiavi.",
|
||||
"SECRET_ACCESS_KEY": "Mostrata una sola volta, alla creazione della chiave. I valori salvati tornano mascherati.",
|
||||
"REGION": "La regione AWS del bucket, ad es. us-east-1 o eu-west-1.",
|
||||
"BUCKET": "Il nome del bucket esattamente come appare nella console S3."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID chiave di accesso",
|
||||
"SECRET_ACCESS_KEY": "Chiave di accesso segreta",
|
||||
"REGION": "Regione",
|
||||
"BUCKET": "Secchio"
|
||||
"BUCKET": "Bucket"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID chiave di accesso",
|
||||
"SECRET_ACCESS_KEY": "Chiave di accesso segreta",
|
||||
"REGION": "Regione",
|
||||
"BUCKET": "Secchio"
|
||||
"BUCKET": "Bucket"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Configurazione di Wasabi",
|
||||
"HEADER": "Configurazione Wasabi",
|
||||
"SUBTITLE": "Archivia i file caricati in un bucket Wasabi tramite la sua API compatibile con S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credenziali",
|
||||
"CREDENTIALS_HINT": "Una coppia di chiavi di accesso dalla console Wasabi.",
|
||||
"BUCKET": "Bucket ed endpoint",
|
||||
"BUCKET_HINT": "Dove vengono scritti i file caricati.",
|
||||
"OPTIONS": "Opzioni"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "La parte pubblica della coppia di chiavi.",
|
||||
"SECRET_ACCESS_KEY": "Mostrata una sola volta, alla creazione della chiave. I valori salvati tornano mascherati.",
|
||||
"REGION": "La regione del bucket, ad es. us-east-1 o eu-central-1.",
|
||||
"SERVICE_URL": "L'endpoint S3 di quella regione.",
|
||||
"BUCKET": "Lettere minuscole, numeri, punti e trattini. I nomi sono unici in tutto Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Indirizza i file come endpoint/bucket/file invece di bucket.endpoint/file. Lascia attivo a meno che non servano URL virtual-hosted."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Endpoint per questa regione:",
|
||||
"USE_SUGGESTION": "Usalo",
|
||||
"SAVE_NOTE": "Al salvataggio, Gauzy usa queste credenziali per creare il bucket in Wasabi. Se Wasabi rifiuta la richiesta, le impostazioni non vengono salvate.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Identificazione chiave di accesso",
|
||||
"ACCESS_KEY_ID": "ID chiave di accesso",
|
||||
"SECRET_ACCESS_KEY": "Chiave di accesso segreta",
|
||||
"REGION": "Regione",
|
||||
"BUCKET": "Secchio",
|
||||
"SERVICE_URL": "URL del servizio"
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL del servizio",
|
||||
"FORCE_PATH_STYLE": "Forza URL in stile percorso"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID chiave di accesso",
|
||||
"SECRET_ACCESS_KEY": "Chiave di accesso segreta",
|
||||
"REGION": "Regione",
|
||||
"BUCKET": "Secchio",
|
||||
"SERVICE_URL": "URL del Servizio"
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL del servizio"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Configurazione di Cloudinary",
|
||||
"HEADER": "Configurazione Cloudinary",
|
||||
"SUBTITLE": "Archivia i file caricati in Cloudinary e distribuiscili tramite la sua rete di delivery.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Account",
|
||||
"ACCOUNT_HINT": "Tutti e tre i valori si trovano nella console Cloudinary, in API Keys.",
|
||||
"DELIVERY": "Distribuzione",
|
||||
"DELIVERY_HINT": "Come vengono costruiti i link ai file caricati."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "Il nome mostrato in alto nella console Cloudinary.",
|
||||
"ACCESS_API_KEY": "Identifica l'account; si usa insieme al segreto API.",
|
||||
"ACCESS_API_SECRET": "I valori salvati tornano mascherati.",
|
||||
"DELIVERY_URL": "Indirizzo base dei link. Mantieni https://res.cloudinary.com a meno che tu non usi un dominio personalizzato.",
|
||||
"SECURE": "Genera i link con https://. Lascia su True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Nome di Cloudinary",
|
||||
"ACCESS_API_KEY": "ID chiave di accesso",
|
||||
"ACCESS_API_SECRET": "Chiave di accesso segreta",
|
||||
"DELIVERY_URL": "URL di consegna",
|
||||
"SECURE": "Sicuro"
|
||||
"CLOUD_NAME": "Nome cloud",
|
||||
"ACCESS_API_KEY": "Chiave API",
|
||||
"ACCESS_API_SECRET": "Segreto API",
|
||||
"DELIVERY_URL": "URL di distribuzione",
|
||||
"SECURE": "Sicuro (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Nome di Cloudinary",
|
||||
"ACCESS_API_KEY": "ID chiave di accesso",
|
||||
"ACCESS_API_SECRET": "Chiave di accesso segreta",
|
||||
"DELIVERY_URL": "URL di consegna",
|
||||
"SECURE": "Sicuro"
|
||||
"CLOUD_NAME": "Nome cloud",
|
||||
"ACCESS_API_KEY": "Chiave API",
|
||||
"ACCESS_API_SECRET": "Segreto API",
|
||||
"DELIVERY_URL": "URL di distribuzione",
|
||||
"SECURE": "Sicuro (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "Configurazione DigitalOcean",
|
||||
"SUBTITLE": "Archivia i file caricati in uno Space DigitalOcean tramite la sua API compatibile con S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credenziali",
|
||||
"CREDENTIALS_HINT": "Una chiave di accesso Spaces dal pannello di controllo DigitalOcean.",
|
||||
"BUCKET": "Space ed endpoint",
|
||||
"BUCKET_HINT": "Dove vengono scritti i file caricati.",
|
||||
"OPTIONS": "Opzioni"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "La parte pubblica della coppia di chiavi.",
|
||||
"SECRET_ACCESS_KEY": "Mostrata una sola volta, alla creazione della chiave. I valori salvati tornano mascherati.",
|
||||
"SERVICE_URL": "L'endpoint del datacenter del tuo Space, ad es. https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Fissata su us-east-1, come richiesto da Spaces; il datacenter è determinato dall'endpoint.",
|
||||
"BUCKET": "Il nome dello Space.",
|
||||
"CDN_URL": "Facoltativo. L'endpoint CDN dello Space, se la CDN è attiva.",
|
||||
"FORCE_PATH_STYLE": "Indirizza i file come endpoint/space/file invece di space.endpoint/file."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID chiave di accesso",
|
||||
"SECRET_ACCESS_KEY": "Chiave di accesso segreta",
|
||||
"REGION": "Regione",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL del servizio",
|
||||
"CDN_URL": "URL della CDN",
|
||||
"FORCE_PATH_STYLE": "Forza URL in stile percorso"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID chiave di accesso",
|
||||
"SECRET_ACCESS_KEY": "Chiave di accesso segreta",
|
||||
"REGION": "Regione",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL del servizio",
|
||||
"CDN_URL": "URL della CDN"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5491,7 +5632,11 @@
|
||||
"invoice": "Fattura",
|
||||
"estimate": "Stima",
|
||||
"receipt": "Ricevuta"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Griglia",
|
||||
"VIEW_EDITOR": "Editor",
|
||||
"FULL_PREVIEW": "Anteprima completa",
|
||||
"SAVED": "Il modello '{{ templateName }}' è stato salvato"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "Registra",
|
||||
|
||||
@@ -5295,7 +5295,10 @@
|
||||
"payment-receipt": "Betalingsbewijs",
|
||||
"reject-candidate": "Kandidaat Afwijzen"
|
||||
},
|
||||
"HTML_EDITOR": "HTML-editor"
|
||||
"HTML_EDITOR": "HTML-editor",
|
||||
"FULL_PREVIEW": "Volledig voorbeeld",
|
||||
"VIEW_GRID": "Raster",
|
||||
"VIEW_EDITOR": "Editor"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Pijpleidingen",
|
||||
@@ -5345,54 +5348,192 @@
|
||||
"QUICK_ACTIONS": "Snelle acties"
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "Bestandsaanbieder",
|
||||
"FILE_PROVIDER": "Bestandsprovider",
|
||||
"SHOW_SECRET": "Geheim tonen",
|
||||
"HIDE_SECRET": "Geheim verbergen",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Huidige configuratie",
|
||||
"DESCRIPTION": "Laatst opgeslagen instellingen — gebruikt voor nieuwe uploads.",
|
||||
"PROVIDER": "Provider",
|
||||
"BUCKET": "Bucket / cloudnaam",
|
||||
"REGION": "Regio",
|
||||
"ENDPOINT": "Endpoint",
|
||||
"CREDENTIALS": "Inloggegevens",
|
||||
"CREDENTIALS_SAVED": "Opgeslagen",
|
||||
"CREDENTIALS_INCOMPLETE": "Onvolledig",
|
||||
"CREDENTIALS_NONE": "Niet ingesteld",
|
||||
"CREDENTIALS_NOT_NEEDED": "Niet nodig",
|
||||
"SERVER_DEFAULT": "Serverstandaard"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Waar u de sleutels krijgt",
|
||||
"LOCAL": {
|
||||
"NAME": "Lokale schijf",
|
||||
"DESCRIPTION": "bestanden worden opgeslagen op het bestandssysteem van de API-server."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "bestanden worden opgeslagen in een AWS S3-bucket. Gebruik een IAM-toegangssleutel met lees- en schrijfrechten op de bucket."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "S3-compatibele cloudopslag. De inloggegevens worden bij het opslaan bij Wasabi gecontroleerd."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "hosting van afbeeldingen en video's. De cloudnaam en API-sleutels staan in de Cloudinary-console onder API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "S3-compatibele objectopslag met optioneel CDN. Maak een Spaces-toegangssleutel aan in het DigitalOcean-controlepaneel."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Lokale opslag",
|
||||
"WHERE": "Geüploade bestanden worden naar het bestandssysteem van de API-server geschreven.",
|
||||
"NO_CONFIG": "Niets te configureren — lokale opslag werkt direct.",
|
||||
"WARNING": "Bestanden gaan verloren als de server opnieuw wordt uitgerold zonder persistent volume, en ze worden niet gedeeld tussen meerdere API-instanties. Gebruik in productie een cloudprovider."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Configuratie controleren",
|
||||
"OK": "Alle verplichte velden zijn ingevuld en de URL's zijn geldig. De sleutels zelf worden alleen door de provider gecontroleerd.",
|
||||
"OK_LOCAL": "Lokale opslag heeft geen configuratie nodig.",
|
||||
"OK_SERVER_DEFAULTS": "Er ontbreekt niets. Deze lege velden gebruiken de configuratie van de server:",
|
||||
"MISSING": "Ontbreekt:",
|
||||
"INVALID_URL": "Geen geldige http(s)-URL:",
|
||||
"HTTPS_REQUIRED": "Moet https:// gebruiken — de sleutels worden naar dit adres verzonden:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "S3-configuratie",
|
||||
"SUBTITLE": "Sla geüploade bestanden op in een Amazon S3-bucket.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Inloggegevens",
|
||||
"CREDENTIALS_HINT": "Een IAM-toegangssleutel met lees- en schrijfrechten op de bucket.",
|
||||
"BUCKET": "Bucket",
|
||||
"BUCKET_HINT": "Waar geüploade bestanden worden weggeschreven."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Het openbare deel van het sleutelpaar.",
|
||||
"SECRET_ACCESS_KEY": "Wordt maar één keer getoond, bij het aanmaken van de sleutel. Opgeslagen waarden komen gemaskeerd terug.",
|
||||
"REGION": "De AWS-regio van de bucket, bijv. us-east-1 of eu-west-1.",
|
||||
"BUCKET": "De bucketnaam precies zoals in de S3-console."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Toegangssleutel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheime toegangssleutel",
|
||||
"REGION": "Regio",
|
||||
"BUCKET": "Emmer"
|
||||
"BUCKET": "Bucket"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Toegangssleutel-id",
|
||||
"ACCESS_KEY_ID": "Toegangssleutel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheime toegangssleutel",
|
||||
"REGION": "Regio",
|
||||
"BUCKET": "Emmer"
|
||||
"BUCKET": "Bucket"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Wasabi-configuratie",
|
||||
"SUBTITLE": "Sla geüploade bestanden op in een Wasabi-bucket via de S3-compatibele API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Inloggegevens",
|
||||
"CREDENTIALS_HINT": "Een toegangssleutelpaar uit de Wasabi-console.",
|
||||
"BUCKET": "Bucket en endpoint",
|
||||
"BUCKET_HINT": "Waar geüploade bestanden worden weggeschreven.",
|
||||
"OPTIONS": "Opties"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Het openbare deel van het sleutelpaar.",
|
||||
"SECRET_ACCESS_KEY": "Wordt maar één keer getoond, bij het aanmaken van de sleutel. Opgeslagen waarden komen gemaskeerd terug.",
|
||||
"REGION": "De regio van de bucket, bijv. us-east-1 of eu-central-1.",
|
||||
"SERVICE_URL": "Het S3-endpoint voor die regio.",
|
||||
"BUCKET": "Kleine letters, cijfers, punten en koppeltekens. Namen zijn uniek binnen heel Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Adresseer bestanden als endpoint/bucket/bestand in plaats van bucket.endpoint/bestand. Laat aan tenzij u virtual-hosted URL's nodig hebt."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Endpoint voor deze regio:",
|
||||
"USE_SUGGESTION": "Gebruiken",
|
||||
"SAVE_NOTE": "Bij het opslaan maakt Gauzy met deze inloggegevens de bucket aan in Wasabi. Als Wasabi het verzoek weigert, worden de instellingen niet opgeslagen.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Toegangs sleutel ID",
|
||||
"ACCESS_KEY_ID": "Toegangssleutel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheime toegangssleutel",
|
||||
"REGION": "Regio",
|
||||
"BUCKET": "Emmer",
|
||||
"SERVICE_URL": "Service URL"
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service-URL",
|
||||
"FORCE_PATH_STYLE": "Padstijl-URL's afdwingen"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Toegangssleutel-id",
|
||||
"ACCESS_KEY_ID": "Toegangssleutel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheime toegangssleutel",
|
||||
"REGION": "Regio",
|
||||
"BUCKET": "Emmer",
|
||||
"SERVICE_URL": "Service URL"
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service-URL"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Cloudinary Configuratie",
|
||||
"HEADER": "Cloudinary-configuratie",
|
||||
"SUBTITLE": "Sla geüploade bestanden op in Cloudinary en lever ze via het leveringsnetwerk.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Account",
|
||||
"ACCOUNT_HINT": "Alle drie de waarden staan in de Cloudinary-console onder API Keys.",
|
||||
"DELIVERY": "Levering",
|
||||
"DELIVERY_HINT": "Hoe links naar geüploade bestanden worden opgebouwd."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "De naam bovenaan uw Cloudinary-console.",
|
||||
"ACCESS_API_KEY": "Identificeert het account; wordt samen met het API-geheim gebruikt.",
|
||||
"ACCESS_API_SECRET": "Opgeslagen waarden komen gemaskeerd terug.",
|
||||
"DELIVERY_URL": "Basisadres van bestandslinks. Houd https://res.cloudinary.com aan, tenzij u een eigen domein gebruikt.",
|
||||
"SECURE": "Bouw bestandslinks met https://. Laat op True staan."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Cloudinary naam",
|
||||
"ACCESS_API_KEY": "Toegangssleutel-ID",
|
||||
"ACCESS_API_SECRET": "Geheime toegangssleutel",
|
||||
"CLOUD_NAME": "Cloudnaam",
|
||||
"ACCESS_API_KEY": "API-sleutel",
|
||||
"ACCESS_API_SECRET": "API-geheim",
|
||||
"DELIVERY_URL": "Leverings-URL",
|
||||
"SECURE": "Beveiligd"
|
||||
"SECURE": "Beveiligd (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Cloudinary naam",
|
||||
"ACCESS_API_KEY": "Toegangssleutel-ID",
|
||||
"ACCESS_API_SECRET": "Geheime toegangssleutel",
|
||||
"DELIVERY_URL": "Afleverings-URL",
|
||||
"SECURE": "Beveiligen"
|
||||
"CLOUD_NAME": "Cloudnaam",
|
||||
"ACCESS_API_KEY": "API-sleutel",
|
||||
"ACCESS_API_SECRET": "API-geheim",
|
||||
"DELIVERY_URL": "Leverings-URL",
|
||||
"SECURE": "Beveiligd (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "DigitalOcean-configuratie",
|
||||
"SUBTITLE": "Sla geüploade bestanden op in een DigitalOcean Space via de S3-compatibele API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Inloggegevens",
|
||||
"CREDENTIALS_HINT": "Een Spaces-toegangssleutel uit het DigitalOcean-controlepaneel.",
|
||||
"BUCKET": "Space en endpoint",
|
||||
"BUCKET_HINT": "Waar geüploade bestanden worden weggeschreven.",
|
||||
"OPTIONS": "Opties"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Het openbare deel van het sleutelpaar.",
|
||||
"SECRET_ACCESS_KEY": "Wordt maar één keer getoond, bij het aanmaken van de sleutel. Opgeslagen waarden komen gemaskeerd terug.",
|
||||
"SERVICE_URL": "Het endpoint van het datacenter van uw Space, bijv. https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Vast op us-east-1, zoals Spaces verwacht; het datacenter volgt uit het endpoint.",
|
||||
"BUCKET": "De naam van de Space.",
|
||||
"CDN_URL": "Optioneel. Het CDN-endpoint van de Space, als het CDN is ingeschakeld.",
|
||||
"FORCE_PATH_STYLE": "Adresseer bestanden als endpoint/space/bestand in plaats van space.endpoint/bestand."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Toegangssleutel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheime toegangssleutel",
|
||||
"REGION": "Regio",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service-URL",
|
||||
"CDN_URL": "CDN-URL",
|
||||
"FORCE_PATH_STYLE": "Padstijl-URL's afdwingen"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Toegangssleutel-ID",
|
||||
"SECRET_ACCESS_KEY": "Geheime toegangssleutel",
|
||||
"REGION": "Regio",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Service-URL",
|
||||
"CDN_URL": "CDN-URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5491,7 +5632,11 @@
|
||||
"invoice": "Factuur",
|
||||
"estimate": "Schatting",
|
||||
"receipt": "Ontvangstbewijs"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Raster",
|
||||
"VIEW_EDITOR": "Editor",
|
||||
"FULL_PREVIEW": "Volledig voorbeeld",
|
||||
"SAVED": "Sjabloon '{{ templateName }}' is opgeslagen"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "Registreren",
|
||||
|
||||
@@ -5294,7 +5294,10 @@
|
||||
"payment-receipt": "Wysłać potwierdzenie płatności",
|
||||
"reject-candidate": "Odrzuć Kandydata"
|
||||
},
|
||||
"HTML_EDITOR": "Edytor HTML"
|
||||
"HTML_EDITOR": "Edytor HTML",
|
||||
"FULL_PREVIEW": "Pełny podgląd",
|
||||
"VIEW_GRID": "Siatka",
|
||||
"VIEW_EDITOR": "Edytor"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Rurociekle",
|
||||
@@ -5345,53 +5348,191 @@
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "Dostawca plików",
|
||||
"SHOW_SECRET": "Pokaż sekret",
|
||||
"HIDE_SECRET": "Ukryj sekret",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Bieżąca konfiguracja",
|
||||
"DESCRIPTION": "Ostatnio zapisane ustawienia — używane przez nowe przesyłane pliki.",
|
||||
"PROVIDER": "Dostawca",
|
||||
"BUCKET": "Bucket / nazwa chmury",
|
||||
"REGION": "Region",
|
||||
"ENDPOINT": "Punkt końcowy",
|
||||
"CREDENTIALS": "Dane uwierzytelniające",
|
||||
"CREDENTIALS_SAVED": "Zapisane",
|
||||
"CREDENTIALS_INCOMPLETE": "Niekompletne",
|
||||
"CREDENTIALS_NONE": "Nie ustawiono",
|
||||
"CREDENTIALS_NOT_NEEDED": "Niewymagane",
|
||||
"SERVER_DEFAULT": "Domyślne ustawienie serwera"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Skąd wziąć klucze",
|
||||
"LOCAL": {
|
||||
"NAME": "Dysk lokalny",
|
||||
"DESCRIPTION": "pliki są zapisywane w systemie plików serwera API."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "pliki są zapisywane w buckecie AWS S3. Użyj klucza dostępu IAM z prawem odczytu i zapisu do bucketu."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "magazyn w chmurze zgodny z S3. Dane uwierzytelniające są weryfikowane w Wasabi podczas zapisu."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "hosting obrazów i wideo. Nazwa chmury i klucze API znajdują się w konsoli Cloudinary w sekcji API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "magazyn obiektów zgodny z S3 z opcjonalnym CDN. Utwórz klucz dostępu Spaces w panelu sterowania DigitalOcean."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Magazyn lokalny",
|
||||
"WHERE": "Przesłane pliki są zapisywane w systemie plików serwera API.",
|
||||
"NO_CONFIG": "Nic do konfigurowania — magazyn lokalny działa od razu.",
|
||||
"WARNING": "Pliki zostaną utracone, jeśli serwer zostanie ponownie wdrożony bez trwałego wolumenu, i nie są współdzielone między wieloma instancjami API. W produkcji użyj dostawcy chmurowego."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Sprawdź konfigurację",
|
||||
"OK": "Wszystkie wymagane pola są wypełnione, a adresy URL są poprawne. Same klucze weryfikuje wyłącznie dostawca.",
|
||||
"OK_LOCAL": "Magazyn lokalny nie wymaga konfiguracji.",
|
||||
"OK_SERVER_DEFAULTS": "Niczego nie brakuje. Te puste pola używają konfiguracji serwera:",
|
||||
"MISSING": "Brakuje:",
|
||||
"INVALID_URL": "Nieprawidłowy adres URL http(s):",
|
||||
"HTTPS_REQUIRED": "Wymagany https:// — klucze są wysyłane na ten adres:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "Konfiguracja S3",
|
||||
"SUBTITLE": "Przechowuj przesłane pliki w buckecie Amazon S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Dane uwierzytelniające",
|
||||
"CREDENTIALS_HINT": "Klucz dostępu IAM z prawem odczytu i zapisu do bucketu.",
|
||||
"BUCKET": "Bucket",
|
||||
"BUCKET_HINT": "Gdzie zapisywane są przesłane pliki."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Publiczna część pary kluczy.",
|
||||
"SECRET_ACCESS_KEY": "Wyświetlany tylko raz, przy tworzeniu klucza. Zapisane wartości wracają zamaskowane.",
|
||||
"REGION": "Region AWS bucketu, np. us-east-1 lub eu-west-1.",
|
||||
"BUCKET": "Nazwa bucketu dokładnie taka jak w konsoli S3."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Identyfikator klucza dostępu",
|
||||
"SECRET_ACCESS_KEY": "Tajny klucz dostępu",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Wiadro"
|
||||
"BUCKET": "Bucket"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Identyfikator klucza dostępu",
|
||||
"SECRET_ACCESS_KEY": "Sekretny klucz dostępu",
|
||||
"SECRET_ACCESS_KEY": "Tajny klucz dostępu",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Wiadro"
|
||||
"BUCKET": "Bucket"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Konfiguracja Wasabi",
|
||||
"SUBTITLE": "Przechowuj przesłane pliki w buckecie Wasabi przez jego API zgodne z S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Dane uwierzytelniające",
|
||||
"CREDENTIALS_HINT": "Para kluczy dostępu z konsoli Wasabi.",
|
||||
"BUCKET": "Bucket i punkt końcowy",
|
||||
"BUCKET_HINT": "Gdzie zapisywane są przesłane pliki.",
|
||||
"OPTIONS": "Opcje"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Publiczna część pary kluczy.",
|
||||
"SECRET_ACCESS_KEY": "Wyświetlany tylko raz, przy tworzeniu klucza. Zapisane wartości wracają zamaskowane.",
|
||||
"REGION": "Region bucketu, np. us-east-1 lub eu-central-1.",
|
||||
"SERVICE_URL": "Punkt końcowy S3 dla tego regionu.",
|
||||
"BUCKET": "Małe litery, cyfry, kropki i myślniki. Nazwy są unikalne w całym Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Adresuj pliki jako endpoint/bucket/plik zamiast bucket.endpoint/plik. Pozostaw włączone, chyba że potrzebujesz adresów virtual-hosted."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Punkt końcowy dla tego regionu:",
|
||||
"USE_SUGGESTION": "Użyj",
|
||||
"SAVE_NOTE": "Podczas zapisu Gauzy używa tych danych, aby utworzyć bucket w Wasabi. Jeśli Wasabi odrzuci żądanie, ustawienia nie zostaną zapisane.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Identyfikator klucza dostępu",
|
||||
"SECRET_ACCESS_KEY": "Tajny klucz dostępu",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Wiadro",
|
||||
"SERVICE_URL": "Adres URL usługi"
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Adres URL usługi",
|
||||
"FORCE_PATH_STYLE": "Wymuś adresy URL w stylu ścieżki"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Identyfikator klucza dostępu",
|
||||
"SECRET_ACCESS_KEY": "Tajny klucz dostępu",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Wiadro",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Adres URL usługi"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Konfiguracja Cloudinary",
|
||||
"SUBTITLE": "Przechowuj przesłane pliki w Cloudinary i udostępniaj je przez jego sieć dostarczania.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Konto",
|
||||
"ACCOUNT_HINT": "Wszystkie trzy wartości znajdziesz w konsoli Cloudinary w sekcji API Keys.",
|
||||
"DELIVERY": "Dostarczanie",
|
||||
"DELIVERY_HINT": "Jak budowane są linki do przesłanych plików."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "Nazwa widoczna u góry konsoli Cloudinary.",
|
||||
"ACCESS_API_KEY": "Identyfikuje konto; używany razem z sekretem API.",
|
||||
"ACCESS_API_SECRET": "Zapisane wartości wracają zamaskowane.",
|
||||
"DELIVERY_URL": "Bazowy adres linków. Zostaw https://res.cloudinary.com, chyba że używasz własnej domeny.",
|
||||
"SECURE": "Twórz linki z https://. Pozostaw True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Nazwa Cloudinary",
|
||||
"ACCESS_API_KEY": "Numer identyfikacyjny klucza dostępu",
|
||||
"ACCESS_API_SECRET": "Sekretny klucz dostępu",
|
||||
"DELIVERY_URL": "Adres dostawy",
|
||||
"SECURE": "Bezpieczny"
|
||||
"CLOUD_NAME": "Nazwa chmury",
|
||||
"ACCESS_API_KEY": "Klucz API",
|
||||
"ACCESS_API_SECRET": "Sekret API",
|
||||
"DELIVERY_URL": "Adres URL dostarczania",
|
||||
"SECURE": "Bezpieczne (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "nazwa Cloudinary",
|
||||
"ACCESS_API_KEY": "Identyfikator klucza dostępu",
|
||||
"ACCESS_API_SECRET": "Sekretny klucz dostępu",
|
||||
"DELIVERY_URL": "Adres URL dostawy",
|
||||
"SECURE": "Bezpieczny"
|
||||
"CLOUD_NAME": "Nazwa chmury",
|
||||
"ACCESS_API_KEY": "Klucz API",
|
||||
"ACCESS_API_SECRET": "Sekret API",
|
||||
"DELIVERY_URL": "Adres URL dostarczania",
|
||||
"SECURE": "Bezpieczne (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "Konfiguracja DigitalOcean",
|
||||
"SUBTITLE": "Przechowuj przesłane pliki w DigitalOcean Space przez jego API zgodne z S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Dane uwierzytelniające",
|
||||
"CREDENTIALS_HINT": "Klucz dostępu Spaces z panelu sterowania DigitalOcean.",
|
||||
"BUCKET": "Space i punkt końcowy",
|
||||
"BUCKET_HINT": "Gdzie zapisywane są przesłane pliki.",
|
||||
"OPTIONS": "Opcje"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Publiczna część pary kluczy.",
|
||||
"SECRET_ACCESS_KEY": "Wyświetlany tylko raz, przy tworzeniu klucza. Zapisane wartości wracają zamaskowane.",
|
||||
"SERVICE_URL": "Punkt końcowy centrum danych Twojego Space, np. https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Ustawiony na us-east-1, zgodnie z wymaganiami Spaces; centrum danych wynika z punktu końcowego.",
|
||||
"BUCKET": "Nazwa Space.",
|
||||
"CDN_URL": "Opcjonalnie. Punkt końcowy CDN Space, jeśli CDN jest włączony.",
|
||||
"FORCE_PATH_STYLE": "Adresuj pliki jako endpoint/space/plik zamiast space.endpoint/plik."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Identyfikator klucza dostępu",
|
||||
"SECRET_ACCESS_KEY": "Tajny klucz dostępu",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Adres URL usługi",
|
||||
"CDN_URL": "Adres URL CDN",
|
||||
"FORCE_PATH_STYLE": "Wymuś adresy URL w stylu ścieżki"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Identyfikator klucza dostępu",
|
||||
"SECRET_ACCESS_KEY": "Tajny klucz dostępu",
|
||||
"REGION": "Region",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "Adres URL usługi",
|
||||
"CDN_URL": "Adres URL CDN"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5490,7 +5631,11 @@
|
||||
"invoice": "Faktura",
|
||||
"estimate": "Szacunek",
|
||||
"receipt": "Paragon"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Siatka",
|
||||
"VIEW_EDITOR": "Edytor",
|
||||
"FULL_PREVIEW": "Pełny podgląd",
|
||||
"SAVED": "Szablon '{{ templateName }}' został zapisany"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "Zarejestruj się",
|
||||
|
||||
@@ -5295,7 +5295,10 @@
|
||||
"payment-receipt": "Recibo de pagamento",
|
||||
"reject-candidate": "Rejeitar Candidato"
|
||||
},
|
||||
"HTML_EDITOR": "Editor HTML"
|
||||
"HTML_EDITOR": "Editor HTML",
|
||||
"FULL_PREVIEW": "Pré-visualização completa",
|
||||
"VIEW_GRID": "Grade",
|
||||
"VIEW_EDITOR": "Editor"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Pipelines",
|
||||
@@ -5345,54 +5348,192 @@
|
||||
"QUICK_ACTIONS": "Ações rápidas"
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "Fornecedor de arquivos",
|
||||
"FILE_PROVIDER": "Provedor de arquivos",
|
||||
"SHOW_SECRET": "Mostrar segredo",
|
||||
"HIDE_SECRET": "Ocultar segredo",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Configuração atual",
|
||||
"DESCRIPTION": "Últimas configurações salvas — usadas pelos novos envios.",
|
||||
"PROVIDER": "Provedor",
|
||||
"BUCKET": "Bucket / nome da nuvem",
|
||||
"REGION": "Região",
|
||||
"ENDPOINT": "Endpoint",
|
||||
"CREDENTIALS": "Credenciais",
|
||||
"CREDENTIALS_SAVED": "Salvas",
|
||||
"CREDENTIALS_INCOMPLETE": "Incompletas",
|
||||
"CREDENTIALS_NONE": "Não definidas",
|
||||
"CREDENTIALS_NOT_NEEDED": "Não necessárias",
|
||||
"SERVER_DEFAULT": "Padrão do servidor"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Onde obter as chaves",
|
||||
"LOCAL": {
|
||||
"NAME": "Disco local",
|
||||
"DESCRIPTION": "os arquivos são salvos no sistema de arquivos do servidor da API."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "os arquivos são salvos em um bucket do AWS S3. Use uma chave de acesso do IAM com permissão de leitura e escrita no bucket."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "armazenamento em nuvem compatível com S3. As credenciais são verificadas com a Wasabi ao salvar."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "hospedagem de imagens e vídeos. O nome da nuvem e as chaves de API ficam no console do Cloudinary, em API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "armazenamento de objetos compatível com S3 com CDN opcional. Crie uma chave de acesso do Spaces no painel de controle da DigitalOcean."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Armazenamento local",
|
||||
"WHERE": "Os arquivos enviados são gravados no sistema de arquivos do servidor da API.",
|
||||
"NO_CONFIG": "Nada a configurar — o armazenamento local funciona imediatamente.",
|
||||
"WARNING": "Os arquivos são perdidos se o servidor for reimplantado sem um volume persistente e não são compartilhados entre várias instâncias da API. Use um provedor em nuvem em produção."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Verificar configuração",
|
||||
"OK": "Todos os campos obrigatórios estão preenchidos e as URLs são válidas. As chaves em si só são verificadas pelo provedor.",
|
||||
"OK_LOCAL": "O armazenamento local não precisa de configuração.",
|
||||
"OK_SERVER_DEFAULTS": "Nada está faltando. Estes campos vazios usam a configuração do servidor:",
|
||||
"MISSING": "Faltando:",
|
||||
"INVALID_URL": "URL http(s) inválida:",
|
||||
"HTTPS_REQUIRED": "Deve usar https:// — as chaves são enviadas para este endereço:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "Configuração do S3",
|
||||
"SUBTITLE": "Armazene os arquivos enviados em um bucket do Amazon S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credenciais",
|
||||
"CREDENTIALS_HINT": "Uma chave de acesso do IAM com permissão de leitura e escrita no bucket.",
|
||||
"BUCKET": "Bucket",
|
||||
"BUCKET_HINT": "Onde os arquivos enviados são gravados."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "A parte pública do par de chaves.",
|
||||
"SECRET_ACCESS_KEY": "Exibida apenas uma vez, quando a chave é criada. Os valores salvos voltam mascarados.",
|
||||
"REGION": "A região da AWS do bucket, por exemplo us-east-1 ou eu-west-1.",
|
||||
"BUCKET": "O nome do bucket exatamente como aparece no console do S3."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID da chave de acesso",
|
||||
"SECRET_ACCESS_KEY": "Chave de acesso secreta",
|
||||
"REGION": "Região",
|
||||
"BUCKET": "Balde"
|
||||
"BUCKET": "Bucket"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Chave de acesso ID",
|
||||
"ACCESS_KEY_ID": "ID da chave de acesso",
|
||||
"SECRET_ACCESS_KEY": "Chave de acesso secreta",
|
||||
"REGION": "Região",
|
||||
"BUCKET": "Balde"
|
||||
"BUCKET": "Bucket"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Configuração do Wasabi",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "Chave de acesso",
|
||||
"SECRET_ACCESS_KEY": "Chave de acesso secreta.",
|
||||
"REGION": "Região",
|
||||
"BUCKET": "Balde",
|
||||
"SERVICE_URL": "URL de serviço"
|
||||
"SUBTITLE": "Armazene os arquivos enviados em um bucket da Wasabi pela API compatível com S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credenciais",
|
||||
"CREDENTIALS_HINT": "Um par de chaves de acesso do console da Wasabi.",
|
||||
"BUCKET": "Bucket e endpoint",
|
||||
"BUCKET_HINT": "Onde os arquivos enviados são gravados.",
|
||||
"OPTIONS": "Opções"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "Id da chave de acesso",
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "A parte pública do par de chaves.",
|
||||
"SECRET_ACCESS_KEY": "Exibida apenas uma vez, quando a chave é criada. Os valores salvos voltam mascarados.",
|
||||
"REGION": "A região do bucket, por exemplo us-east-1 ou eu-central-1.",
|
||||
"SERVICE_URL": "O endpoint S3 dessa região.",
|
||||
"BUCKET": "Letras minúsculas, números, pontos e hífens. Os nomes são únicos em toda a Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Endereça os arquivos como endpoint/bucket/arquivo em vez de bucket.endpoint/arquivo. Deixe ativado, a menos que precise de URLs virtual-hosted."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Endpoint para esta região:",
|
||||
"USE_SUGGESTION": "Usar",
|
||||
"SAVE_NOTE": "Ao salvar, o Gauzy usa essas credenciais para criar o bucket na Wasabi. Se a Wasabi recusar a solicitação, as configurações não são salvas.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID da chave de acesso",
|
||||
"SECRET_ACCESS_KEY": "Chave de acesso secreta",
|
||||
"REGION": "Região",
|
||||
"BUCKET": "Balde",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL do serviço",
|
||||
"FORCE_PATH_STYLE": "Forçar URLs no estilo de caminho"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID da chave de acesso",
|
||||
"SECRET_ACCESS_KEY": "Chave de acesso secreta",
|
||||
"REGION": "Região",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL do serviço"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Configuração do Cloudinary",
|
||||
"SUBTITLE": "Armazene os arquivos enviados no Cloudinary e entregue-os pela rede de distribuição dele.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Conta",
|
||||
"ACCOUNT_HINT": "Os três valores ficam no console do Cloudinary, em API Keys.",
|
||||
"DELIVERY": "Entrega",
|
||||
"DELIVERY_HINT": "Como os links dos arquivos enviados são montados."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "O nome exibido no topo do seu console do Cloudinary.",
|
||||
"ACCESS_API_KEY": "Identifica a conta; usada junto com o segredo de API.",
|
||||
"ACCESS_API_SECRET": "Os valores salvos voltam mascarados.",
|
||||
"DELIVERY_URL": "Endereço base dos links. Mantenha https://res.cloudinary.com, a menos que use um domínio próprio.",
|
||||
"SECURE": "Gera os links com https://. Deixe em True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Nome do Cloudinary",
|
||||
"ACCESS_API_KEY": "ID da chave de acesso",
|
||||
"ACCESS_API_SECRET": "Chave de acesso secreta",
|
||||
"CLOUD_NAME": "Nome da nuvem",
|
||||
"ACCESS_API_KEY": "Chave de API",
|
||||
"ACCESS_API_SECRET": "Segredo de API",
|
||||
"DELIVERY_URL": "URL de entrega",
|
||||
"SECURE": "Seguro"
|
||||
"SECURE": "Seguro (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "nome Cloudinary",
|
||||
"ACCESS_API_KEY": "ID da chave de acesso",
|
||||
"ACCESS_API_SECRET": "Chave de acesso secreta",
|
||||
"CLOUD_NAME": "Nome da nuvem",
|
||||
"ACCESS_API_KEY": "Chave de API",
|
||||
"ACCESS_API_SECRET": "Segredo de API",
|
||||
"DELIVERY_URL": "URL de entrega",
|
||||
"SECURE": "Seguro"
|
||||
"SECURE": "Seguro (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "Configuração do DigitalOcean",
|
||||
"SUBTITLE": "Armazene os arquivos enviados em um Space da DigitalOcean pela API compatível com S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Credenciais",
|
||||
"CREDENTIALS_HINT": "Uma chave de acesso do Spaces do painel de controle da DigitalOcean.",
|
||||
"BUCKET": "Space e endpoint",
|
||||
"BUCKET_HINT": "Onde os arquivos enviados são gravados.",
|
||||
"OPTIONS": "Opções"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "A parte pública do par de chaves.",
|
||||
"SECRET_ACCESS_KEY": "Exibida apenas uma vez, quando a chave é criada. Os valores salvos voltam mascarados.",
|
||||
"SERVICE_URL": "O endpoint do datacenter do seu Space, por exemplo https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Fixada em us-east-1, como o Spaces espera; o datacenter vem do endpoint.",
|
||||
"BUCKET": "O nome do Space.",
|
||||
"CDN_URL": "Opcional. O endpoint de CDN do Space, se a CDN estiver ativada.",
|
||||
"FORCE_PATH_STYLE": "Endereça os arquivos como endpoint/space/arquivo em vez de space.endpoint/arquivo."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID da chave de acesso",
|
||||
"SECRET_ACCESS_KEY": "Chave de acesso secreta",
|
||||
"REGION": "Região",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL do serviço",
|
||||
"CDN_URL": "URL da CDN",
|
||||
"FORCE_PATH_STYLE": "Forçar URLs no estilo de caminho"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID da chave de acesso",
|
||||
"SECRET_ACCESS_KEY": "Chave de acesso secreta",
|
||||
"REGION": "Região",
|
||||
"BUCKET": "Bucket",
|
||||
"SERVICE_URL": "URL do serviço",
|
||||
"CDN_URL": "URL da CDN"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5491,7 +5632,11 @@
|
||||
"invoice": "Fatura",
|
||||
"estimate": "Estimativa",
|
||||
"receipt": "Recibo"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Grade",
|
||||
"VIEW_EDITOR": "Editor",
|
||||
"FULL_PREVIEW": "Pré-visualização completa",
|
||||
"SAVED": "O modelo '{{ templateName }}' foi salvo"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "Registrar",
|
||||
|
||||
@@ -5311,7 +5311,10 @@
|
||||
"payment-receipt": "Квитанция об оплате",
|
||||
"reject-candidate": "Отклонить кандидата"
|
||||
},
|
||||
"HTML_EDITOR": "HTML-редактор"
|
||||
"HTML_EDITOR": "HTML-редактор",
|
||||
"FULL_PREVIEW": "Полный просмотр",
|
||||
"VIEW_GRID": "Сетка",
|
||||
"VIEW_EDITOR": "Редактор"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "Трубопроводы",
|
||||
@@ -5361,37 +5364,192 @@
|
||||
"QUICK_ACTIONS": "Быстрые действия"
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "Поставщик файлов",
|
||||
"FILE_PROVIDER": "Провайдер файлов",
|
||||
"SHOW_SECRET": "Показать секрет",
|
||||
"HIDE_SECRET": "Скрыть секрет",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Текущая конфигурация",
|
||||
"DESCRIPTION": "Последние сохранённые настройки — используются для новых загрузок.",
|
||||
"PROVIDER": "Провайдер",
|
||||
"BUCKET": "Бакет / имя облака",
|
||||
"REGION": "Регион",
|
||||
"ENDPOINT": "Эндпоинт",
|
||||
"CREDENTIALS": "Учётные данные",
|
||||
"CREDENTIALS_SAVED": "Сохранены",
|
||||
"CREDENTIALS_INCOMPLETE": "Неполные",
|
||||
"CREDENTIALS_NONE": "Не заданы",
|
||||
"CREDENTIALS_NOT_NEEDED": "Не требуются",
|
||||
"SERVER_DEFAULT": "Настройки сервера по умолчанию"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "Где получить ключи",
|
||||
"LOCAL": {
|
||||
"NAME": "Локальный диск",
|
||||
"DESCRIPTION": "файлы сохраняются в файловой системе сервера API."
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "файлы сохраняются в бакет AWS S3. Используйте ключ доступа IAM с правами на чтение и запись в бакет."
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "S3-совместимое облачное хранилище. Учётные данные проверяются в Wasabi при сохранении."
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "хостинг изображений и видео. Имя облака и API-ключи находятся в консоли Cloudinary в разделе API Keys."
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "S3-совместимое объектное хранилище с необязательным CDN. Создайте ключ доступа Spaces в панели управления DigitalOcean."
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "Локальное хранилище",
|
||||
"WHERE": "Загруженные файлы записываются в файловую систему сервера API.",
|
||||
"NO_CONFIG": "Настраивать нечего — локальное хранилище работает сразу.",
|
||||
"WARNING": "Файлы будут потеряны, если сервер переразвернуть без постоянного тома, и они не разделяются между несколькими экземплярами API. В продакшене используйте облачного провайдера."
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "Проверить конфигурацию",
|
||||
"OK": "Все обязательные поля заполнены, URL-адреса корректны. Сами ключи проверяет только провайдер.",
|
||||
"OK_LOCAL": "Локальному хранилищу не нужна настройка.",
|
||||
"OK_SERVER_DEFAULTS": "Ничего не упущено. Эти пустые поля берутся из конфигурации сервера:",
|
||||
"MISSING": "Не заполнено:",
|
||||
"INVALID_URL": "Некорректный http(s) URL:",
|
||||
"HTTPS_REQUIRED": "Нужен https:// — ключи отправляются на этот адрес:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "Конфигурация S3",
|
||||
"SUBTITLE": "Храните загруженные файлы в бакете Amazon S3.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Учётные данные",
|
||||
"CREDENTIALS_HINT": "Ключ доступа IAM с правами на чтение и запись в бакет.",
|
||||
"BUCKET": "Бакет",
|
||||
"BUCKET_HINT": "Куда записываются загруженные файлы."
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Открытая часть пары ключей.",
|
||||
"SECRET_ACCESS_KEY": "Показывается только один раз — при создании ключа. Сохранённые значения возвращаются замаскированными.",
|
||||
"REGION": "Регион AWS, в котором находится бакет, например us-east-1 или eu-west-1.",
|
||||
"BUCKET": "Имя бакета точно как в консоли S3."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID ключа доступа",
|
||||
"SECRET_ACCESS_KEY": "Секретный ключ доступа",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Ведро"
|
||||
"BUCKET": "Бакет"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID ключа доступа",
|
||||
"SECRET_ACCESS_KEY": "Секретный ключ доступа",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Ведро"
|
||||
"BUCKET": "Бакет"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "Wasabi Configuration",
|
||||
"HEADER": "Конфигурация Wasabi",
|
||||
"SUBTITLE": "Храните загруженные файлы в бакете Wasabi через его S3-совместимый API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Учётные данные",
|
||||
"CREDENTIALS_HINT": "Пара ключей доступа из консоли Wasabi.",
|
||||
"BUCKET": "Бакет и эндпоинт",
|
||||
"BUCKET_HINT": "Куда записываются загруженные файлы.",
|
||||
"OPTIONS": "Параметры"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Открытая часть пары ключей.",
|
||||
"SECRET_ACCESS_KEY": "Показывается только один раз — при создании ключа. Сохранённые значения возвращаются замаскированными.",
|
||||
"REGION": "Регион бакета, например us-east-1 или eu-central-1.",
|
||||
"SERVICE_URL": "S3-эндпоинт этого региона.",
|
||||
"BUCKET": "Строчные буквы, цифры, точки и дефисы. Имена уникальны во всём Wasabi.",
|
||||
"FORCE_PATH_STYLE": "Адресовать файлы как эндпоинт/бакет/файл вместо бакет.эндпоинт/файл. Оставьте включённым, если вам не нужны virtual-hosted URL."
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "Эндпоинт для этого региона:",
|
||||
"USE_SUGGESTION": "Использовать",
|
||||
"SAVE_NOTE": "При сохранении Gauzy создаёт бакет в Wasabi с этими учётными данными. Если Wasabi отклонит запрос, настройки не сохранятся.",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID ключа доступа",
|
||||
"SECRET_ACCESS_KEY": "Секретный ключ доступа",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Ведро",
|
||||
"SERVICE_URL": "URL-адрес сервиса"
|
||||
"BUCKET": "Бакет",
|
||||
"SERVICE_URL": "URL сервиса",
|
||||
"FORCE_PATH_STYLE": "URL в стиле пути"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID ключа доступа",
|
||||
"SECRET_ACCESS_KEY": "Секретный ключ доступа",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Ведро",
|
||||
"SERVICE_URL": "URL-адрес сервиса"
|
||||
"BUCKET": "Бакет",
|
||||
"SERVICE_URL": "URL сервиса"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "Конфигурация Cloudinary",
|
||||
"SUBTITLE": "Храните загруженные файлы в Cloudinary и отдавайте их через его сеть доставки.",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "Аккаунт",
|
||||
"ACCOUNT_HINT": "Все три значения есть в консоли Cloudinary в разделе API Keys.",
|
||||
"DELIVERY": "Доставка",
|
||||
"DELIVERY_HINT": "Как формируются ссылки на загруженные файлы."
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "Имя, показанное вверху консоли Cloudinary.",
|
||||
"ACCESS_API_KEY": "Идентифицирует аккаунт; используется вместе с API-секретом.",
|
||||
"ACCESS_API_SECRET": "Сохранённые значения возвращаются замаскированными.",
|
||||
"DELIVERY_URL": "Базовый адрес ссылок. Оставьте https://res.cloudinary.com, если не используете свой домен.",
|
||||
"SECURE": "Формировать ссылки с https://. Оставьте True."
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "Имя облака",
|
||||
"ACCESS_API_KEY": "API-ключ",
|
||||
"ACCESS_API_SECRET": "API-секрет",
|
||||
"DELIVERY_URL": "URL доставки",
|
||||
"SECURE": "Защищённое соединение (HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Имя облака",
|
||||
"ACCESS_API_KEY": "API-ключ",
|
||||
"ACCESS_API_SECRET": "API-секрет",
|
||||
"DELIVERY_URL": "URL доставки",
|
||||
"SECURE": "Защищённое соединение (HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "Конфигурация DigitalOcean",
|
||||
"SUBTITLE": "Храните загруженные файлы в DigitalOcean Space через его S3-совместимый API.",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "Учётные данные",
|
||||
"CREDENTIALS_HINT": "Ключ доступа Spaces из панели управления DigitalOcean.",
|
||||
"BUCKET": "Space и эндпоинт",
|
||||
"BUCKET_HINT": "Куда записываются загруженные файлы.",
|
||||
"OPTIONS": "Параметры"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "Открытая часть пары ключей.",
|
||||
"SECRET_ACCESS_KEY": "Показывается только один раз — при создании ключа. Сохранённые значения возвращаются замаскированными.",
|
||||
"SERVICE_URL": "Эндпоинт дата-центра вашего Space, например https://fra1.digitaloceanspaces.com.",
|
||||
"REGION": "Всегда us-east-1, как требует Spaces; дата-центр определяется эндпоинтом.",
|
||||
"BUCKET": "Имя Space.",
|
||||
"CDN_URL": "Необязательно. CDN-эндпоинт Space, если CDN включён.",
|
||||
"FORCE_PATH_STYLE": "Адресовать файлы как эндпоинт/space/файл вместо space.эндпоинт/файл."
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "ID ключа доступа",
|
||||
"SECRET_ACCESS_KEY": "Секретный ключ доступа",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Бакет",
|
||||
"SERVICE_URL": "URL сервиса",
|
||||
"CDN_URL": "URL CDN",
|
||||
"FORCE_PATH_STYLE": "URL в стиле пути"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "ID ключа доступа",
|
||||
"SECRET_ACCESS_KEY": "Секретный ключ доступа",
|
||||
"REGION": "Регион",
|
||||
"BUCKET": "Бакет",
|
||||
"SERVICE_URL": "URL сервиса",
|
||||
"CDN_URL": "URL CDN"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5489,7 +5647,11 @@
|
||||
"invoice": "Счет",
|
||||
"estimate": "Оценить",
|
||||
"receipt": "Квитанция"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "Сетка",
|
||||
"VIEW_EDITOR": "Редактор",
|
||||
"FULL_PREVIEW": "Полный просмотр",
|
||||
"SAVED": "Шаблон '{{ templateName }}' сохранён"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "Регистрация",
|
||||
|
||||
@@ -5295,7 +5295,10 @@
|
||||
"payment-receipt": "付款收据",
|
||||
"reject-candidate": "拒绝候选人"
|
||||
},
|
||||
"HTML_EDITOR": "HTML编辑器"
|
||||
"HTML_EDITOR": "HTML编辑器",
|
||||
"FULL_PREVIEW": "完整预览",
|
||||
"VIEW_GRID": "网格",
|
||||
"VIEW_EDITOR": "编辑器"
|
||||
},
|
||||
"PIPELINES_PAGE": {
|
||||
"HEADER": "管道",
|
||||
@@ -5345,54 +5348,192 @@
|
||||
"QUICK_ACTIONS": "快速行动"
|
||||
},
|
||||
"SETTINGS_FILE_STORAGE": {
|
||||
"FILE_PROVIDER": "文件提供者",
|
||||
"FILE_PROVIDER": "文件存储提供商",
|
||||
"SHOW_SECRET": "显示密钥",
|
||||
"HIDE_SECRET": "隐藏密钥",
|
||||
"SUMMARY": {
|
||||
"TITLE": "当前配置",
|
||||
"DESCRIPTION": "最近保存的设置——新上传的文件将使用此配置。",
|
||||
"PROVIDER": "提供商",
|
||||
"BUCKET": "存储桶 / 云名称",
|
||||
"REGION": "区域",
|
||||
"ENDPOINT": "端点",
|
||||
"CREDENTIALS": "凭据",
|
||||
"CREDENTIALS_SAVED": "已保存",
|
||||
"CREDENTIALS_INCOMPLETE": "不完整",
|
||||
"CREDENTIALS_NONE": "未设置",
|
||||
"CREDENTIALS_NOT_NEEDED": "无需",
|
||||
"SERVER_DEFAULT": "服务器默认值"
|
||||
},
|
||||
"PROVIDERS": {
|
||||
"DOCS_LINK": "如何获取密钥",
|
||||
"LOCAL": {
|
||||
"NAME": "本地磁盘",
|
||||
"DESCRIPTION": "文件保存在 API 服务器自身的文件系统中。"
|
||||
},
|
||||
"S3": {
|
||||
"NAME": "Amazon S3",
|
||||
"DESCRIPTION": "文件保存到 AWS S3 存储桶。请使用对该存储桶具有读写权限的 IAM 访问密钥。"
|
||||
},
|
||||
"WASABI": {
|
||||
"NAME": "Wasabi",
|
||||
"DESCRIPTION": "兼容 S3 的云存储。保存时会向 Wasabi 验证凭据。"
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"NAME": "Cloudinary",
|
||||
"DESCRIPTION": "图片和视频托管。云名称和 API 密钥位于 Cloudinary 控制台的 API Keys 下。"
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"NAME": "DigitalOcean Spaces",
|
||||
"DESCRIPTION": "兼容 S3 的对象存储,可选 CDN。请在 DigitalOcean 控制面板中创建 Spaces 访问密钥。"
|
||||
}
|
||||
},
|
||||
"LOCAL": {
|
||||
"HEADER": "本地存储",
|
||||
"WHERE": "上传的文件写入 API 服务器的文件系统。",
|
||||
"NO_CONFIG": "无需配置——本地存储开箱即用。",
|
||||
"WARNING": "如果服务器在没有持久卷的情况下重新部署,文件将丢失,且文件不会在多个 API 实例之间共享。生产环境请使用云存储提供商。"
|
||||
},
|
||||
"CHECK": {
|
||||
"BUTTON": "检查配置",
|
||||
"OK": "所有必填字段均已填写,URL 有效。密钥本身仅由提供商验证。",
|
||||
"OK_LOCAL": "本地存储无需配置。",
|
||||
"OK_SERVER_DEFAULTS": "没有缺失项。以下留空的字段将使用服务器自身的配置:",
|
||||
"MISSING": "缺少:",
|
||||
"INVALID_URL": "无效的 http(s) URL:",
|
||||
"HTTPS_REQUIRED": "必须使用 https://——密钥会发送到此地址:"
|
||||
},
|
||||
"S3": {
|
||||
"HEADER": "S3配置",
|
||||
"HEADER": "S3 配置",
|
||||
"SUBTITLE": "将上传的文件存储到 Amazon S3 存储桶中。",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "凭据",
|
||||
"CREDENTIALS_HINT": "对该存储桶具有读写权限的 IAM 访问密钥。",
|
||||
"BUCKET": "存储桶",
|
||||
"BUCKET_HINT": "上传文件的写入位置。"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "密钥对的公开部分。",
|
||||
"SECRET_ACCESS_KEY": "仅在创建密钥时显示一次。已保存的值会以掩码形式返回。",
|
||||
"REGION": "存储桶所在的 AWS 区域,例如 us-east-1 或 eu-west-1。",
|
||||
"BUCKET": "与 S3 控制台中完全一致的存储桶名称。"
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "访问密钥 ID",
|
||||
"SECRET_ACCESS_KEY": "秘密访问密钥",
|
||||
"REGION": "地区",
|
||||
"BUCKET": "桶"
|
||||
"REGION": "区域",
|
||||
"BUCKET": "存储桶"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "访问密钥 ID",
|
||||
"SECRET_ACCESS_KEY": "秘密访问密钥",
|
||||
"REGION": "地区",
|
||||
"BUCKET": "桶"
|
||||
"REGION": "区域",
|
||||
"BUCKET": "存储桶"
|
||||
}
|
||||
},
|
||||
"WASABI": {
|
||||
"HEADER": "山葵配置",
|
||||
"HEADER": "Wasabi 配置",
|
||||
"SUBTITLE": "通过兼容 S3 的 API 将上传的文件存储到 Wasabi 存储桶中。",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "凭据",
|
||||
"CREDENTIALS_HINT": "来自 Wasabi 控制台的访问密钥对。",
|
||||
"BUCKET": "存储桶与端点",
|
||||
"BUCKET_HINT": "上传文件的写入位置。",
|
||||
"OPTIONS": "选项"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "密钥对的公开部分。",
|
||||
"SECRET_ACCESS_KEY": "仅在创建密钥时显示一次。已保存的值会以掩码形式返回。",
|
||||
"REGION": "存储桶所在区域,例如 us-east-1 或 eu-central-1。",
|
||||
"SERVICE_URL": "该区域的 S3 端点。",
|
||||
"BUCKET": "仅限小写字母、数字、点和连字符。名称在整个 Wasabi 中唯一。",
|
||||
"FORCE_PATH_STYLE": "以 endpoint/bucket/file 而非 bucket.endpoint/file 的形式访问文件。除非需要虚拟主机式 URL,否则请保持开启。"
|
||||
},
|
||||
"SUGGESTED_ENDPOINT": "该区域的端点:",
|
||||
"USE_SUGGESTION": "使用",
|
||||
"SAVE_NOTE": "保存时,Gauzy 会使用这些凭据在 Wasabi 中创建存储桶。如果 Wasabi 拒绝请求,设置将不会保存。",
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "访问密钥ID",
|
||||
"ACCESS_KEY_ID": "访问密钥 ID",
|
||||
"SECRET_ACCESS_KEY": "秘密访问密钥",
|
||||
"REGION": "地区",
|
||||
"BUCKET": "桶",
|
||||
"SERVICE_URL": "服务网址"
|
||||
"REGION": "区域",
|
||||
"BUCKET": "存储桶",
|
||||
"SERVICE_URL": "服务 URL",
|
||||
"FORCE_PATH_STYLE": "强制使用路径样式 URL"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "访问密钥 ID",
|
||||
"SECRET_ACCESS_KEY": "秘密访问密钥",
|
||||
"REGION": "地区",
|
||||
"BUCKET": "桶",
|
||||
"SERVICE_URL": "服务网址"
|
||||
"REGION": "区域",
|
||||
"BUCKET": "存储桶",
|
||||
"SERVICE_URL": "服务 URL"
|
||||
}
|
||||
},
|
||||
"CLOUDINARY": {
|
||||
"HEADER": "云图片配置",
|
||||
"HEADER": "Cloudinary 配置",
|
||||
"SUBTITLE": "将上传的文件存储到 Cloudinary,并通过其分发网络提供访问。",
|
||||
"SECTIONS": {
|
||||
"ACCOUNT": "账户",
|
||||
"ACCOUNT_HINT": "这三项均可在 Cloudinary 控制台的 API Keys 中找到。",
|
||||
"DELIVERY": "分发",
|
||||
"DELIVERY_HINT": "上传文件链接的生成方式。"
|
||||
},
|
||||
"HINTS": {
|
||||
"CLOUD_NAME": "Cloudinary 控制台顶部显示的名称。",
|
||||
"ACCESS_API_KEY": "用于识别账户;与 API Secret 配合使用。",
|
||||
"ACCESS_API_SECRET": "已保存的值会以掩码形式返回。",
|
||||
"DELIVERY_URL": "文件链接的基础地址。除非使用自定义域名,否则保持 https://res.cloudinary.com。",
|
||||
"SECURE": "使用 https:// 生成文件链接。保持为 True。"
|
||||
},
|
||||
"LABELS": {
|
||||
"CLOUD_NAME": "云图名字",
|
||||
"ACCESS_API_KEY": "访问密钥 ID",
|
||||
"ACCESS_API_SECRET": "秘密访问密钥",
|
||||
"DELIVERY_URL": "交付网址",
|
||||
"SECURE": "安全"
|
||||
"CLOUD_NAME": "云名称",
|
||||
"ACCESS_API_KEY": "API Key",
|
||||
"ACCESS_API_SECRET": "API Secret",
|
||||
"DELIVERY_URL": "分发 URL",
|
||||
"SECURE": "安全连接(HTTPS)"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"CLOUD_NAME": "Cloudinary名称",
|
||||
"ACCESS_API_KEY": "访问密钥标识 (Fǎngwèn mìyào biāozhū)",
|
||||
"ACCESS_API_SECRET": "秘密访问密钥",
|
||||
"DELIVERY_URL": "交付URL",
|
||||
"SECURE": "安全"
|
||||
"CLOUD_NAME": "云名称",
|
||||
"ACCESS_API_KEY": "API Key",
|
||||
"ACCESS_API_SECRET": "API Secret",
|
||||
"DELIVERY_URL": "分发 URL",
|
||||
"SECURE": "安全连接(HTTPS)"
|
||||
}
|
||||
},
|
||||
"DIGITALOCEAN": {
|
||||
"HEADER": "DigitalOcean 配置",
|
||||
"SUBTITLE": "通过兼容 S3 的 API 将上传的文件存储到 DigitalOcean Space 中。",
|
||||
"SECTIONS": {
|
||||
"CREDENTIALS": "凭据",
|
||||
"CREDENTIALS_HINT": "来自 DigitalOcean 控制面板的 Spaces 访问密钥。",
|
||||
"BUCKET": "Space 与端点",
|
||||
"BUCKET_HINT": "上传文件的写入位置。",
|
||||
"OPTIONS": "选项"
|
||||
},
|
||||
"HINTS": {
|
||||
"ACCESS_KEY_ID": "密钥对的公开部分。",
|
||||
"SECRET_ACCESS_KEY": "仅在创建密钥时显示一次。已保存的值会以掩码形式返回。",
|
||||
"SERVICE_URL": "Space 所在数据中心的端点,例如 https://fra1.digitaloceanspaces.com。",
|
||||
"REGION": "按 Spaces 的要求固定为 us-east-1;数据中心由端点决定。",
|
||||
"BUCKET": "Space 的名称。",
|
||||
"CDN_URL": "可选。若已启用 CDN,填写该 Space 的 CDN 端点。",
|
||||
"FORCE_PATH_STYLE": "以 endpoint/space/file 而非 space.endpoint/file 的形式访问文件。"
|
||||
},
|
||||
"LABELS": {
|
||||
"ACCESS_KEY_ID": "访问密钥 ID",
|
||||
"SECRET_ACCESS_KEY": "秘密访问密钥",
|
||||
"REGION": "区域",
|
||||
"BUCKET": "存储桶",
|
||||
"SERVICE_URL": "服务 URL",
|
||||
"CDN_URL": "CDN URL",
|
||||
"FORCE_PATH_STYLE": "强制使用路径样式 URL"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"ACCESS_KEY_ID": "访问密钥 ID",
|
||||
"SECRET_ACCESS_KEY": "秘密访问密钥",
|
||||
"REGION": "区域",
|
||||
"BUCKET": "存储桶",
|
||||
"SERVICE_URL": "服务 URL",
|
||||
"CDN_URL": "CDN URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5491,7 +5632,11 @@
|
||||
"invoice": "发票",
|
||||
"estimate": "估计",
|
||||
"receipt": "收据"
|
||||
}
|
||||
},
|
||||
"VIEW_GRID": "网格",
|
||||
"VIEW_EDITOR": "编辑器",
|
||||
"FULL_PREVIEW": "完整预览",
|
||||
"SAVED": "模板“{{ templateName }}”已保存"
|
||||
},
|
||||
"REGISTER_PAGE": {
|
||||
"TITLE": "注册",
|
||||
|
||||
@@ -23,6 +23,14 @@ import { CountryService } from '@gauzy/ui-core/core';
|
||||
import { convertPrecisionFloatDigit } from '@gauzy/ui-core/common';
|
||||
import { FormHelpers } from '../helpers';
|
||||
|
||||
/**
|
||||
* Whether the Google Maps script was loaded. It may not be (no internet access, a blocked host): the
|
||||
* form then works without geocoding and address suggestions, and the address is entered by hand.
|
||||
*/
|
||||
function isGoogleMapsLoaded(): boolean {
|
||||
return typeof google !== 'undefined' && !!google?.maps;
|
||||
}
|
||||
|
||||
@UntilDestroy({ checkProperties: true })
|
||||
@Component({
|
||||
selector: 'ga-location-form',
|
||||
@@ -251,6 +259,9 @@ export class LocationFormComponent extends TranslationBaseComponent implements A
|
||||
if (newAddress !== this._lastUsedAddressText) {
|
||||
this._lastUsedAddressText = newAddress;
|
||||
|
||||
if (!isGoogleMapsLoaded()) {
|
||||
return;
|
||||
}
|
||||
const geocoder = new google.maps.Geocoder();
|
||||
geocoder.geocode(
|
||||
{
|
||||
@@ -281,6 +292,9 @@ export class LocationFormComponent extends TranslationBaseComponent implements A
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isGoogleMapsLoaded()) {
|
||||
return;
|
||||
}
|
||||
const geocoder = new google.maps.Geocoder();
|
||||
geocoder.geocode(
|
||||
{
|
||||
@@ -452,7 +466,7 @@ export class LocationFormComponent extends TranslationBaseComponent implements A
|
||||
* Initializes the Google Autocomplete API on the specified DOM element.
|
||||
*/
|
||||
private _initGoogleAutocompleteApi() {
|
||||
if (this.searchElement) {
|
||||
if (this.searchElement && isGoogleMapsLoaded()) {
|
||||
const autocomplete = new google.maps.places.Autocomplete(this.searchElement.nativeElement);
|
||||
this._setupGoogleAutocompleteOptions(autocomplete);
|
||||
this._listenForGoogleAutocompleteAddressChanges(autocomplete);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IIncome, ITag, IOrganization, ICurrency, ISelectedEmployee } from '@gau
|
||||
import { debounceTime, filter, tap } from 'rxjs/operators';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { distinctUntilChange, isNotEmpty } from '@gauzy/ui-core/common';
|
||||
import { distinctUntilChange } from '@gauzy/ui-core/common';
|
||||
import { OrganizationSettingService, Store } from '@gauzy/ui-core/core';
|
||||
import { TranslationBaseComponent } from '@gauzy/ui-core/i18n';
|
||||
import { FormHelpers } from '../../forms/helpers';
|
||||
@@ -102,10 +102,8 @@ export class IncomeMutationComponent extends TranslationBaseComponent implements
|
||||
}
|
||||
|
||||
selectedTagsHandler(tags: ITag[]) {
|
||||
if (isNotEmpty(tags)) {
|
||||
this.form.patchValue({ tags: tags });
|
||||
this.form.updateValueAndValidity();
|
||||
}
|
||||
this.form.patchValue({ tags: tags });
|
||||
this.form.updateValueAndValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+112
-40
@@ -2,65 +2,137 @@
|
||||
|
||||
# Egress audit
|
||||
|
||||
A runtime proof of what the anonymous usage statistics module (`@gauzy/plugin-ever-stats`) may send:
|
||||
**nothing at all** when it is switched off, and **only the statistics report** when it is on.
|
||||
A runtime proof of what the optional Ever Platform modules (`@gauzy/plugin-ever-stats`, the
|
||||
anonymous usage statistics, and `@gauzy/plugin-ever-connect`, the connection) may send:
|
||||
**nothing at all** when they are switched off or loaded but idle, and **only the statistics report**
|
||||
when the statistics are on. Both are off on a self-hosted install unless an operator turns them on.
|
||||
|
||||
The audit runs the Gauzy API image with a throw-away PostgreSQL on Docker networks that have no route out.
|
||||
CoreDNS is the API's only resolver and logs every query; a `tcpdump` sniffer in the API's network
|
||||
namespace starts before the API and records every connection attempt and DNS question. A run fails
|
||||
when the API looks up an Ever host (or any name outside the compose services), tries to reach
|
||||
anything outside the sealed networks, answers a statistics route while the module is off, or makes a
|
||||
call its mode does not allow.
|
||||
The audit runs the published Gauzy API and web app images with a throw-away PostgreSQL on Docker
|
||||
networks that have no route out. CoreDNS is the only resolver and logs every query; a `tcpdump`
|
||||
sniffer in the API's network namespace starts before the API and records every connection attempt
|
||||
and DNS question. A run fails when the API looks up an Ever host (or any name outside the compose
|
||||
services), tries to reach anything outside the sealed networks, answers a statistics route while the
|
||||
module is off, or makes a call its mode does not allow.
|
||||
|
||||
The **browser leg** does the same for the web app: a real browser, in its own sniffed namespace with
|
||||
the same resolver, signs in as the seeded Super Admin through the real sign-in page, opens every
|
||||
route of the Angular router and holds the settings and integrations pages open. It fails when the
|
||||
browser looks up or requests an Ever host, or when a page renders a link to one that is not in the
|
||||
baseline of links that predate the modules.
|
||||
|
||||
## The harness
|
||||
|
||||
The harness is the egress audit of the Ever Platform SDK's dev tools, the
|
||||
`ever-egress-audit` of [`@ever-co/connect-tools`][connect-tools] (Apache-2.0),
|
||||
with its mock platform. It is not copied here: the workflow installs the exact
|
||||
version the statistics plugin pins (`devDependencies` of
|
||||
`packages/plugins/ever-stats/package.json`) from npm for the run. The config
|
||||
pins the sealed network to a fixed private `subnet`, so the mock's address on
|
||||
it (`__MOCK_URL__`) is a local address the module accepts over plain http.
|
||||
with its mock platform. It is not copied here: `package.json` and
|
||||
`package-lock.json` in this directory pin it (with the `typescript` its Angular
|
||||
route generator reads the router with, and the `eslint` and `@typescript-eslint/parser` the
|
||||
import-boundary test runs its rule with), and the workflow installs them with
|
||||
`npm ci`. This directory is not a workspace of the monorepo. The config pins
|
||||
the sealed network to a fixed private `subnet`, so the mock's address on it
|
||||
(`__MOCK_URL__`) is a local address the module accepts over plain http.
|
||||
|
||||
[connect-tools]: https://www.npmjs.com/package/@ever-co/connect-tools
|
||||
|
||||
This directory holds only Gauzy's inputs:
|
||||
|
||||
- `egress-audit.config.json`: the compose file, the API service, the statistics routes probed in the
|
||||
off modes, and the Gauzy mode `off_env_file`;
|
||||
- `adapter.mjs`: in the off modes, calls every statistics route with its own method (the harness's
|
||||
probe sends GET only) and requires 404 from each; it also points the module at an address on the
|
||||
audit network and serves `teams`, so a module loaded by mistake would answer its public route and
|
||||
try to send within the watched window;
|
||||
- `compose.egress-audit.yml`: the API image under test (`GAUZY_API_IMAGE`) and a PostgreSQL, a
|
||||
random secret per run (`GAUZY_AUDIT_SECRET`), the API's `.env.local` of the mode
|
||||
(`GAUZY_AUDIT_DOTENV`) and no published port.
|
||||
- `egress-audit.config.json`: the compose file, the API and web app services, the statistics routes
|
||||
probed in the off modes, the browser leg's settings and the Gauzy mode `off_env_file`;
|
||||
- `adapter.mjs`: signs in as the seeded Super Admin (through the API for the ids the browser needs,
|
||||
never a token; through the sign-in page for the browser, after the pages a person sees signed
|
||||
out); in the off modes calls every statistics route with its own method and requires
|
||||
404 from each; in `loaded_off` switches the statistics off in Settings as the operator; points the
|
||||
statistics at an address on the audit network with a short day, so a module that sends when it
|
||||
should not is seen within the watched window;
|
||||
- `compose.egress-audit.yml`: the images under test (`GAUZY_API_IMAGE`, `GAUZY_WEBAPP_IMAGE`) and a
|
||||
PostgreSQL, a random secret per run (`GAUZY_AUDIT_SECRET`), the API's `.env.local` of the mode
|
||||
(`GAUZY_AUDIT_DOTENV`), seeded accounts under `example.com` and no published port. The web app gets
|
||||
only the API's address and its own; every other setting keeps the image's default;
|
||||
- `ui-routes.json`: the routes the browser opens, generated from the Angular router; routes that UI
|
||||
plugins register at run time are listed by hand (`"source": "manual"`) and kept when it is
|
||||
generated again;
|
||||
- `route-params.json`: values for route parameters (the adapter adds the seeded ids);
|
||||
- `ui-baseline.json`: the links to Ever hosts the web app rendered before the modules (the product
|
||||
site, legal pages, downloads), recorded with both modules off from `base_commit`. It may only
|
||||
shrink, and an entry excuses that one link on that one route, never a lookup or a request.
|
||||
|
||||
`allowed_external_hosts` names the third-party hosts the web app reaches by default whatever the
|
||||
modules do, none of them Ever's: Google Fonts (`fonts.googleapis.com`, the fonts' stylesheet), Google
|
||||
Maps (`maps.googleapis.com`, the maps script), OpenStreetMap tiles (`a.`, `b.`,
|
||||
`c.tile.openstreetmap.org`, the location maps), `dummyimage.com` (placeholder images of records
|
||||
without one) and `github.com` (the GitHub integration page opens the GitHub App installation). They
|
||||
may be looked up (in the sealed networks they reach nothing); an Ever host can never be allowed.
|
||||
|
||||
The web app routes in the URL fragment (`/#/pages/...`), so the config says `"ui_routing": "hash"`
|
||||
(with `web_url` the bare address) and the browser opens each route as
|
||||
`http://webapp:4200/#/<route>`. The harness signs the walk in through the adapter's `uiLogin` and
|
||||
checks that the sign-in holds: `ui_sign_in_route` (`/auth/login`) is the sign-in page, and a route
|
||||
that ends on it faults the run.
|
||||
|
||||
## Modes
|
||||
|
||||
| Mode | The API | Passes when |
|
||||
|---|---|---|
|
||||
| `off` | `EVER_STATS_ENABLED=false` in the container environment | no Ever host looked up, no connection attempt out, `/api/ever-stats/*` 404 |
|
||||
| `off_env_file` | the same switch written ONLY in the API's `.env.local` (its working directory) | the same |
|
||||
| `positive_stats` | module on, `EVER_STATS_API_URL` = the mock platform | reports accepted (`202`), and no call but the statistics report |
|
||||
| control | `positive_stats` with the mock platform left out | must **fail** (exit 1): a green run is not a blind one |
|
||||
|
||||
"Switched off in Settings" (module loaded, no call at all, *Send now* answers 409) is proven by the
|
||||
plugin's suite against the same mock platform (`yarn nx run plugin-ever-stats:test-mock-platform`),
|
||||
which `build-api` runs on every develop push.
|
||||
| Mode | The API | Browser leg | Passes when |
|
||||
|---|---|---|---|
|
||||
| `off` | `EVER_STATS_ENABLED=false`, connection unset | yes | no Ever host looked up or requested, no connection attempt out, `/api/ever-stats/*` 404, no Ever link outside the baseline |
|
||||
| `off_env_file` | the statistics switch written ONLY in the API's `.env.local` (its working directory) | no | the same, for the API |
|
||||
| `loaded_off` | both modules loaded: statistics on by configuration and switched off by the operator in Settings, connection on and not connected | yes | no call at all, in both legs |
|
||||
| `positive_stats` | statistics on, `EVER_STATS_API_URL` = the mock platform | yes | reports accepted (`202`), no call but the statistics report; the browser's settings page asks the API for the statistics status |
|
||||
| control | `positive_stats` with the mock platform left out | yes | must **fail** (exit 1): a green run is not a blind one |
|
||||
| `every_trigger` | both modules on against the mock platform; the adapter fires every trigger of every call (connect with `EVER_CONNECT_CODE`, link, entitlement refresh unchanged and new, a consent and the operator's accept, the statistics link, switching off, unlink, disconnect; the report goes out by itself) | no | the calls made are **exactly** the Gauzy rows of the contract this release makes, row for row; the rows it does not make (`every_trigger_exclude_rows`, kept equal to the "not made" table of [the list of outbound calls](../../docs/ever-platform/outbound-calls.md) by a check) are never called |
|
||||
| `connect_off_sign_in_on` | connection off, statistics off, the Ever ID sign-in plugin on (`ZITADEL_ENABLED=true`) with an issuer on an Ever host | yes | the plugin leaves the issuer unused on a self-hosted installation: no Ever host looked up or requested, no redirect to it, `/api/ever-stats/*` 404 |
|
||||
| `entitlement_ladder` | connection on against the mock platform; the mock's clock moves so its documents are issued 600 s in the future, 8 days ago (a 7-day document: expired a day ago), 38 days ago (expired 31 days ago), 200 s ahead | no | the future one is refused and the stored one kept; the others are stored and the ladder reads `grace`, `paused` (every feature off), `valid`; Gauzy's own routes (health, sign-in, the user, the organization, projects, invoices, time logs, contacts) answer the same at every step; only the connection's documented calls |
|
||||
| static-bundle | the web app image's built files | - | no Ever host the baseline's `bundle` list does not have |
|
||||
|
||||
## Running it
|
||||
|
||||
`.github/workflows/egress-audit.yml` runs it on a GitHub-hosted runner every day on the newest
|
||||
published develop API image, and on demand (`gh workflow run egress-audit.yml -f image=<image>`).
|
||||
Locally, with Docker and the workspace installed (`@ever-co/connect-tools` is
|
||||
a devDependency):
|
||||
`.github/workflows/egress-audit.yml` runs each mode as its own job on a GitHub-hosted runner every
|
||||
day on the newest published develop images, on demand (`gh workflow run egress-audit.yml -f
|
||||
image=<api image> -f webapp_image=<web app image>`) and on pull requests that change its inputs. On
|
||||
every pull request that changes the UI's routes it also checks that `ui-routes.json` is in step with
|
||||
the router and that `ui-baseline.json` only shrank.
|
||||
|
||||
Locally, with Docker:
|
||||
|
||||
```sh
|
||||
npm ci --prefix tools/egress-audit --ignore-scripts
|
||||
: > /tmp/api-empty.env
|
||||
GAUZY_AUDIT_SECRET=$(openssl rand -hex 32) GAUZY_API_IMAGE=ghcr.io/ever-co/gauzy-api-demo:latest GAUZY_AUDIT_DOTENV=/tmp/api-empty.env \
|
||||
node node_modules/@ever-co/connect-tools/dist/egress-audit/run.mjs --config tools/egress-audit/egress-audit.config.json --mode off
|
||||
GAUZY_AUDIT_SECRET=$(openssl rand -hex 32) GAUZY_AUDIT_DOTENV=/tmp/api-empty.env \
|
||||
GAUZY_API_IMAGE=ghcr.io/ever-co/gauzy-api-demo:latest GAUZY_WEBAPP_IMAGE=ghcr.io/ever-co/gauzy-webapp-demo:latest \
|
||||
node tools/egress-audit/node_modules/@ever-co/connect-tools/dist/egress-audit/run.mjs \
|
||||
--config tools/egress-audit/egress-audit.config.json --mode off
|
||||
|
||||
# the route list against the router (exits 1 naming a route the list lacks)
|
||||
node tools/egress-audit/node_modules/@ever-co/connect-tools/dist/egress-audit/run.mjs ui-routes \
|
||||
--framework angular --entry apps/gauzy/src/app/app.routes.ts --out tools/egress-audit/ui-routes.json --check
|
||||
```
|
||||
|
||||
The evidence (`report.json`, the pcaps, the DNS log, the API log and the mock's call record per
|
||||
mode) lands in `egress-audit-artifacts/`.
|
||||
## Static checks
|
||||
|
||||
Run on every pull request that touches code, with nothing installed but the harness:
|
||||
|
||||
- `node tools/egress-audit/static-hostnames.mjs`: no file outside the Ever Platform modules (their
|
||||
packages, their docs, this directory, tests and prose) names an Ever host (the `ever_owned`
|
||||
names of the harness's never-allowed list and every name under them) or `EVER_PLATFORM_API_URL` /
|
||||
`EVER_STATS_API_URL` more often than `static-hostnames.baseline.json` records. The baseline holds
|
||||
what Gauzy named before the modules (the product site, documentation, demo accounts, downloads);
|
||||
it may only shrink (`--check-shrink <the file at the base>`), and `--write` refuses to grow it. With
|
||||
`--bundle <dir>` the same scan reads a built web app (the `/srv/gauzy` of the web app image)
|
||||
against the baseline's `bundle` hosts; the `static-bundle` job does it on the image under test.
|
||||
- `node tools/ever-platform/check-import-boundary.mjs`: only the modules and the two plugin lists
|
||||
import a module, through its entry point; the statistics never import the connection or an
|
||||
analytics plugin, the instance identity no HTTP client, the connection and the sign-in plugin
|
||||
never each other, a web part never a server module, and the connection's entry point exports no
|
||||
entitlement code. The same rules are the ESLint rule `ever-platform/import-boundary` of the root
|
||||
`eslint.config.js` (`tools/ever-platform/import-boundary.cjs`).
|
||||
- `node tools/ever-platform/outbound-calls.mjs --check`: the tables of
|
||||
[`docs/ever-platform/outbound-calls.md`](../../docs/ever-platform/outbound-calls.md) are the pinned
|
||||
contract's rows for what Gauzy makes (`--write` regenerates them), the connection README's request
|
||||
table names exactly those rows and their requests, the statistics README its request, the audit
|
||||
excludes exactly the rows Gauzy does not make, and the harness and the modules pin one contract
|
||||
version.
|
||||
|
||||
Each has a known-bad fixture its test (`*.test.mjs`, `node --test`) requires to fail.
|
||||
|
||||
The evidence (`report.json`, the pcaps, the DNS log, the API log, the mock's call record, and for
|
||||
the browser leg the HAR without cookies, header values or bodies, the requests, the rendered links
|
||||
and the visited routes) lands in `egress-audit-artifacts/<mode>/`.
|
||||
|
||||
@@ -1,8 +1,78 @@
|
||||
// Ever Gauzy's adapter for the Ever Platform egress audit (see README.md).
|
||||
//
|
||||
// The audit's driver runs it inside the sealed audit network, so it reaches the API by its compose
|
||||
// service name. In the off modes it does what anyone could do without an account: call every route
|
||||
// of the anonymous usage statistics with its own method, and require 404 from each.
|
||||
// The audit's driver runs the API hooks inside the sealed audit network, so they reach the API by
|
||||
// its compose service name. The browser hooks (uiLogin, routeParams) run in the audit's browser,
|
||||
// which reaches the web app and the API by their service names too.
|
||||
//
|
||||
// - off modes: what anyone could do without an account: call every route of the anonymous usage
|
||||
// statistics with its own method, and require 404 from each.
|
||||
// - every mode: sign in as the seeded Super Admin and hand the browser the ids it needs for the
|
||||
// routes that carry one (never a token).
|
||||
// - loaded_off: the Super Admin (the instance operator) switches the statistics off in Settings.
|
||||
// - every_trigger: every trigger of every call this release makes, against the mock platform
|
||||
// (connect, link, entitlement refresh, a consent and the operator's accept, the statistics link,
|
||||
// switching off, unlink, disconnect); the call log must be exactly the generated rows.
|
||||
// - connect_off_sign_in_on: the connection off and the Ever ID sign-in plugin on with an issuer on
|
||||
// an Ever host: the plugin must not use it (no lookup, no request, no redirect to it).
|
||||
// - entitlement_ladder: entitlement documents dated so the stored one is in grace, then paused, one
|
||||
// issued in the future (refused, the stored one kept) and one slightly ahead (accepted): the
|
||||
// ladder follows, and Gauzy's own routes answer the same at every step.
|
||||
// - browser leg: sign in through the real sign-in page (the harness checks that the sign-in holds:
|
||||
// a route that ends on the sign-in page faults the walk).
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
/** The seeded Super Admin of the audit's throw-away database (compose.egress-audit.yml). */
|
||||
const SEED_EMAIL = 'admin@example.com';
|
||||
|
||||
/**
|
||||
* Its password, made for this run where the harness reads the adapter's `env` (the runner) and
|
||||
* handed to the API (which seeds it) and to the hooks as `ctx.env.DEMO_SUPER_ADMIN_PASSWORD`. The
|
||||
* driver and the browser import this file too; there the value below is unused.
|
||||
*/
|
||||
const RUN_SEED = { DEMO_SUPER_ADMIN_PASSWORD: `audit-${randomBytes(18).toString('hex')}` };
|
||||
const seedPassword = (ctx) => {
|
||||
const value = ctx.env?.DEMO_SUPER_ADMIN_PASSWORD;
|
||||
if (!value) throw new Error('no seed password in the mode environment (DEMO_SUPER_ADMIN_PASSWORD)');
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* The pages a person sees before signing in. Signed in, their guard sends the walk to the dashboard,
|
||||
* so the browser opens them first, signed out: each as a full page load (a query of its own before
|
||||
* the fragment), so the harness records its requests and dumps its links (under the path `/`).
|
||||
*/
|
||||
const SIGNED_OUT_ROUTES = [
|
||||
'/auth/login',
|
||||
'/auth/register',
|
||||
'/auth/request-password',
|
||||
'/auth/reset-password',
|
||||
'/auth/confirm-email',
|
||||
'/auth/accept-invite',
|
||||
'/auth/accept-client-invite',
|
||||
'/auth/estimate',
|
||||
'/auth/login-workspace',
|
||||
'/auth/login-magic',
|
||||
'/auth/magic-sign-in',
|
||||
'/auth/ever-id',
|
||||
'/auth/ever-id/confirm',
|
||||
'/auth/ever-id/signup',
|
||||
'/share/workspace/create',
|
||||
'/share/workspace/find',
|
||||
'/share/workspace/signin',
|
||||
'/legal/terms',
|
||||
'/legal/privacy',
|
||||
'/legal/cookies'
|
||||
];
|
||||
|
||||
/** The route of a web app URL: its fragment without the query (`#/pages/settings`). */
|
||||
const routeOf = (url) => new URL(url).hash.split('?')[0] || '/';
|
||||
|
||||
/**
|
||||
* A value for a route parameter that names something the seed does not have: the page still
|
||||
* renders around a "not found", which is all the walk needs (what it renders and what it calls).
|
||||
*/
|
||||
const UNKNOWN_ID = '00000000-0000-4000-8000-000000000000';
|
||||
|
||||
/** Every statistics route, with the method the settings page (or the paired Ever Teams) uses. */
|
||||
const STATS_ROUTES = [
|
||||
@@ -16,7 +86,136 @@ const STATS_ROUTES = [
|
||||
];
|
||||
|
||||
/** The modes in which the module is switched off by configuration. */
|
||||
const OFF_MODES = new Set(['off', 'off_env_file']);
|
||||
const OFF_MODES = new Set(['off', 'off_env_file', 'connect_off_sign_in_on']);
|
||||
|
||||
/** The mock platform, by its alias on the sealed network (the driver runs the API hooks). */
|
||||
// The mock platform serves plain http on the sealed audit network only (no route out). NOSONAR
|
||||
const MOCK = 'http://mock-platform:8080'; // NOSONAR
|
||||
|
||||
/** The mock's link code: an organization of Ever Platform the operator's organization links to. */
|
||||
const MOCK_LINK_CODE = 'EVL-TEST-0000-0002';
|
||||
|
||||
/**
|
||||
* connect_off_sign_in_on: the Ever ID sign-in plugin on, with an issuer on an Ever host and the
|
||||
* connection off. On a self-hosted installation only the connection can enable such an issuer, so
|
||||
* the plugin must leave it unused.
|
||||
*/
|
||||
const SIGN_IN_ON = {
|
||||
ZITADEL_ISSUERS: 'https://auth.ever.co',
|
||||
ZITADEL_CLIENT_ID: 'gauzy-egress-audit',
|
||||
ZITADEL_CLIENT_SECRET: 'egress-audit-not-a-secret'
|
||||
};
|
||||
|
||||
/** entitlement_ladder: the operator connects with the mock's code and the statistics stay off. */
|
||||
const LADDER = {
|
||||
EVER_CONNECT_FEED_MODE: 'interval'
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** A JSON request to the API with the operator's bearer; answers {status, body}. */
|
||||
async function api(ctx, method, path, body) {
|
||||
const response = await ctx.fetch(`${ctx.baseUrl}${path}`, {
|
||||
method,
|
||||
redirect: 'manual',
|
||||
headers: { ...ctx.auth, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
return { status: response.status, body: parsed, location: response.headers.get('location') };
|
||||
}
|
||||
|
||||
/** A control of the mock platform (`/__mock/<path>`): what app.ever.co or Ever would do. */
|
||||
async function mock(ctx, path, body) {
|
||||
const response = await ctx.fetch(`${MOCK}/__mock/${path}`, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: body === undefined ? {} : { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
const parsed = await response.json().catch(() => null);
|
||||
if (response.status >= 300) throw new Error(`mock ${path} answered ${response.status} ${JSON.stringify(parsed)}`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** The rows the mock platform recorded so far. */
|
||||
const recordedRows = async (ctx) => (await mock(ctx, 'requests')).map((entry) => entry.row);
|
||||
|
||||
/** Waits until `probe` answers truthy (checked every 2 s), else fails naming `what`. */
|
||||
async function until(what, probe, seconds = 180) {
|
||||
const deadline = Date.now() + seconds * 1000;
|
||||
let last;
|
||||
while (Date.now() < deadline) {
|
||||
last = await probe().catch((error) => ({ error: error.message }));
|
||||
if (last && !last.error) return last;
|
||||
await sleep(2000);
|
||||
}
|
||||
throw new Error(`${what} did not happen within ${seconds} s (last: ${JSON.stringify(last)})`);
|
||||
}
|
||||
|
||||
/** Signs in and waits until the installation is connected (EVER_CONNECT_CODE at boot); answers the context. */
|
||||
async function connected(ctx) {
|
||||
const { headers, token, user } = await apiLogin(ctx);
|
||||
let session = { ...ctx, auth: { ...headers, ...(user.tenantId ? { 'tenant-id': user.tenantId } : {}) } };
|
||||
// The organization: the one the sign-in names, else the first the Super Admin is an active member of.
|
||||
const memberships = await api(
|
||||
session,
|
||||
'GET',
|
||||
`/api/user-organization?where[userId]=${user.id}&where[isActive]=true&take=20`
|
||||
);
|
||||
const candidates = [
|
||||
user.lastOrganizationId,
|
||||
organizationOf(token),
|
||||
...((memberships.body?.items ?? []).map((m) => m.organizationId) ?? [])
|
||||
].filter((id, index, all) => typeof id === 'string' && all.indexOf(id) === index);
|
||||
if (!candidates.length)
|
||||
throw new Error(`the seeded Super Admin has no organization (memberships: ${memberships.status})`);
|
||||
await until('the connection with EVER_CONNECT_CODE', async () => {
|
||||
const answers = [];
|
||||
for (const organizationId of candidates) {
|
||||
const status = await api(session, 'GET', `/api/ever-connect/status?organizationId=${organizationId}`);
|
||||
answers.push(`${organizationId}: ${status.status} ${JSON.stringify(status.body?.message ?? '')}`);
|
||||
if (status.status !== 200) continue;
|
||||
if (!status.body?.operator) throw new Error('the seeded Super Admin is not the operator');
|
||||
session.organizationId = organizationId;
|
||||
return status.body.connected ? status.body : null;
|
||||
}
|
||||
throw new Error(`no organization of the Super Admin answers the status: ${answers.join('; ')}`);
|
||||
});
|
||||
const state = await mock(session, 'state');
|
||||
session.instanceId = state.instances?.[0]?.id;
|
||||
if (!session.instanceId) throw new Error('the mock platform has no installation after the connect');
|
||||
ctx.log(`adapter: connected (instance ${session.instanceId})`);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Each of Gauzy's own routes the ladder must leave alone, with its status. */
|
||||
async function coreRoutes(session) {
|
||||
const login = await session.fetch(`${session.baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: SEED_EMAIL, password: seedPassword(session) })
|
||||
});
|
||||
let answers = {
|
||||
'GET /api/health': (await api(session, 'GET', '/api/health')).status,
|
||||
'POST /api/auth/login': login.status
|
||||
};
|
||||
for (const path of [
|
||||
'/api/user/me',
|
||||
`/api/organization/${session.organizationId}`,
|
||||
`/api/organization-projects?where[organizationId]=${session.organizationId}`,
|
||||
`/api/invoices?where[organizationId]=${session.organizationId}`,
|
||||
`/api/timesheet/time-log?organizationId=${session.organizationId}`,
|
||||
`/api/organization-contact?where[organizationId]=${session.organizationId}`
|
||||
])
|
||||
answers[`GET ${path.split('?')[0]}`] = (await api(session, 'GET', path)).status;
|
||||
return answers;
|
||||
}
|
||||
|
||||
/**
|
||||
* In the off modes a module loaded by mistake must be SEEN: its routes would answer (the public
|
||||
@@ -29,10 +228,256 @@ const LOADED_BY_MISTAKE_WOULD_SHOW = {
|
||||
EVER_STATS_SERVES: 'gauzy,teams'
|
||||
};
|
||||
|
||||
/**
|
||||
* loaded_off: the module is loaded and on by configuration, and the operator switches it off in
|
||||
* Settings once the API is up. Its day is 5 minutes, so the first report would fall due inside the
|
||||
* watched window (a few minutes after the switch, during the browser walk): a switch that did not
|
||||
* hold would look up `ever-audit-sink`, which is not a compose service.
|
||||
*/
|
||||
const LOADED_OFF = {
|
||||
EVER_STATS_API_URL: 'https://ever-audit-sink',
|
||||
EVER_STATS_SEND_INTERVAL_S: '300',
|
||||
EVER_STATS_SERVES: 'gauzy,teams'
|
||||
};
|
||||
|
||||
/** Signs in through the API as the seeded Super Admin; answers the bearer headers and the user. */
|
||||
async function apiLogin(ctx) {
|
||||
const { baseUrl, fetch } = ctx;
|
||||
const response = await fetch(`${baseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email: SEED_EMAIL, password: seedPassword(ctx) })
|
||||
});
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
throw new Error(`the seeded Super Admin could not sign in through the API (${response.status})`);
|
||||
}
|
||||
const body = await response.json();
|
||||
if (!body?.token) throw new Error('the API sign-in answered no token');
|
||||
return { headers: { authorization: `Bearer ${body.token}` }, user: body.user ?? {}, token: body.token };
|
||||
}
|
||||
|
||||
/** The organization a token is scoped to (its `organizationId` claim), read without verifying it. */
|
||||
function organizationOf(token) {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
||||
return typeof payload.organizationId === 'string' ? payload.organizationId : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
env: {
|
||||
off: LOADED_BY_MISTAKE_WOULD_SHOW,
|
||||
off_env_file: LOADED_BY_MISTAKE_WOULD_SHOW
|
||||
off: { ...LOADED_BY_MISTAKE_WOULD_SHOW, ...RUN_SEED },
|
||||
off_env_file: { ...LOADED_BY_MISTAKE_WOULD_SHOW, ...RUN_SEED },
|
||||
loaded_off: { ...LOADED_OFF, ...RUN_SEED },
|
||||
positive_stats: RUN_SEED,
|
||||
positive_connect: RUN_SEED,
|
||||
every_trigger: RUN_SEED,
|
||||
positive_managed: RUN_SEED,
|
||||
connect_off_sign_in_on: { ...LOADED_BY_MISTAKE_WOULD_SHOW, ...SIGN_IN_ON, ...RUN_SEED },
|
||||
entitlement_ladder: { ...LADDER, ...RUN_SEED }
|
||||
},
|
||||
|
||||
/**
|
||||
* every_trigger: each trigger of every call the modules make (the statistics report, row 17,
|
||||
* goes out by itself every 5 s). The installation connected at boot with EVER_CONNECT_CODE
|
||||
* (rows 1, 3, 4, then 6, 7, 8, 9).
|
||||
*/
|
||||
async triggerAll(ctx) {
|
||||
const session = await connected(ctx);
|
||||
const org = session.organizationId;
|
||||
const expect = (what, answer, statuses) => {
|
||||
if (!statuses.includes(answer.status))
|
||||
throw new Error(`${what} answered ${answer.status} ${JSON.stringify(answer.body)}`);
|
||||
ctx.log(`adapter: ${what}: ${answer.status}`);
|
||||
return answer.body;
|
||||
};
|
||||
|
||||
// Row 5: the operator's organization links to an Ever organization with a link code.
|
||||
const link = expect(
|
||||
'link',
|
||||
await api(session, 'POST', `/api/ever-connect/links?organizationId=${org}`, { link_code: MOCK_LINK_CODE }),
|
||||
[200, 201]
|
||||
);
|
||||
|
||||
// Row 8: an on-demand refresh (304: nothing new), then a new document on the platform (200).
|
||||
expect(
|
||||
'entitlement refresh (unchanged)',
|
||||
await api(session, 'POST', `/api/ever-connect/entitlement/refresh?organizationId=${org}`),
|
||||
[200]
|
||||
);
|
||||
await mock(session, 'entitlement/reissue', { instance_id: session.instanceId });
|
||||
const refreshed = expect(
|
||||
'entitlement refresh (new document)',
|
||||
await api(session, 'POST', `/api/ever-connect/entitlement/refresh?organizationId=${org}`),
|
||||
[200]
|
||||
);
|
||||
ctx.log(`adapter: the installation's document is now seq ${refreshed?.instance?.seq}`);
|
||||
|
||||
// Rows 9, 31, 11: the organization consents to the statistics link in app.ever.co; it waits
|
||||
// for the operator (read from the event feed, row 7); the operator accepts; the link goes out.
|
||||
await mock(session, 'consent', {
|
||||
instance_id: session.instanceId,
|
||||
integration: 'stats_link',
|
||||
operator_accept: 'pending'
|
||||
});
|
||||
await until('the consent waiting for the operator', async () => {
|
||||
const status = await api(session, 'GET', `/api/ever-connect/status?organizationId=${org}`);
|
||||
return (status.body?.pending_approvals ?? []).some((p) => p.key === 'stats_link') || null;
|
||||
});
|
||||
expect(
|
||||
'operator accept',
|
||||
await api(session, 'POST', '/api/ever-connect/integrations/stats_link/accept', { accepted: true }),
|
||||
[200]
|
||||
);
|
||||
await until(
|
||||
'the statistics link (row 11)',
|
||||
async () => (await recordedRows(session)).includes(11) || null,
|
||||
120
|
||||
);
|
||||
|
||||
// Row 10: switched off here, Ever Platform told.
|
||||
expect(
|
||||
'switch off',
|
||||
await api(session, 'PUT', `/api/ever-connect/integrations/stats_link?organizationId=${org}`, {
|
||||
enabled: false
|
||||
}),
|
||||
[200]
|
||||
);
|
||||
|
||||
// Row 5 (removal): unlink.
|
||||
const linkId = link?.integration_tenant_id ?? link?.integrationTenantId ?? link?.id;
|
||||
if (!linkId) throw new Error(`the link answered no id: ${JSON.stringify(Object.keys(link ?? {}))}`);
|
||||
expect(
|
||||
'unlink',
|
||||
await api(session, 'DELETE', `/api/ever-connect/links/${linkId}?organizationId=${org}`),
|
||||
[200, 204]
|
||||
);
|
||||
|
||||
// Row 16: the operator disconnects.
|
||||
expect('disconnect', await api(session, 'POST', '/api/ever-connect/disconnect', { confirm: true }), [200, 201]);
|
||||
ctx.log(`adapter: rows so far: ${[...new Set(await recordedRows(session))].sort((a, b) => a - b).join(', ')}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* connect_off_sign_in_on: the sign-in plugin is loaded, but the Ever issuer is not used: its
|
||||
* public config offers no Ever ID button and a sign-in start does not redirect to an Ever host.
|
||||
*/
|
||||
async probeSignIn(ctx) {
|
||||
const config = await ctx.fetch(`${ctx.baseUrl}/api/auth/zitadel/config`);
|
||||
const body = await config.json().catch(() => null);
|
||||
ctx.log(`adapter: sign-in plugin config: ${config.status} ${JSON.stringify(body)}`);
|
||||
if (config.status !== 200)
|
||||
throw new Error(
|
||||
`the sign-in plugin is not loaded (config answered ${config.status}); this mode needs it on`
|
||||
);
|
||||
const start = await ctx.fetch(`${ctx.baseUrl}/api/auth/zitadel`, { redirect: 'manual' });
|
||||
const location = start.headers.get('location') ?? '';
|
||||
ctx.log(
|
||||
`adapter: sign-in start: ${start.status}${location ? ` to ${new URL(location, ctx.baseUrl).host}` : ''}`
|
||||
);
|
||||
if (/^https?:\/\/([^/]*\.)?ever\.co(\/|:|$)/i.test(location)) {
|
||||
throw new Error('the sign-in plugin redirected to an Ever issuer with the connection off');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* entitlement_ladder: the mock platform's clock is moved so the documents it signs are dated in
|
||||
* the past or the future of the installation's clock. At each step the installation refreshes
|
||||
* on demand (a new document each time) and the ladder must follow; Gauzy's own routes must answer
|
||||
* as they did before the first step.
|
||||
*/
|
||||
async entitlementLadder(ctx) {
|
||||
const session = await connected(ctx);
|
||||
const org = session.organizationId;
|
||||
const before = await coreRoutes(session);
|
||||
ctx.log(`adapter: Gauzy's routes before: ${JSON.stringify(before)}`);
|
||||
const failing = Object.entries(before).filter(([, status]) => status < 200 || status >= 300);
|
||||
if (failing.length) {
|
||||
throw new Error(
|
||||
`Gauzy's own routes must answer before the ladder starts: ${failing.map(([route, status]) => `${route} ${status}`).join('; ')}`
|
||||
);
|
||||
}
|
||||
const realNow = () => Math.floor(Date.now() / 1000);
|
||||
const day = 86_400;
|
||||
// The mock issues documents valid for 7 days (exp = iat + 7 d) with a 30-day grace: issued 8 days
|
||||
// ago it expired a day ago (grace), issued 38 days ago it expired 31 days ago (paused).
|
||||
const LIFETIME_DAYS = 7;
|
||||
const steps = [
|
||||
{
|
||||
name: 'issued 600 s in the future (refused, the stored one kept)',
|
||||
at: () => realNow() + 600,
|
||||
ladder: 'valid',
|
||||
newSeq: false
|
||||
},
|
||||
{
|
||||
name: `issued ${LIFETIME_DAYS + 1} days ago, expired a day ago (grace)`,
|
||||
at: () => realNow() - (LIFETIME_DAYS + 1) * day,
|
||||
ladder: 'grace',
|
||||
newSeq: true
|
||||
},
|
||||
{
|
||||
name: `issued ${LIFETIME_DAYS + 31} days ago, expired 31 days ago (paused)`,
|
||||
at: () => realNow() - (LIFETIME_DAYS + 31) * day,
|
||||
ladder: 'paused',
|
||||
newSeq: true
|
||||
},
|
||||
{ name: 'issued 200 s ahead (accepted)', at: () => realNow() + 200, ladder: 'valid', newSeq: true }
|
||||
];
|
||||
let seq =
|
||||
(await api(session, 'GET', `/api/ever-connect/entitlement?organizationId=${org}`)).body?.instance?.seq ??
|
||||
null;
|
||||
if (seq === null) throw new Error('no entitlement document was stored at connect');
|
||||
for (const step of steps) {
|
||||
let refreshed;
|
||||
await mock(session, 'clock', { set: step.at() });
|
||||
try {
|
||||
await mock(session, 'entitlement/reissue', { instance_id: session.instanceId });
|
||||
refreshed = await api(session, 'POST', `/api/ever-connect/entitlement/refresh?organizationId=${org}`);
|
||||
} finally {
|
||||
// Back to real time whatever happened, so the rest of the run is recorded on the real clock.
|
||||
await mock(session, 'clock', { set: realNow() });
|
||||
}
|
||||
if (refreshed.status !== 200)
|
||||
throw new Error(
|
||||
`${step.name}: the refresh answered ${refreshed.status} ${JSON.stringify(refreshed.body)}`
|
||||
);
|
||||
const instance = refreshed.body?.instance;
|
||||
const features = Object.values(instance?.features ?? {});
|
||||
ctx.log(
|
||||
`adapter: ${step.name}: ladder ${instance?.ladder}, seq ${instance?.seq}, features on ${features.filter(Boolean).length}`
|
||||
);
|
||||
if (instance?.ladder !== step.ladder)
|
||||
throw new Error(`${step.name}: the ladder is ${instance?.ladder}, expected ${step.ladder}`);
|
||||
if (step.newSeq === (instance?.seq === seq)) {
|
||||
throw new Error(
|
||||
`${step.name}: the stored document is seq ${instance?.seq} (was ${seq}); ${step.newSeq ? 'a new one' : 'the old one'} was expected`
|
||||
);
|
||||
}
|
||||
if (step.ladder === 'paused' && features.some(Boolean))
|
||||
throw new Error(`${step.name}: a feature is still on while paused`);
|
||||
seq = instance.seq;
|
||||
const after = await coreRoutes(session);
|
||||
if (JSON.stringify(after) !== JSON.stringify(before)) {
|
||||
throw new Error(
|
||||
`${step.name}: Gauzy's own routes changed: ${JSON.stringify(before)} then ${JSON.stringify(after)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.log("adapter: the ladder followed every document and Gauzy's own routes answered the same at every step");
|
||||
},
|
||||
|
||||
/** Ids for the browser's routes (the user, the organization, the employee): never a token. */
|
||||
async createFixtures(ctx) {
|
||||
const { user, token } = await apiLogin(ctx);
|
||||
const fixtures = {
|
||||
userId: user.id ?? UNKNOWN_ID,
|
||||
organizationId: user.lastOrganizationId ?? organizationOf(token) ?? UNKNOWN_ID,
|
||||
employeeId: user.employee?.id ?? UNKNOWN_ID
|
||||
};
|
||||
ctx.log(`adapter: fixtures for the browser: ${Object.keys(fixtures).join(', ')}`);
|
||||
return fixtures;
|
||||
},
|
||||
|
||||
/** Off modes only: each statistics route, with its own method, must answer 404. */
|
||||
@@ -44,7 +489,10 @@ export default {
|
||||
method,
|
||||
redirect: 'manual',
|
||||
headers: method === 'GET' ? {} : { 'content-type': 'application/json' },
|
||||
body: method === 'GET' ? undefined : JSON.stringify(method === 'PUT' ? { enabled: true } : { confirm: true })
|
||||
body:
|
||||
method === 'GET'
|
||||
? undefined
|
||||
: JSON.stringify(method === 'PUT' ? { enabled: true } : { confirm: true })
|
||||
});
|
||||
answers.push({ route: `${method} ${path}`, status: response.status });
|
||||
}
|
||||
@@ -55,5 +503,84 @@ export default {
|
||||
`with the statistics off every statistics route must answer 404: ${answered.map((a) => `${a.route} ${a.status}`).join('; ')}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/** loaded_off: the instance operator switches the statistics off in Settings, as a person would. */
|
||||
async prepareLoadedOff(ctx) {
|
||||
const { headers } = await apiLogin(ctx);
|
||||
const response = await ctx.fetch(`${ctx.baseUrl}/api/ever-stats/enabled`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: false })
|
||||
});
|
||||
if (response.status !== 200) throw new Error(`switching the statistics off answered ${response.status}`);
|
||||
const status = await (await ctx.fetch(`${ctx.baseUrl}/api/ever-stats/status`, { headers })).json();
|
||||
if (status?.enabled !== false)
|
||||
throw new Error('the statistics still read as on after the operator switched them off');
|
||||
ctx.log('adapter: the operator switched the statistics off in Settings');
|
||||
},
|
||||
|
||||
/**
|
||||
* Browser leg: the signed-out pages first (SIGNED_OUT_ROUTES), then the real sign-in page. The web
|
||||
* app routes in the URL fragment (`/#/auth/login`): the config says `"ui_routing": "hash"`, so
|
||||
* `ctx.baseUrl` is the web app's address with `/#` and `ctx.baseUrl + route` opens a route. The
|
||||
* sign-in page is `ui_sign_in_route`, and the harness faults every route that ends on it, so a
|
||||
* walk that lost its session never passes there.
|
||||
*/
|
||||
async uiLogin(page, ctx) {
|
||||
const origin = new URL(ctx.baseUrl).origin;
|
||||
for (const [index, route] of SIGNED_OUT_ROUTES.entries()) {
|
||||
await page.goto(`${origin}/?signed-out=${index}#${route}`, { waitUntil: 'load' });
|
||||
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
ctx.log(`adapter: opened ${SIGNED_OUT_ROUTES.length} signed-out pages`);
|
||||
await page.goto(`${ctx.baseUrl}/auth/login`);
|
||||
try {
|
||||
await page.fill('#input-email', SEED_EMAIL, { timeout: 120_000 });
|
||||
await page.fill('#input-password', seedPassword(ctx));
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => /^#\/(pages|onboarding)(\/|$)/.test(new URL(url).hash), { timeout: 120_000 }),
|
||||
page.click('form button[type=submit]')
|
||||
]);
|
||||
} catch (error) {
|
||||
// Evidence of what the browser showed instead (seed data only): a screenshot and the
|
||||
// page's route and visible text.
|
||||
await page.screenshot({ path: '/out/sign-in-failed.png', fullPage: true }).catch(() => {});
|
||||
const text = await page.evaluate(() => document.body?.innerText ?? '').catch(() => '');
|
||||
ctx.log(
|
||||
`adapter: the sign-in did not complete at ${routeOf(page.url())}; the page reads: ${text.split(/\s/).filter(Boolean).join(' ').slice(0, 300)}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
ctx.log(`adapter: signed in, landed on ${routeOf(page.url())}`);
|
||||
},
|
||||
|
||||
/** Route parameters: the seeded ids where the seed has one, an unknown id elsewhere. */
|
||||
async routeParams(ctx) {
|
||||
const f = ctx.fixtures ?? {};
|
||||
return {
|
||||
id: UNKNOWN_ID,
|
||||
employeeId: f.employeeId ?? UNKNOWN_ID,
|
||||
appointmentId: UNKNOWN_ID,
|
||||
eventId: UNKNOWN_ID,
|
||||
itemId: UNKNOWN_ID,
|
||||
itemVariantId: UNKNOWN_ID,
|
||||
pipelineId: UNKNOWN_ID,
|
||||
dealId: UNKNOWN_ID,
|
||||
integrationTenantId: UNKNOWN_ID,
|
||||
organizationId: f.organizationId ?? UNKNOWN_ID,
|
||||
'/pages/users/edit/:id': { id: f.userId ?? UNKNOWN_ID },
|
||||
'/pages/users/edit/:id/location': { id: f.userId ?? UNKNOWN_ID },
|
||||
'/pages/users/edit/:id/main': { id: f.userId ?? UNKNOWN_ID },
|
||||
'/pages/users/edit/:id/organizations': { id: f.userId ?? UNKNOWN_ID },
|
||||
'/pages/users/edit/:id/settings': { id: f.userId ?? UNKNOWN_ID },
|
||||
'/pages/organizations/edit/:id': { id: f.organizationId ?? UNKNOWN_ID },
|
||||
'/pages/organizations/edit/:id/main': { id: f.organizationId ?? UNKNOWN_ID },
|
||||
'/pages/organizations/edit/:id/location': { id: f.organizationId ?? UNKNOWN_ID },
|
||||
'/pages/organizations/edit/:id/settings': { id: f.organizationId ?? UNKNOWN_ID },
|
||||
'/pages/employees/edit/:id': { id: f.employeeId ?? UNKNOWN_ID },
|
||||
'/pages/employees/view/:id': { id: f.employeeId ?? UNKNOWN_ID }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The one exception to "ui-baseline.json may only shrink": a change that makes the browser walk open
|
||||
* routes it did not open before (a newer harness or route generator), whose pages show the same
|
||||
* pre-existing links as every other page. The workflow runs this after the harness's
|
||||
* check-baseline-shrink only while BASELINE_REWALK is 'true' in that one change.
|
||||
*
|
||||
* git show origin/develop:tools/egress-audit/ui-baseline.json > base.json
|
||||
* node tools/egress-audit/check-baseline-rewalk.mjs --base-file base.json
|
||||
*
|
||||
* Passes when base_commit is unchanged and every entry added since the base is on a route the base
|
||||
* had no entry for (a route the walk did not record before) and repeats a link (same attribute and
|
||||
* URL) the base baseline already had for another route: no new Ever link can enter the baseline
|
||||
* this way, and no route walked before can gain one, except a signed-out page (`/auth/`, `/share/`,
|
||||
* `/legal/`) gaining a link the base recorded under `/`, where those pages were recorded before. Exit 0, 1 naming each entry
|
||||
* with a link the base did not have, 2 on a usage error.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const FILE = join(dirname(fileURLToPath(import.meta.url)), 'ui-baseline.json');
|
||||
|
||||
const key = (e) => `${e.route}|${e.attribute}|${e.url}`;
|
||||
const link = (e) => `${e.attribute}|${e.url}`;
|
||||
const show = (e) => `${e.route} ${e.attribute} ${e.url}`;
|
||||
|
||||
/**
|
||||
* The entries added since `base` that this exception does not cover: on a route the base already
|
||||
* had entries for (a route walked before may never gain a link), or with a link the base did not
|
||||
* have anywhere. Answers those and the count of all added.
|
||||
*/
|
||||
export function newLinks(current, base) {
|
||||
const before = new Set((base.entries ?? []).map(key));
|
||||
const links = new Set((base.entries ?? []).map(link));
|
||||
const routes = new Set((base.entries ?? []).map((e) => e.route));
|
||||
// The signed-out pages' links were recorded under the path `/`; the hash-routed walk records
|
||||
// them under each page's own route.
|
||||
const signedOut = new Set((base.entries ?? []).filter((e) => e.route === '/').map(link));
|
||||
const isSignedOutPage = (route) => /^\/(auth|share|legal)\//.test(route);
|
||||
const covered = (e) =>
|
||||
(!routes.has(e.route) && links.has(link(e))) || (isSignedOutPage(e.route) && signedOut.has(link(e)));
|
||||
const added = (current.entries ?? []).filter((e) => !before.has(key(e)));
|
||||
return { added: added.length, unknown: added.filter((e) => !covered(e)) };
|
||||
}
|
||||
|
||||
export function main(argv) {
|
||||
const i = argv.indexOf('--base-file');
|
||||
const baseFile = i >= 0 ? argv[i + 1] : null;
|
||||
if (!baseFile) {
|
||||
process.stderr.write('check-baseline-rewalk: --base-file <the baseline at the base> is required\n');
|
||||
return 2;
|
||||
}
|
||||
let base;
|
||||
let current;
|
||||
try {
|
||||
base = JSON.parse(readFileSync(resolve(baseFile), 'utf8'));
|
||||
current = JSON.parse(readFileSync(FILE, 'utf8'));
|
||||
} catch (error) {
|
||||
process.stderr.write(`check-baseline-rewalk: ${error.message}\n`);
|
||||
return 2;
|
||||
}
|
||||
if (current.base_commit !== base.base_commit) {
|
||||
process.stderr.write('check-baseline-rewalk: base_commit changed; it is fixed once the baseline exists\n');
|
||||
return 1;
|
||||
}
|
||||
const { added, unknown } = newLinks(current, base);
|
||||
if (unknown.length) {
|
||||
const list = unknown.map(show).join('\n ');
|
||||
process.stderr.write(
|
||||
'check-baseline-rewalk: entries on a route the base baseline already had, or with a link it did not have (only routes it did not record may be added, for links it already excused):\n ' +
|
||||
list +
|
||||
'\n'
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write(`check-baseline-rewalk: ok (${added} entries added, each a link the baseline already had)\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exit(main(process.argv.slice(2)));
|
||||
@@ -1,9 +1,15 @@
|
||||
# cspell:words DOTENV
|
||||
# Egress audit only (see README.md): the Gauzy API from the image under test, with its database.
|
||||
# Egress audit only (see README.md): the Gauzy API and web app from the images under test, with the
|
||||
# API's database.
|
||||
#
|
||||
# - GAUZY_API_IMAGE: the image under test (the workflow pulls it first); never pulled here.
|
||||
# - GAUZY_API_IMAGE: the API image under test (the workflow pulls it first); never pulled here.
|
||||
# - GAUZY_WEBAPP_IMAGE: the web app image under test (nginx serving the built Angular app; the
|
||||
# audit's browser opens it by its service name and it calls the API by its service name).
|
||||
# - GAUZY_AUDIT_SECRET: a random value the workflow makes per run (never committed); the production
|
||||
# image refuses to start without its token and session secrets, and to seed with known passwords.
|
||||
# image refuses to start without its token and session secrets.
|
||||
# - The seeded accounts get addresses under example.com; the Super Admin's password is made per run
|
||||
# by the adapter (the browser signs in with it). They exist only in this throw-away database, on
|
||||
# networks with no route out.
|
||||
# - GAUZY_AUDIT_DOTENV: the API's .env.local of the mode (empty, or EVER_STATS_ENABLED=false for
|
||||
# `off_env_file`), mounted in the API's working directory.
|
||||
# - PostgreSQL (throw-away, on the sealed networks): the migrations of a new database run far faster
|
||||
@@ -16,6 +22,10 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
# The web app first: its entrypoint writes the settings into the built bundle before
|
||||
# nginx serves it, and the browser walk starts as soon as the API answers.
|
||||
webapp:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DEMO: 'false'
|
||||
@@ -24,13 +34,17 @@ services:
|
||||
JWT_VERIFICATION_TOKEN_SECRET: ${GAUZY_AUDIT_SECRET}-verification
|
||||
EXPRESS_SESSION_SECRET: ${GAUZY_AUDIT_SECRET}-session
|
||||
# The first start seeds the default accounts, which production refuses with known passwords.
|
||||
DEMO_SUPER_ADMIN_EMAIL: admin@example.com
|
||||
# The audit's adapter gives each run its own (see adapter.mjs); this is the fallback.
|
||||
DEMO_SUPER_ADMIN_PASSWORD: ${GAUZY_AUDIT_SECRET}-super-admin
|
||||
DEMO_ADMIN_EMAIL: local.admin@example.com
|
||||
DEMO_ADMIN_PASSWORD: ${GAUZY_AUDIT_SECRET}-admin
|
||||
DEMO_EMPLOYEE_EMAIL: employee@example.com
|
||||
DEMO_EMPLOYEE_PASSWORD: ${GAUZY_AUDIT_SECRET}-employee
|
||||
API_HOST: 0.0.0.0
|
||||
API_PORT: '3000'
|
||||
API_BASE_URL: http://api:3000
|
||||
CLIENT_BASE_URL: http://localhost:4200
|
||||
CLIENT_BASE_URL: http://webapp:4200
|
||||
DB_TYPE: postgres
|
||||
DB_HOST: db
|
||||
DB_PORT: '5432'
|
||||
@@ -43,8 +57,23 @@ services:
|
||||
volumes:
|
||||
- ${GAUZY_AUDIT_DOTENV:?set GAUZY_AUDIT_DOTENV to the API .env.local file of this run}:/srv/gauzy/.env.local:ro
|
||||
|
||||
# The web app as it ships: only the addresses of the API and of itself are set (both by service
|
||||
# name inside the sealed networks); every other setting keeps the image's default.
|
||||
webapp:
|
||||
image: ${GAUZY_WEBAPP_IMAGE:?set GAUZY_WEBAPP_IMAGE to the web app image under test}
|
||||
pull_policy: never
|
||||
environment:
|
||||
API_BASE_URL: http://api:3000
|
||||
CLIENT_BASE_URL: http://webapp:4200
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:4200/ || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 60
|
||||
|
||||
db:
|
||||
image: postgres:18-alpine
|
||||
# The public mirror of the official image: Docker Hub limits anonymous pulls.
|
||||
image: public.ecr.aws/docker/library/postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_DB: gauzy
|
||||
POSTGRES_USER: gauzy
|
||||
|
||||
@@ -5,21 +5,88 @@
|
||||
"subnet": "10.231.7.0/24",
|
||||
"api_service": "api",
|
||||
"process_services": ["api"],
|
||||
"services": ["api"],
|
||||
"services": ["api", "webapp"],
|
||||
"health_url": "http://api:3000/api/health",
|
||||
"health_timeout_s": 1200,
|
||||
"module_routes": ["/api/ever-stats/status", "/api/ever-stats/last", "/api/ever-stats/state"],
|
||||
"adapter": "adapter.mjs",
|
||||
"wait_s": 120,
|
||||
"allowed_external_hosts": [],
|
||||
"allowed_external_hosts": [
|
||||
"fonts.googleapis.com",
|
||||
"maps.googleapis.com",
|
||||
"a.tile.openstreetmap.org",
|
||||
"b.tile.openstreetmap.org",
|
||||
"c.tile.openstreetmap.org",
|
||||
"dummyimage.com",
|
||||
"github.com"
|
||||
],
|
||||
"web_service": "webapp",
|
||||
"web_static": true,
|
||||
"web_url": "http://webapp:4200",
|
||||
"ui_routing": "hash",
|
||||
"ui_sign_in_route": "/auth/login",
|
||||
"ui_routes": "ui-routes.json",
|
||||
"route_params": "route-params.json",
|
||||
"ui_baseline": "ui-baseline.json",
|
||||
"idle_pages": ["/pages/settings/usage-statistics", "/pages/integrations", "/pages/integrations/ever-connect"],
|
||||
"ui_page_timeout_s": 90,
|
||||
"ui_skip_routes": [
|
||||
{
|
||||
"path": "/auth/logout",
|
||||
"reason": "signs the walk out; the sign-in page itself is opened by the sign-in"
|
||||
}
|
||||
],
|
||||
"ui_expected_requests": {
|
||||
"positive_stats": ["GET /api/ever-stats/status"]
|
||||
},
|
||||
"modes": {
|
||||
"off_env_file": {
|
||||
"description": "Statistics switched off ONLY in the API's .env.local file (in the API's working directory), nothing about them in the container environment: the module is not loaded, no call, no Ever host looked up, every module route 404.",
|
||||
"env": { "EVER_STATS_SEND_INTERVAL_S": "5" },
|
||||
"env": {
|
||||
"EVER_STATS_SEND_INTERVAL_S": "5"
|
||||
},
|
||||
"mock": false,
|
||||
"module_routes": "404",
|
||||
"allowed_rows": [],
|
||||
"required_rows": []
|
||||
},
|
||||
"connect_off_sign_in_on": {
|
||||
"description": "The connection off (EVER_CONNECT_ENABLED unset) and the Ever ID sign-in plugin on (ZITADEL_ENABLED=true) with an issuer on an Ever host on a self-hosted installation: the plugin leaves that issuer unused, so nothing is looked up or sent, in the API or the browser; every statistics route 404.",
|
||||
"env": {
|
||||
"EVER_CONNECT_ENABLED": null,
|
||||
"EVER_STATS_ENABLED": "false",
|
||||
"EVER_STATS_SEND_INTERVAL_S": "5",
|
||||
"ZITADEL_ENABLED": "true"
|
||||
},
|
||||
"mock": false,
|
||||
"prepare": "probeSignIn",
|
||||
"module_routes": "404",
|
||||
"browser": "negative",
|
||||
"allowed_rows": [],
|
||||
"required_rows": []
|
||||
},
|
||||
"entitlement_ladder": {
|
||||
"description": "The connection on against the mock platform, with entitlement documents dated so the stored one is in grace, then paused, one issued in the future (refused, the stored one kept) and one slightly ahead (accepted): the ladder follows each, Gauzy's own routes answer the same at every step, and only the connection's documented calls are made.",
|
||||
"env": {
|
||||
"EVER_CONNECT_ENABLED": "true",
|
||||
"EVER_CONNECT_CODE": "EVC-TEST-0000-0001",
|
||||
"EVER_PLATFORM_API_URL": "__MOCK_URL__",
|
||||
"EVER_PLATFORM_ISSUER": "__MOCK_ISSUER__",
|
||||
"EVER_STATS_ENABLED": "false",
|
||||
"EVER_PLATFORM_ROOT_KEYS_FILE": "/ever-audit/roots.json"
|
||||
},
|
||||
"mock": true,
|
||||
"mock_config": {
|
||||
"limits": {
|
||||
"entitlement_reads_per_hour": 60
|
||||
}
|
||||
},
|
||||
"trigger": "entitlementLadder",
|
||||
"module_routes": "any",
|
||||
"allowed_rows": [1, 3, 4, 6, 7, 8, 9],
|
||||
"required_rows": [1, 3, 4, 8]
|
||||
}
|
||||
}
|
||||
},
|
||||
"phase": 2,
|
||||
"every_trigger_exclude_rows": [2, 14, 18, 19, 20, 22, 23, 25, 27, 30, 33]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// A known-bad fixture for static-hostnames.test.mjs: core code calling Ever Platform by itself.
|
||||
export const platform = process.env.EVER_PLATFORM_API_URL ?? 'https://api.ever.co';
|
||||
Generated
+1607
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@gauzy/egress-audit-inputs",
|
||||
"private": true,
|
||||
"description": "The egress audit harness for this directory's config, and the ESLint the import-boundary test runs its rule with (installed by .github/workflows/egress-audit.yml with npm ci; not a workspace).",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@ever-co/connect-tools": "1.0.0-rc.6",
|
||||
"@typescript-eslint/parser": "8.69.0",
|
||||
"eslint": "9.39.2",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"type": "invoice",
|
||||
"token": "egress-audit",
|
||||
"profileLink": "egress-audit",
|
||||
"slug": "egress-audit"
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
{
|
||||
"description": "Ever hosts (and Ever Platform base URL variables) that Gauzy code outside the Ever Platform modules named before the modules: the product site, legal pages, documentation, demo accounts, downloads. Written by `node tools/egress-audit/static-hostnames.mjs --write`; it may only shrink (checked against the base of every pull request). `code`: per tracked file and host, the occurrences; `bundle`: the hosts the built web app names.",
|
||||
"code": {
|
||||
".deploy/api/Dockerfile": {
|
||||
"ever.co": 1,
|
||||
"packages.ever.co": 13
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.civo.demo.yaml": {
|
||||
"apidemocivo.gauzy.co": 4,
|
||||
"democivo.gauzy.co": 4
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.civo.prod.yaml": {
|
||||
"apicivo.gauzy.co": 4,
|
||||
"appcivo.gauzy.co": 4
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.civo.stage.yaml": {
|
||||
"apistagecivo.gauzy.co": 4,
|
||||
"stagecivo.gauzy.co": 4
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.cw.demo.yaml": {
|
||||
"apidemocw.gauzy.co": 4,
|
||||
"democw.gauzy.co": 4
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.cw.prod.yaml": {
|
||||
"apicw.gauzy.co": 4,
|
||||
"appcw.gauzy.co": 4
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.cw.stage.yaml": {
|
||||
"apistagecw.gauzy.co": 4,
|
||||
"stagecw.gauzy.co": 4
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.demo.yaml": {
|
||||
"apidemo.gauzy.co": 5,
|
||||
"demo.gauzy.co": 5
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.mcp-auth.demo.yaml": {
|
||||
"mcpauthdemo.gauzy.co": 2
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.mcp-auth.prod.yaml": {
|
||||
"mcpauth.gauzy.co": 2
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.mcp-auth.stage.yaml": {
|
||||
"mcpauthstage.gauzy.co": 2
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.mcp.demo.yaml": {
|
||||
"mcpdemo.gauzy.co": 2
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.mcp.prod.yaml": {
|
||||
"mcp.gauzy.co": 2
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.mcp.stage.yaml": {
|
||||
"mcpstage.gauzy.co": 2
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.prod.yaml": {
|
||||
"api.gauzy.co": 5,
|
||||
"app.gauzy.co": 5,
|
||||
"pm-space.gauzy.co": 2,
|
||||
"pm.gauzy.co": 2
|
||||
},
|
||||
".deploy/k8s/k8s-manifest.stage.yaml": {
|
||||
"apistage.gauzy.co": 5,
|
||||
"stage.gauzy.co": 5
|
||||
},
|
||||
".deploy/mcp-auth/Dockerfile": {
|
||||
"ever.co": 1,
|
||||
"packages.ever.co": 12
|
||||
},
|
||||
".deploy/mcp/Dockerfile": {
|
||||
"apidemo.gauzy.co": 1,
|
||||
"auth.gauzy.co": 3,
|
||||
"ever.co": 1,
|
||||
"mcp.gauzy.co": 1,
|
||||
"mcpdemo.gauzy.co": 1,
|
||||
"packages.ever.co": 12
|
||||
},
|
||||
".deploy/ssh/with-cloudflare/demo/nginx.demo.pre.cloudflare.conf": {
|
||||
"apidemodt.gauzy.co": 2
|
||||
},
|
||||
".deploy/ssh/with-cloudflare/prod/nginx.prod.pre.cloudflare.conf": {
|
||||
"apidt.gauzy.co": 2
|
||||
},
|
||||
".deploy/ssh/with-cloudflare/stage/nginx.stage.pre.cloudflare.conf": {
|
||||
"apistagedt.gauzy.co": 2
|
||||
},
|
||||
".deploy/ssh/with-letsencrypt/demo/docker-compose.api.demo.letsencrypt.pre.yml": {
|
||||
"ever.co": 1
|
||||
},
|
||||
".deploy/ssh/with-letsencrypt/demo/nginx-certbot.env": {
|
||||
"ever.co": 1
|
||||
},
|
||||
".deploy/ssh/with-letsencrypt/demo/user_conf.d/nginx.conf": {
|
||||
"apidemodts.gauzy.co": 4
|
||||
},
|
||||
".deploy/ssh/with-letsencrypt/prod/docker-compose.api.prod.letsencrypt.pre.yml": {
|
||||
"ever.co": 1
|
||||
},
|
||||
".deploy/ssh/with-letsencrypt/prod/nginx-certbot.env": {
|
||||
"ever.co": 1
|
||||
},
|
||||
".deploy/ssh/with-letsencrypt/prod/user_conf.d/nginx.conf": {
|
||||
"apidts.gauzy.co": 4
|
||||
},
|
||||
".deploy/ssh/with-letsencrypt/stage/docker-compose.api.stage.letsencrypt.pre.yml": {
|
||||
"ever.co": 1
|
||||
},
|
||||
".deploy/ssh/with-letsencrypt/stage/nginx-certbot.env": {
|
||||
"ever.co": 1
|
||||
},
|
||||
".deploy/ssh/with-letsencrypt/stage/user_conf.d/nginx.conf": {
|
||||
"apistagedts.gauzy.co": 4
|
||||
},
|
||||
".deploy/webapp/Dockerfile": {
|
||||
"api.gauzy.co": 1,
|
||||
"app.gauzy.co": 1,
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 10,
|
||||
"packages.ever.co": 7
|
||||
},
|
||||
".deploy/worker/Dockerfile": {
|
||||
"ever.co": 1,
|
||||
"packages.ever.co": 13
|
||||
},
|
||||
".env.compose": {
|
||||
"api.gauzy.co": 1,
|
||||
"app.gauzy.co": 3,
|
||||
"auth.gauzy.co": 9,
|
||||
"ever.co": 6,
|
||||
"gauzy.co": 12,
|
||||
"mcp.gauzy.co": 3,
|
||||
"mcpauth.gauzy.co": 1
|
||||
},
|
||||
".env.demo.compose": {
|
||||
"api.gauzy.co": 1,
|
||||
"app.gauzy.co": 3,
|
||||
"authdemo.gauzy.co": 8,
|
||||
"ever.co": 3,
|
||||
"gauzy.co": 12,
|
||||
"mcpauthdemo.gauzy.co": 1,
|
||||
"mcpdemo.gauzy.co": 3
|
||||
},
|
||||
".env.docker": {
|
||||
"api.gauzy.co": 1,
|
||||
"app.gauzy.co": 3,
|
||||
"ever.co": 3,
|
||||
"gauzy.co": 12
|
||||
},
|
||||
".env.local": {
|
||||
"app.gauzy.co": 2,
|
||||
"ever.co": 3,
|
||||
"gauzy.co": 11
|
||||
},
|
||||
".env.sample": {
|
||||
"api.ever.co": 4,
|
||||
"api.gauzy.co": 1,
|
||||
"app.ever.co": 3,
|
||||
"app.ever.team": 1,
|
||||
"app.gauzy.co": 4,
|
||||
"EVER_PLATFORM_API_URL": 2,
|
||||
"EVER_STATS_API_URL": 2,
|
||||
"ever.co": 7,
|
||||
"gauzy.co": 12
|
||||
},
|
||||
".github/ISSUE_TEMPLATE/config.yml": {
|
||||
"docs.gauzy.co": 1
|
||||
},
|
||||
".github/actions/configure-registry/action.yml": {
|
||||
"packages.ever.co": 10
|
||||
},
|
||||
".github/workflows/agent-demo.yml": {
|
||||
"gauzy.co": 2,
|
||||
"packages.ever.co": 1
|
||||
},
|
||||
".github/workflows/agent-prod.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/agent-stage.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/deploy-do-app-platform-demo.yml": {
|
||||
"apidemoda.gauzy.co": 2,
|
||||
"demo.gauzy.co": 1
|
||||
},
|
||||
".github/workflows/deploy-do-app-platform-prod.yml": {
|
||||
"apida.gauzy.co": 2,
|
||||
"app.gauzy.co": 1
|
||||
},
|
||||
".github/workflows/deploy-do-app-platform-stage.yml": {
|
||||
"apistageda.gauzy.co": 2,
|
||||
"stage.gauzy.co": 1
|
||||
},
|
||||
".github/workflows/deploy-do-droplet-demo.yml": {
|
||||
"apidemodt.gauzy.co": 1,
|
||||
"apidemodts.gauzy.co": 1,
|
||||
"demo.gauzy.co": 1
|
||||
},
|
||||
".github/workflows/deploy-do-droplet-prod.yml": {
|
||||
"apidt.gauzy.co": 1,
|
||||
"apidts.gauzy.co": 1,
|
||||
"app.gauzy.co": 1
|
||||
},
|
||||
".github/workflows/deploy-do-droplet-stage.yml": {
|
||||
"apistagedt.gauzy.co": 1,
|
||||
"apistagedts.gauzy.co": 1,
|
||||
"stage.gauzy.co": 1
|
||||
},
|
||||
".github/workflows/desktop-app-demo.yml": {
|
||||
"gauzy.co": 2,
|
||||
"packages.ever.co": 1
|
||||
},
|
||||
".github/workflows/desktop-app-prod.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/desktop-app-stage.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/desktop-timer-app-demo.yml": {
|
||||
"gauzy.co": 2,
|
||||
"packages.ever.co": 1
|
||||
},
|
||||
".github/workflows/desktop-timer-app-prod.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/desktop-timer-app-stage.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/external-uptime-monitor.yml": {
|
||||
"api.gauzy.co": 2,
|
||||
"apistage.gauzy.co": 1,
|
||||
"app.gauzy.co": 2,
|
||||
"gauzy.co": 2,
|
||||
"stage.gauzy.co": 1
|
||||
},
|
||||
".github/workflows/mirror-releases-to-r2.yml": {
|
||||
"cdn.ever.co": 1,
|
||||
"downloads.ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
".github/workflows/server-api-demo.yml": {
|
||||
"gauzy.co": 2,
|
||||
"packages.ever.co": 1
|
||||
},
|
||||
".github/workflows/server-api-prod.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/server-api-stage.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/server-demo.yml": {
|
||||
"gauzy.co": 2,
|
||||
"packages.ever.co": 1
|
||||
},
|
||||
".github/workflows/server-mcp-demo.yml": {
|
||||
"gauzy.co": 2,
|
||||
"packages.ever.co": 1
|
||||
},
|
||||
".github/workflows/server-mcp-prod.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/server-mcp-stage.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/server-prod.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".github/workflows/server-stage.yml": {
|
||||
"gauzy.co": 5,
|
||||
"packages.ever.co": 5
|
||||
},
|
||||
".scripts/env.ts": {
|
||||
"app.gauzy.co": 3,
|
||||
"ever.co": 4,
|
||||
"gauzy.co": 10
|
||||
},
|
||||
"apps/agent/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/agent/src/main/init/menu.ts": {
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/agent/src/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/api/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/desktop-api/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/desktop-timer/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/desktop-timer/src/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/desktop/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/desktop/src/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/gauzy/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/gauzy/proxy.demo.conf.json": {
|
||||
"apidemo.gauzy.co": 1,
|
||||
"demo.gauzy.co": 2
|
||||
},
|
||||
"apps/gauzy/src/app/pages/about/about.component.html": {
|
||||
"docs.gauzy.co": 2,
|
||||
"ever.co": 1,
|
||||
"gauzy.co": 2
|
||||
},
|
||||
"apps/gauzy/src/app/pages/help/help.component.html": {
|
||||
"docs.gauzy.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/mcp-auth/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/mcp/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/server-api/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/server-api/src/index.ts": {
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/server-api/src/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/server-mcp/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/server-mcp/src/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/server/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/server/src/index.ts": {
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/server/src/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"apps/worker/package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"okteto-pipeline.yml": {
|
||||
"docs.ever.co": 1
|
||||
},
|
||||
"package.json": {
|
||||
"ever.co": 2,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"packages/auth/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/common/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/config/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/config/src/lib/environments/environment.helper.ts": {
|
||||
"app.gauzy.co": 1
|
||||
},
|
||||
"packages/config/src/lib/environments/environment.prod.ts": {
|
||||
"api.gauzy.co": 1,
|
||||
"app.gauzy.co": 2,
|
||||
"ever.co": 4
|
||||
},
|
||||
"packages/config/src/lib/environments/environment.ts": {
|
||||
"ever.co": 4
|
||||
},
|
||||
"packages/constants/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/contracts/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/core/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/core/src/lib/app/app.service.ts": {
|
||||
"ever.co": 1
|
||||
},
|
||||
"packages/core/src/lib/auth/auth.service.ts": {
|
||||
"ever.co": 2
|
||||
},
|
||||
"packages/core/src/lib/auth/login-attempt.service.ts": {
|
||||
"ever.co": 2
|
||||
},
|
||||
"packages/core/src/lib/bootstrap/index.ts": {
|
||||
"app.gauzy.co": 1
|
||||
},
|
||||
"packages/core/src/lib/bootstrap/swagger.ts": {
|
||||
"api.gauzy.co": 1,
|
||||
"apidemo.gauzy.co": 1,
|
||||
"docs.gauzy.co": 1,
|
||||
"ever.co": 2
|
||||
},
|
||||
"packages/core/src/lib/bootstrap/validate-secrets.ts": {
|
||||
"ever.co": 5
|
||||
},
|
||||
"packages/core/src/lib/database/migrations/1790000005000-RefreshChangelogContent.ts": {
|
||||
"docs.gauzy.co": 5,
|
||||
"gauzy.co": 2
|
||||
},
|
||||
"packages/core/src/lib/email-send/email-send.service.ts": {
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"packages/core/src/lib/shared/billing/billing-product.ts": {
|
||||
"app.gauzy.co": 1,
|
||||
"ever.co": 2
|
||||
},
|
||||
"packages/core/src/lib/shared/billing/billing.controller.ts": {
|
||||
"app.gauzy.co": 1
|
||||
},
|
||||
"packages/core/src/lib/shared/billing/stripe-subscription.service.ts": {
|
||||
"app.gauzy.co": 2,
|
||||
"demo.gauzy.co": 1,
|
||||
"ever.co": 1,
|
||||
"stage.gauzy.co": 1
|
||||
},
|
||||
"packages/core/src/lib/shared/guards/subscription-required.guard.ts": {
|
||||
"ever.co": 2
|
||||
},
|
||||
"packages/core/src/lib/user/dto/register-user.dto.ts": {
|
||||
"ever.co": 1
|
||||
},
|
||||
"packages/core/src/lib/user/user.service.ts": {
|
||||
"ever.co": 2
|
||||
},
|
||||
"packages/desktop-activity/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/desktop-core/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/desktop-lib/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/desktop-lib/src/lib/desktop-menu.ts": {
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"packages/desktop-lib/src/lib/desktop-store.ts": {
|
||||
"api.gauzy.co": 2
|
||||
},
|
||||
"packages/desktop-ui-lib/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.html": {
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"packages/desktop-ui-lib/src/lib/login/login.component.ts": {
|
||||
"app.gauzy.co": 2
|
||||
},
|
||||
"packages/desktop-ui-lib/src/lib/settings/settings.component.ts": {
|
||||
"api.gauzy.co": 3,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"packages/desktop-ui-lib/src/lib/setup/setup.component.ts": {
|
||||
"api.gauzy.co": 3
|
||||
},
|
||||
"packages/desktop-window/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/mcp-server/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/mcp-server/src/lib/common/security-config.ts": {
|
||||
"api.gauzy.co": 1,
|
||||
"apidemo.gauzy.co": 1,
|
||||
"apistage.gauzy.co": 1,
|
||||
"app.gauzy.co": 1,
|
||||
"demo.gauzy.co": 1,
|
||||
"stage.gauzy.co": 1
|
||||
},
|
||||
"packages/plugin-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugin/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-chat-react-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-chat/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-anthropic/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-deepgram/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-elevenlabs/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-gauzy-ai/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-gemini/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-grok/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-groq/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-localai/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-mistral/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-openai-compatible/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-openai/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-openrouter/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-speaches/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-vercel-gateway/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/ai-provider-whisper-cpp/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/auth-keycloak/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/auth-zitadel-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/auth-zitadel/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/auth-zitadel/src/lib/auth-zitadel.config.ts": {
|
||||
"ever.co": 2
|
||||
},
|
||||
"packages/plugins/camshot/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/changelog/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/changelog/src/lib/initial-changelog-template.ts": {
|
||||
"docs.gauzy.co": 5,
|
||||
"gauzy.co": 2
|
||||
},
|
||||
"packages/plugins/dashboard-time-track-angular-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/dashboard-time-track-react-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/docs-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/docs/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/docs/src/lib/knowledge/extraction/__fixtures__/docx.fixture.ts": {
|
||||
"ever.co": 1
|
||||
},
|
||||
"packages/plugins/docs/src/lib/knowledge/extraction/__fixtures__/odf.fixture.ts": {
|
||||
"ever.co": 1
|
||||
},
|
||||
"packages/plugins/docs/src/lib/knowledge/extraction/__fixtures__/text.fixture.ts": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/docs/src/lib/knowledge/extraction/__fixtures__/xlsx.fixture.ts": {
|
||||
"ever.co": 1
|
||||
},
|
||||
"packages/plugins/integration-activepieces-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-activepieces/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-ai-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-ai/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-ever-async-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.html": {
|
||||
"docs-async.ever.co": 1
|
||||
},
|
||||
"packages/plugins/integration-ever-async-ui/src/lib/components/ever-async-connect/ever-async-connect.component.ts": {
|
||||
"api-async.ever.co": 2
|
||||
},
|
||||
"packages/plugins/integration-ever-async/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-ever-async/src/lib/dto/configure-ever-async-integration.dto.ts": {
|
||||
"api-async.ever.co": 1
|
||||
},
|
||||
"packages/plugins/integration-ever-async/src/lib/dto/verify-ever-async-connection.dto.ts": {
|
||||
"api-async.ever.co": 1
|
||||
},
|
||||
"packages/plugins/integration-github-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-github/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-github/src/lib/github/github.module.ts": {
|
||||
"api.gauzy.co": 1
|
||||
},
|
||||
"packages/plugins/integration-hubstaff-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-hubstaff/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-jira/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-make-com-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-make-com/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-plane-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-plane-ui/src/lib/components/plane-authorize/plane-authorize.component.ts": {
|
||||
"pm-space.gauzy.co": 1,
|
||||
"pm.gauzy.co": 1
|
||||
},
|
||||
"packages/plugins/integration-plane-ui/src/lib/components/plane-settings/plane-settings.component.ts": {
|
||||
"pm.gauzy.co": 1
|
||||
},
|
||||
"packages/plugins/integration-plane/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-plane/src/lib/plane-integration.service.ts": {
|
||||
"pm-space.gauzy.co": 1,
|
||||
"pm.gauzy.co": 1
|
||||
},
|
||||
"packages/plugins/integration-sim-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-sim/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-upwork-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-upwork/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-wakatime/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-zapier-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/integration-zapier/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/jitsu-analytics/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/job-employee-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/job-matching-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/job-proposal-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/job-proposal/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/job-search-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/job-search/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/jobs-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/knowledge-base/package.json": {
|
||||
"ever.co": 2
|
||||
},
|
||||
"packages/plugins/legal-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/legal-ui/scripts/sync-legal-content.mjs": {
|
||||
"ever.co": 1,
|
||||
"gauzy.co": 4
|
||||
},
|
||||
"packages/plugins/legal-ui/src/lib/content/cookies.en.generated.ts": {
|
||||
"ever.co": 7,
|
||||
"gauzy.co": 14
|
||||
},
|
||||
"packages/plugins/legal-ui/src/lib/content/index.ts": {
|
||||
"ever.co": 1
|
||||
},
|
||||
"packages/plugins/legal-ui/src/lib/content/privacy.en.generated.ts": {
|
||||
"api.gauzy.co": 1,
|
||||
"app.gauzy.co": 3,
|
||||
"docs.gauzy.co": 1,
|
||||
"ever.co": 21,
|
||||
"gauzy.co": 35
|
||||
},
|
||||
"packages/plugins/legal-ui/src/lib/content/tos.en.generated.ts": {
|
||||
"ever.co": 22,
|
||||
"gauzy.co": 21
|
||||
},
|
||||
"packages/plugins/legal-ui/src/lib/models/legal-document.model.ts": {
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"packages/plugins/maintenance-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/onboarding-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/posthog-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/posthog/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/product-reviews/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/public-layout-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/public-layout-ui/src/lib/components/workspace-actions/shared/email-code-form/email-code-form.component.ts": {
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"packages/plugins/registry/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/sentry-tracing/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/soundshot/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/videos-ui/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/plugins/videos/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/ui-auth/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/ui-auth/src/lib/components/login-magic/login-magic.component.html": {
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"packages/ui-auth/src/lib/components/login/login.component.html": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/ui-auth/src/lib/components/register/register.component.ts": {
|
||||
"ever.co": 1
|
||||
},
|
||||
"packages/ui-config/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/ui-core/core/src/lib/auth/cookie-helper.ts": {
|
||||
"app.gauzy.co": 2,
|
||||
"demo.gauzy.co": 2,
|
||||
"gauzy.co": 1,
|
||||
"staging.gauzy.co": 1
|
||||
},
|
||||
"packages/ui-core/core/src/lib/services/auth/checkout-session.ts": {
|
||||
"ever.co": 2
|
||||
},
|
||||
"packages/ui-core/core/src/lib/services/auth/register-error.ts": {
|
||||
"app.gauzy.co": 1
|
||||
},
|
||||
"packages/ui-core/core/src/lib/services/page/page-route-registry.service.ts": {
|
||||
"demo.gauzy.co": 1
|
||||
},
|
||||
"packages/ui-core/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/ui-core/theme/src/lib/components/header/header.component.html": {
|
||||
"app.gauzy.co": 1
|
||||
},
|
||||
"packages/ui-core/theme/src/lib/components/theme-sidebar/theme-settings/theme-settings.component.ts": {
|
||||
"docs.gauzy.co": 1
|
||||
},
|
||||
"packages/ui-react-components/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/ui-react/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"packages/utils/package.json": {
|
||||
"ever.co": 3
|
||||
},
|
||||
"scripts/mirror-releases-to-r2.mjs": {
|
||||
"downloads.ever.co": 3,
|
||||
"gauzy.co": 1
|
||||
},
|
||||
"tools/scripts/legacy-rich-text-audit.ts": {
|
||||
"ever.co": 1
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"image": "ghcr.io/ever-co/gauzy-webapp-demo@sha256:b7ad958da853ce0947ac5f56a3d7fcbff20b12c500ee9841755512b3748266e5",
|
||||
"hosts": [
|
||||
"EVER_STATS_API_URL",
|
||||
"api-async.ever.co",
|
||||
"api.gauzy.co",
|
||||
"app.ever.co",
|
||||
"app.gauzy.co",
|
||||
"demo.gauzy.co",
|
||||
"docs-async.ever.co",
|
||||
"docs.gauzy.co",
|
||||
"ever.co",
|
||||
"gauzy.co",
|
||||
"pm-space.gauzy.co",
|
||||
"pm.gauzy.co",
|
||||
"staging.gauzy.co"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env node
|
||||
// cspell:words gzip
|
||||
/**
|
||||
* The static half of the egress audit: no code outside the Ever Platform modules names an Ever host
|
||||
* or an Ever Platform base URL variable it did not name before the modules, in the sources and in
|
||||
* the built web app.
|
||||
*
|
||||
* Ever hosts are the `ever_owned` names of the harness's never-allowed list (`ever-hosts.json` of
|
||||
* `@ever-co/connect-tools`, installed here with `npm ci`) and every name under them. Gauzy names
|
||||
* many of them on purpose (the product site, legal pages, documentation, demo accounts, download
|
||||
* links): those occurrences are recorded in `static-hostnames.baseline.json`, which may only shrink.
|
||||
*
|
||||
* node tools/egress-audit/static-hostnames.mjs
|
||||
* the files git tracks, outside the module directories (ALLOWED below), against the `code`
|
||||
* part of the baseline: per file and host, never more occurrences than recorded
|
||||
* node tools/egress-audit/static-hostnames.mjs --bundle <dir>
|
||||
* every text file of a built web app (the `/srv/gauzy` of the web app image), against the
|
||||
* `bundle` part: never a host that is not recorded (file names and counts change with every
|
||||
* build; a new host does not)
|
||||
* node tools/egress-audit/static-hostnames.mjs --check-shrink <file>
|
||||
* the baseline only lost entries (or occurrences) since the base of the change, <file>
|
||||
* being the baseline at the base (git show <base>:tools/egress-audit/static-hostnames.baseline.json)
|
||||
* node tools/egress-audit/static-hostnames.mjs --write [--bundle <dir>] [--first-version]
|
||||
* rewrites the baseline from the tree (or the bundle); refuses to grow it unless
|
||||
* --first-version (the change that adds the baseline)
|
||||
* node tools/egress-audit/static-hostnames.mjs --fixture <dir>
|
||||
* scans <dir> as a tree of its own with an empty baseline (the known-bad fixture)
|
||||
*
|
||||
* Exit 0 when clean, 1 naming every new occurrence, 2 on a usage error.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { hostsInText, isEverOwned, loadEverHosts, normaliseHost } from '@ever-co/connect-tools/egress-audit/hosts';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
export const REPO_ROOT = resolve(HERE, '..', '..');
|
||||
export const BASELINE_FILE = join(HERE, 'static-hostnames.baseline.json');
|
||||
|
||||
/**
|
||||
* Where an Ever host may be named: the modules themselves, their docs, the audit and its checks,
|
||||
* prose (Markdown) and tests. Everything else is core code.
|
||||
*/
|
||||
export const ALLOWED = [
|
||||
'packages/plugins/ever-connect/',
|
||||
'packages/plugins/ever-connect-ui/',
|
||||
'packages/plugins/ever-stats/',
|
||||
'packages/plugins/ever-stats-ui/',
|
||||
'packages/plugins/ever-instance/',
|
||||
'docs/ever-platform/',
|
||||
'tools/egress-audit/',
|
||||
'tools/ever-platform/',
|
||||
'**/*.md',
|
||||
'**/*.spec.ts',
|
||||
'**/*.e2e-spec.ts',
|
||||
'**/*.test.*',
|
||||
'apps/gauzy-e2e/'
|
||||
];
|
||||
|
||||
/** The Ever Platform base URL variables: only the modules read them. */
|
||||
export const VARIABLES = ['EVER_PLATFORM_API_URL', 'EVER_STATS_API_URL'];
|
||||
|
||||
/** Files that hold code or configuration a build or a container reads. */
|
||||
const TEXT =
|
||||
/\.(m?[jt]sx?|cjs|cts|mts|json|ya?ml|html?|s?css|less|hbs|mjml|ejs|env|sample|example|conf|toml|xml|sh|webmanifest)$|(^|\/)(Dockerfile[^/]*|\.env[^/]*)$/;
|
||||
|
||||
/** Lock files name package registries, never a host the product calls. */
|
||||
const SKIP = /(^|\/)(yarn\.lock|package-lock\.json|pnpm-lock\.yaml)$/;
|
||||
|
||||
/** A minimal glob: `dir/` prefixes and `**\/*.ext` suffix patterns. */
|
||||
export function allowed(file, patterns = ALLOWED) {
|
||||
return patterns.some((p) => {
|
||||
if (p.startsWith('**/')) {
|
||||
const quote = (s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
const tail = p.slice(3).split('*').map(quote).join('[^/]*');
|
||||
return new RegExp(`(^|/)${tail}$`).test(file);
|
||||
}
|
||||
return p.endsWith('/') ? file.startsWith(p) : file === p;
|
||||
});
|
||||
}
|
||||
|
||||
/** The Ever hosts and base URL variables a text names, with their counts. */
|
||||
export function occurrences(text, lists = loadEverHosts()) {
|
||||
let counts = {};
|
||||
const add = (key) => (counts[key] = (counts[key] ?? 0) + 1);
|
||||
for (const host of hostsInText(text)) if (isEverOwned(host, lists)) add(normaliseHost(host));
|
||||
for (const variable of VARIABLES)
|
||||
for (let n = text.split(new RegExp(String.raw`\b${variable}\b`)).length - 1; n > 0; n--) add(variable);
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Code-unit order (what Array#sort does without a compare function), stated explicitly. */
|
||||
export function byCodeUnit(a, b) {
|
||||
if (a < b) return -1;
|
||||
if (a > b) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Directories that hold no source of the product: dependencies, build output, caches. */
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'out-tsc',
|
||||
'coverage',
|
||||
'.angular',
|
||||
'.nx',
|
||||
'.cache',
|
||||
'tmp'
|
||||
]);
|
||||
|
||||
/** Every file under `root`, outside SKIP_DIRS (in CI the checkout holds exactly the tracked files). */
|
||||
function walk(root, skip = SKIP_DIRS) {
|
||||
const out = [];
|
||||
const visit = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (skip.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) visit(p);
|
||||
else out.push(relative(root, p).split(sep).join('/'));
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return out.sort(byCodeUnit);
|
||||
}
|
||||
|
||||
/** `{file: {host: count}}` of the core code under `root` (outside ALLOWED). */
|
||||
export function scanCode(root, { patterns = ALLOWED, files = walk(root) } = {}) {
|
||||
const lists = loadEverHosts();
|
||||
const found = {};
|
||||
for (const file of files) {
|
||||
if (!TEXT.test(file) || SKIP.test(file) || allowed(file, patterns)) continue;
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(join(root, file), 'utf8');
|
||||
} catch {
|
||||
continue; // a file deleted in the working copy
|
||||
}
|
||||
const counts = occurrences(text, lists);
|
||||
if (Object.keys(counts).length) found[file] = counts;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The sorted Ever hosts and variables named anywhere in a built web app. */
|
||||
export function scanBundle(dir) {
|
||||
const lists = loadEverHosts();
|
||||
const hosts = new Set();
|
||||
for (const file of walk(dir, new Set())) {
|
||||
if (!/\.(m?js|html?|css|json|txt|webmanifest|svg)$/.test(file)) continue;
|
||||
for (const key of Object.keys(occurrences(readFileSync(join(dir, file), 'utf8'), lists))) hosts.add(key);
|
||||
}
|
||||
return [...hosts].sort(byCodeUnit);
|
||||
}
|
||||
|
||||
/** What `found` has beyond `baseline` (both `{file: {host: count}}`): `file host now (recorded n)`. */
|
||||
export function codeGrowth(found, baseline = {}) {
|
||||
const out = [];
|
||||
for (const [file, counts] of Object.entries(found))
|
||||
for (const [host, count] of Object.entries(counts)) {
|
||||
const recorded = baseline[file]?.[host] ?? 0;
|
||||
if (count > recorded) out.push(`${file}: ${host} x${count} (recorded: ${recorded})`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The hosts of `found` that `recorded` does not have. */
|
||||
export const bundleGrowth = (found, recorded = []) => found.filter((host) => !recorded.includes(host));
|
||||
|
||||
export function readBaseline(file = BASELINE_FILE) {
|
||||
if (!existsSync(file)) return { code: {}, bundle: { hosts: [] } };
|
||||
const data = JSON.parse(readFileSync(file, 'utf8'));
|
||||
return { ...data, code: data.code ?? {}, bundle: { hosts: data.bundle?.hosts ?? [], ...data.bundle } };
|
||||
}
|
||||
|
||||
const DESCRIPTION =
|
||||
'Ever hosts (and Ever Platform base URL variables) that Gauzy code outside the Ever Platform modules named before the modules: the product site, legal pages, documentation, demo accounts, downloads. Written by `node tools/egress-audit/static-hostnames.mjs --write`; it may only shrink (checked against the base of every pull request). `code`: per tracked file and host, the occurrences; `bundle`: the hosts the built web app names.';
|
||||
|
||||
function writeBaseline(next, file = BASELINE_FILE) {
|
||||
const sorted = Object.fromEntries(
|
||||
Object.keys(next.code)
|
||||
.sort(byCodeUnit)
|
||||
.map((f) => [f, Object.fromEntries(Object.entries(next.code[f]).sort(([a], [b]) => byCodeUnit(a, b)))])
|
||||
);
|
||||
const body = {
|
||||
description: DESCRIPTION,
|
||||
code: sorted,
|
||||
bundle: {
|
||||
...(next.bundle.image ? { image: next.bundle.image } : {}),
|
||||
hosts: [...next.bundle.hosts].sort(byCodeUnit)
|
||||
}
|
||||
};
|
||||
writeFileSync(file, `${JSON.stringify(body, null, '\t')}\n`);
|
||||
}
|
||||
|
||||
export function main(
|
||||
argv,
|
||||
{ log = (s) => process.stdout.write(`${s}\n`), err = (s) => process.stderr.write(`${s}\n`) } = {}
|
||||
) {
|
||||
const arg = (name) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 ? argv[i + 1] : undefined;
|
||||
};
|
||||
const flag = (name) => argv.includes(`--${name}`);
|
||||
const root = resolve(arg('root') ?? REPO_ROOT);
|
||||
|
||||
if (flag('fixture')) {
|
||||
const dir = resolve(arg('fixture') ?? '');
|
||||
const growth = codeGrowth(scanCode(dir, { patterns: [], files: walk(dir) }), {});
|
||||
if (growth.length) {
|
||||
err(`static-hostnames: Ever hosts outside the Ever Platform modules:\n ${growth.join('\n ')}`);
|
||||
return 1;
|
||||
}
|
||||
log('static-hostnames: ok (fixture)');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const baseline = readBaseline();
|
||||
|
||||
if (flag('check-shrink')) {
|
||||
// The baseline as it is at the base of the change (`git show <base>:<path> > file`); a missing
|
||||
// or empty file means the baseline is new.
|
||||
const baseFile = arg('check-shrink');
|
||||
if (!baseFile) {
|
||||
err('static-hostnames: --check-shrink <the baseline file at the base>');
|
||||
return 2;
|
||||
}
|
||||
let before = null;
|
||||
try {
|
||||
const text = existsSync(resolve(baseFile)) ? readFileSync(resolve(baseFile), 'utf8') : '';
|
||||
before = text.trim() ? JSON.parse(text) : null;
|
||||
} catch (error) {
|
||||
err(`static-hostnames: ${baseFile} could not be read: ${error.message}`);
|
||||
return 2;
|
||||
}
|
||||
const ref = 'the base';
|
||||
if (!before) {
|
||||
log('static-hostnames: the baseline is new (its first version)');
|
||||
return 0;
|
||||
}
|
||||
const grown = [
|
||||
...codeGrowth(baseline.code, before.code ?? {}),
|
||||
...bundleGrowth(baseline.bundle.hosts, before.bundle?.hosts ?? []).map((h) => `bundle: ${h}`)
|
||||
];
|
||||
if (grown.length) {
|
||||
err(`static-hostnames: the baseline may only shrink; grown since ${ref}:\n ${grown.join('\n ')}`);
|
||||
return 1;
|
||||
}
|
||||
log(`static-hostnames: the baseline only shrank since ${ref}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const bundleDir = arg('bundle');
|
||||
if (flag('write')) {
|
||||
const next = { code: baseline.code, bundle: baseline.bundle };
|
||||
let grown;
|
||||
if (bundleDir) {
|
||||
next.bundle = { hosts: scanBundle(resolve(bundleDir)), ...(arg('image') ? { image: arg('image') } : {}) };
|
||||
grown = bundleGrowth(next.bundle.hosts, baseline.bundle.hosts);
|
||||
} else {
|
||||
next.code = scanCode(root);
|
||||
grown = codeGrowth(next.code, baseline.code);
|
||||
}
|
||||
if (grown.length && !flag('first-version') && existsSync(BASELINE_FILE)) {
|
||||
err(`static-hostnames: --write would grow the baseline (it may only shrink):\n ${grown.join('\n ')}`);
|
||||
return 1;
|
||||
}
|
||||
writeBaseline(next);
|
||||
log(`static-hostnames: wrote ${relative(root, BASELINE_FILE)}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (bundleDir) {
|
||||
const found = scanBundle(resolve(bundleDir));
|
||||
const grown = bundleGrowth(found, baseline.bundle.hosts);
|
||||
if (grown.length) {
|
||||
err(
|
||||
`static-hostnames: the built web app names Ever hosts the baseline does not have (core code must not call or link Ever hosts; the modules' own hosts come from the modules):\n ${grown.join('\n ')}`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
log(`static-hostnames: ok (bundle: ${found.length} Ever host(s), all recorded)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const found = scanCode(root);
|
||||
const grown = codeGrowth(found, baseline.code);
|
||||
if (grown.length) {
|
||||
err(
|
||||
`static-hostnames: Ever hosts (or Ever Platform base URL variables) outside the Ever Platform modules that the baseline does not have. Only the modules (${ALLOWED.slice(0, 5).join(', ')}) may name them; move the call into a module:\n ${grown.join('\n ')}`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
const files = Object.keys(found).length;
|
||||
log(`static-hostnames: ok (${files} core file(s) name Ever hosts, none beyond the baseline)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
@@ -0,0 +1,81 @@
|
||||
// cspell:ignore notever
|
||||
// node --test tools/egress-audit/static-hostnames.test.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
allowed,
|
||||
bundleGrowth,
|
||||
codeGrowth,
|
||||
main,
|
||||
occurrences,
|
||||
readBaseline,
|
||||
REPO_ROOT,
|
||||
scanCode
|
||||
} from './static-hostnames.mjs';
|
||||
|
||||
const quiet = () => {
|
||||
const lines = [];
|
||||
return { lines, io: { log: (s) => lines.push(s), err: (s) => lines.push(s) } };
|
||||
};
|
||||
|
||||
test('the tree names no Ever host outside the modules beyond the baseline', () => {
|
||||
const { lines, io } = quiet();
|
||||
assert.equal(main([], io), 0, lines.join('\n'));
|
||||
});
|
||||
|
||||
test('the known-bad fixture fails and names its file, its host and its variable', () => {
|
||||
const { lines, io } = quiet();
|
||||
assert.equal(main(['--fixture', join(REPO_ROOT, 'tools/egress-audit/fixtures/static-bad')], io), 1);
|
||||
const out = lines.join('\n');
|
||||
assert.match(out, /packages\/core\/src\/core-host\.ts: api\.ever\.co x1/);
|
||||
assert.match(out, /core-host\.ts: EVER_PLATFORM_API_URL x1/);
|
||||
});
|
||||
|
||||
test('one more occurrence of a recorded host in a recorded file fails; one less passes', () => {
|
||||
const baseline = { 'packages/config/src/a.ts': { 'gauzy.co': 2 } };
|
||||
assert.deepEqual(codeGrowth({ 'packages/config/src/a.ts': { 'gauzy.co': 3 } }, baseline), [
|
||||
'packages/config/src/a.ts: gauzy.co x3 (recorded: 2)'
|
||||
]);
|
||||
assert.deepEqual(codeGrowth({ 'packages/config/src/a.ts': { 'gauzy.co': 1 } }, baseline), []);
|
||||
assert.equal(codeGrowth({ 'packages/core/src/b.ts': { 'api.ever.co': 1 } }, baseline).length, 1);
|
||||
});
|
||||
|
||||
test('a host new to the built web app fails', () => {
|
||||
assert.deepEqual(bundleGrowth(['gauzy.co', 'api.ever.co'], ['gauzy.co']), ['api.ever.co']);
|
||||
assert.deepEqual(bundleGrowth(['gauzy.co'], ['gauzy.co', 'docs.gauzy.co']), []);
|
||||
});
|
||||
|
||||
test('every Ever-owned name counts, names under it included; other hosts do not', () => {
|
||||
assert.deepEqual(occurrences('see https://docs.gauzy.co and https://example.com, mail ops@ever.team'), {
|
||||
'docs.gauzy.co': 1,
|
||||
'ever.team': 1
|
||||
});
|
||||
assert.deepEqual(occurrences('new URL(process.env.EVER_STATS_API_URL)'), { EVER_STATS_API_URL: 1 });
|
||||
assert.deepEqual(occurrences('notever.co.uk forever.com'), {});
|
||||
});
|
||||
|
||||
test('the modules, docs, the audit and tests may name Ever hosts; core code may not', () => {
|
||||
for (const file of [
|
||||
'packages/plugins/ever-connect/src/lib/sdk.ts',
|
||||
'packages/plugins/ever-stats-ui/src/index.ts',
|
||||
'docs/ever-platform/outbound-calls.md',
|
||||
'README.md',
|
||||
'packages/core/src/lib/user/user.service.spec.ts',
|
||||
'tools/egress-audit/adapter.mjs'
|
||||
])
|
||||
assert.equal(allowed(file), true, file);
|
||||
for (const file of [
|
||||
'packages/core/src/lib/user/user.service.ts',
|
||||
'apps/gauzy/src/app/app.component.ts',
|
||||
'.env.sample',
|
||||
'packages/plugins/ever-connector/x.ts'
|
||||
])
|
||||
assert.equal(allowed(file), false, file);
|
||||
});
|
||||
|
||||
test('the baseline holds no file the scan does not reach (an entry for an allowed file would excuse nothing)', () => {
|
||||
const { code } = readBaseline();
|
||||
for (const file of Object.keys(code)) assert.equal(allowed(file), false, file);
|
||||
assert.ok(Object.keys(scanCode(REPO_ROOT)).length > 0);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The import boundary of the optional Ever Platform modules (rules in import-boundary.cjs, which the
|
||||
* ESLint rule `ever-platform/import-boundary` shares), over the tree (dependencies and build output skipped). The CI gate: it
|
||||
* needs nothing but Node, so it runs before any install.
|
||||
*
|
||||
* node tools/ever-platform/check-import-boundary.mjs [--root <dir>]
|
||||
*
|
||||
* --root scans another tree (a fixture) with its own paths. Exit 0 when clean, 1 naming each
|
||||
* `file:line specifier: reason`.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { violation, exportViolation, FIXTURES } = require('./import-boundary.cjs');
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
export const REPO_ROOT = resolve(HERE, '..', '..');
|
||||
|
||||
const CODE = /\.(m?[jt]sx?|c[jt]s)$/;
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'out-tsc',
|
||||
'coverage',
|
||||
'.angular',
|
||||
'.nx',
|
||||
'.cache',
|
||||
'tmp'
|
||||
]);
|
||||
|
||||
/**
|
||||
* TypeScript's own import scanner (it reads tokens, so a specifier written inside a string or a
|
||||
* comment is not an import): from the audit's harness install (tools/egress-audit, `npm ci`), else
|
||||
* from the workspace.
|
||||
*/
|
||||
function loadTypeScript() {
|
||||
for (const base of [join(REPO_ROOT, 'tools/egress-audit/package.json'), join(REPO_ROOT, 'package.json')]) {
|
||||
try {
|
||||
return createRequire(base)('typescript');
|
||||
} catch {
|
||||
// next
|
||||
}
|
||||
}
|
||||
throw new Error('typescript is not installed: run npm ci --prefix tools/egress-audit');
|
||||
}
|
||||
const ts = loadTypeScript();
|
||||
|
||||
/** Every module specifier of a source text (imports, re-exports, require, import()), with its line. */
|
||||
export function specifiers(text) {
|
||||
const { importedFiles } = ts.preProcessFile(text, true, true);
|
||||
return importedFiles.map(({ fileName, pos }) => ({ specifier: fileName, line: lineAt(text, pos) }));
|
||||
}
|
||||
|
||||
/** The specifiers an entry point re-exports (`export * from`, `export { a } from`). */
|
||||
export function reexports(text) {
|
||||
// Parsed, so a re-export written over several lines or with comments is still found.
|
||||
const source = ts.createSourceFile('entry.ts', text, ts.ScriptTarget.Latest, true);
|
||||
return source.statements
|
||||
.filter(
|
||||
(node) => ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)
|
||||
)
|
||||
.map((node) => ({
|
||||
specifier: node.moduleSpecifier.text,
|
||||
line: source.getLineAndCharacterOfPosition(node.moduleSpecifier.getStart(source)).line + 1
|
||||
}));
|
||||
}
|
||||
|
||||
const lineAt = (text, index) => text.slice(0, index).split('\n').length;
|
||||
|
||||
function walk(root) {
|
||||
const out = [];
|
||||
const visit = (dir) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) visit(p);
|
||||
else out.push(relative(root, p).split(sep).join('/'));
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every file under `root` outside SKIP_DIRS (in CI the checkout holds exactly the tracked files). */
|
||||
const files = (root) => walk(root);
|
||||
|
||||
/** `file:line specifier: reason` for every import across the boundary under `root`. */
|
||||
export function check(root = REPO_ROOT) {
|
||||
const findings = [];
|
||||
for (const file of files(root)) {
|
||||
if (!CODE.test(file) || file.split('/').some((part) => SKIP_DIRS.has(part)) || file.startsWith(FIXTURES))
|
||||
continue;
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(join(root, file), 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Every rule is about a package name or a module's entry point; skip the rest unread.
|
||||
if (!/@gauzy\/plugin-|axios|undici/.test(text) && !file.endsWith('ever-connect/src/index.ts')) continue;
|
||||
for (const { specifier, line } of specifiers(text)) {
|
||||
const reason = violation(file, specifier);
|
||||
if (reason) findings.push(`${file}:${line} ${reason}`);
|
||||
}
|
||||
for (const { specifier, line } of reexports(text)) {
|
||||
const reason = exportViolation(file, specifier);
|
||||
if (reason) findings.push(`${file}:${line} ${reason}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(findings)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function main(argv) {
|
||||
const i = argv.indexOf('--root');
|
||||
const root = i >= 0 ? resolve(argv[i + 1]) : REPO_ROOT;
|
||||
const findings = check(root);
|
||||
if (findings.length) {
|
||||
process.stderr.write(
|
||||
`check-import-boundary: imports across the Ever Platform modules' boundary:\n ${findings.join('\n ')}\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
process.stdout.write('check-import-boundary: ok\n');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
||||
process.exit(main(process.argv.slice(2)));
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
// No dependency of the public default branch on a private ever-co repository.
|
||||
//
|
||||
// Anyone who clones `develop` must be able to install and build it. This reads every
|
||||
// package.json (its dependency fields only: dependencies, devDependencies, optionalDependencies,
|
||||
// peerDependencies, resolutions, overrides), yarn.lock (entry specs and `resolved` URLs) and workflow
|
||||
// (`uses:` values only), finds the ones that install from an ever-co GitHub repository, and asks
|
||||
// GitHub WITHOUT credentials whether each repository is public (its page answers 404 when it is
|
||||
// private). A published npm version never matches; a `repository` or `bugs` link is not read.
|
||||
//
|
||||
// Dependency forms recognized: `github:ever-co/<repo>`, the shorthand `ever-co/<repo>`,
|
||||
// `git+https://github.com/ever-co/<repo>`, `https://github.com/ever-co/<repo>.git`,
|
||||
// `git+ssh://git@github.com[:/]ever-co/<repo>`, `git@github.com:ever-co/<repo>`,
|
||||
// `git://github.com/ever-co/<repo>`, and GitHub tarballs (`https://codeload.github.com/ever-co/<repo>/...`,
|
||||
// `https://github.com/ever-co/<repo>/archive/...`).
|
||||
//
|
||||
// node tools/ever-platform/check-private-deps.mjs [--root <dir>]
|
||||
//
|
||||
// Exit codes: 0 every referenced ever-co repository is public, 1 a private one is referenced, 2 the
|
||||
// check could not tell (GitHub answered something else, for example a rate limit).
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const OWNER = 'ever-co';
|
||||
const REPO = '([A-Za-z0-9._-]+)';
|
||||
/** A dependency specification that installs from an ever-co GitHub repository (whole value). */
|
||||
const SPEC_FORMS = [
|
||||
`^github:${OWNER}/${REPO}`,
|
||||
`^${OWNER}/${REPO}(?:#.*)?$`,
|
||||
String.raw`^git\+https://(?:[^@/\s]+@)?github\.com/${OWNER}/${REPO}`,
|
||||
String.raw`^https://(?:[^@/\s]+@)?github\.com/${OWNER}/${REPO}\.git(?:#.*)?$`,
|
||||
String.raw`^git\+ssh://git@github\.com[:/]${OWNER}/${REPO}`,
|
||||
String.raw`^git@github\.com:${OWNER}/${REPO}`,
|
||||
String.raw`^git://github\.com/${OWNER}/${REPO}`,
|
||||
String.raw`^https://codeload\.github\.com/${OWNER}/${REPO}/`,
|
||||
String.raw`^https://github\.com/${OWNER}/${REPO}/(?:archive|tarball)/`
|
||||
].map((source) => new RegExp(source));
|
||||
/**
|
||||
* The same forms inside a yarn.lock line (entry selectors like `"pkg@github:ever-co/x#ref"`,
|
||||
* `resolved` URLs). The shorthand counts after a quote, a space or the line start, or as the spec of
|
||||
* a selector (`pkg@ever-co/<repo>`: a package name right before the `@`); `"@ever-co/<package>"` is
|
||||
* an npm scope, never a repository.
|
||||
*/
|
||||
const LOCK_FORMS = [
|
||||
`github:${OWNER}/${REPO}`,
|
||||
String.raw`git\+https://(?:[^@/\s"]+@)?github\.com/${OWNER}/${REPO}`,
|
||||
String.raw`git\+ssh://git@github\.com[:/]${OWNER}/${REPO}`,
|
||||
String.raw`git@github\.com:${OWNER}/${REPO}`,
|
||||
String.raw`(?:^|[\s"'])${OWNER}/${REPO}(?=[#\s"':,]|$)`,
|
||||
String.raw`(?<=[A-Za-z0-9._-]@)${OWNER}/${REPO}(?=[#\s"':,]|$)`,
|
||||
String.raw`git://github\.com/${OWNER}/${REPO}`,
|
||||
String.raw`https://(?:codeload\.)?github\.com/${OWNER}/${REPO}(?:\.git|/tar\.gz/|/archive/)`
|
||||
].map((source) => new RegExp(source, 'g'));
|
||||
const USES = new RegExp(String.raw`^\s*-?\s*uses:\s*["']?${OWNER}/${REPO}`);
|
||||
const DEPENDENCY_FIELDS = [
|
||||
'dependencies',
|
||||
'devDependencies',
|
||||
'optionalDependencies',
|
||||
'peerDependencies',
|
||||
'resolutions',
|
||||
'overrides'
|
||||
];
|
||||
/** Directories never read: dependencies, build output and VCS data. */
|
||||
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.angular', '.nx', 'tmp', 'coverage']);
|
||||
/** The check's own fixtures name a private repository on purpose. */
|
||||
const FIXTURES = 'tools/ever-platform/fixtures';
|
||||
|
||||
const normalise = (repo) => repo.replace(/\.git$/, '').toLowerCase();
|
||||
|
||||
/** The files that can declare a dependency: every package.json and yarn.lock, and the workflows. */
|
||||
export function dependencyFiles(root) {
|
||||
let files = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
|
||||
const path = dir ? `${dir}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(entry.name) && path !== FIXTURES) walk(path);
|
||||
} else if (entry.name === 'package.json' || entry.name === 'yarn.lock') {
|
||||
files.push(path);
|
||||
} else if (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(path)) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk('');
|
||||
return files.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/** The ever-co repository a dependency specification installs from, or null. */
|
||||
export function repoOfSpec(spec) {
|
||||
if (typeof spec !== 'string') return null;
|
||||
const value = spec.trim();
|
||||
for (const form of SPEC_FORMS) {
|
||||
const match = form.exec(value);
|
||||
if (match) return normalise(match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The 1-based line of the first occurrence of `needle` in `text` (1 when not found). */
|
||||
function lineOf(text, needle) {
|
||||
const at = text.indexOf(needle);
|
||||
return at < 0 ? 1 : text.slice(0, at).split('\n').length;
|
||||
}
|
||||
|
||||
/** Every dependency value of a package.json (nested `overrides` included): [[name, value]]. */
|
||||
function dependencyValues(manifest) {
|
||||
let values = [];
|
||||
const walk = (node, prefix) => {
|
||||
for (const [name, value] of Object.entries(node ?? {})) {
|
||||
if (typeof value === 'string') values.push([`${prefix}${name}`, value]);
|
||||
else if (value && typeof value === 'object') walk(value, `${prefix}${name}/`);
|
||||
}
|
||||
};
|
||||
for (const field of DEPENDENCY_FIELDS) walk(manifest?.[field], `${field}.`);
|
||||
return values;
|
||||
}
|
||||
|
||||
/** The references of a package.json: its dependency values that install from an ever-co repository. */
|
||||
function manifestRefs(text, file) {
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(text);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
let refs = [];
|
||||
for (const [, value] of dependencyValues(manifest)) {
|
||||
const repo = repoOfSpec(value);
|
||||
if (repo) refs.push({ repo, file, line: lineOf(text, JSON.stringify(value)) });
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/** The references of a yarn.lock line. */
|
||||
function lockRefs(line, file, lineNumber) {
|
||||
let refs = [];
|
||||
for (const form of LOCK_FORMS)
|
||||
for (const match of line.matchAll(form)) refs.push({ repo: normalise(match[1]), file, line: lineNumber });
|
||||
return refs;
|
||||
}
|
||||
|
||||
/** Every reference to an ever-co repository in one file: [{repo, file, line}]. */
|
||||
export function findRefs(text, file) {
|
||||
if (file.endsWith('package.json')) return manifestRefs(text, file);
|
||||
const lockfile = file.endsWith('yarn.lock');
|
||||
return text.split('\n').flatMap((line, index) => {
|
||||
if (lockfile) return lockRefs(line, file, index + 1);
|
||||
const match = USES.exec(line);
|
||||
return match ? [{ repo: normalise(match[1]), file, line: index + 1 }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `ever-co/<repo>` is public, asked without credentials: the repository's page answers 200
|
||||
* to anyone when it is public and 404 when it is private (the web page, not the REST API, whose
|
||||
* anonymous rate limit shared CI addresses exhaust). true, false (404), or null (could not tell).
|
||||
*/
|
||||
export async function isPublicOnGitHub(repo, fetchImpl = fetch, waitMs = 2000) {
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
// One question at a time, with a pause between retries: this is a polite, anonymous check.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const response = await fetchImpl(`https://github.com/${OWNER}/${encodeURIComponent(repo)}`, {
|
||||
method: 'HEAD',
|
||||
headers: { 'user-agent': 'ever-gauzy-private-deps-check' },
|
||||
redirect: 'follow'
|
||||
});
|
||||
if (response.status === 200) return true;
|
||||
if (response.status === 404) return false;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (attempt < 3) await new Promise((r) => setTimeout(r, waitMs * attempt));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The exit code of a verdict: 1 a private repository, 2 inconclusive, 0 all public. */
|
||||
function exitOf(privateRefs, unknown) {
|
||||
if (privateRefs.length) return 1;
|
||||
if (unknown.length) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The verdict for a set of references: {exit, privateRefs[], unknown[], repos[]}. */
|
||||
export async function check(refs, isPublic) {
|
||||
const repos = [...new Set(refs.map((r) => r.repo))];
|
||||
const answers = await Promise.all(repos.map((repo) => isPublic(repo)));
|
||||
let verdicts = new Map(repos.map((repo, i) => [repo, answers[i]]));
|
||||
const privateRefs = refs.filter((r) => verdicts.get(r.repo) === false);
|
||||
const unknown = repos.filter((repo) => verdicts.get(repo) === null);
|
||||
return { exit: exitOf(privateRefs, unknown), privateRefs, unknown, repos };
|
||||
}
|
||||
|
||||
function summary(result, count) {
|
||||
let verdict = 'all public';
|
||||
if (result.exit === 1) verdict = 'a private repository is referenced';
|
||||
if (result.exit === 2) verdict = 'inconclusive';
|
||||
return `check-private-deps: ${count} reference(s) to ${result.repos.length} ever-co repositories (${result.repos.join(', ') || 'none'}); ${verdict}\n`;
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
const at = argv.indexOf('--root');
|
||||
const root = resolve(at >= 0 ? argv[at + 1] : '.');
|
||||
const refs = dependencyFiles(root).flatMap((file) => findRefs(readFileSync(join(root, file), 'utf8'), file));
|
||||
const result = await check(refs, (repo) => isPublicOnGitHub(repo));
|
||||
for (const r of result.privateRefs)
|
||||
process.stdout.write(`${r.file}:${r.line}: depends on the private repository ${OWNER}/${r.repo}\n`);
|
||||
for (const repo of result.unknown)
|
||||
process.stdout.write(`could not tell whether ${OWNER}/${repo} is public (GitHub did not answer 200 or 404)\n`);
|
||||
process.stdout.write(summary(result, refs.length));
|
||||
return result.exit;
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
process.exitCode = await main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(`check-private-deps: ${error.message}\n`);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// node --test tools/ever-platform/check-private-deps.test.mjs
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import { check, findRefs, isPublicOnGitHub, repoOfSpec } from './check-private-deps.mjs';
|
||||
|
||||
const fixture = (name) => readFileSync(new URL(`./fixtures/private-deps/${name}`, import.meta.url), 'utf8');
|
||||
|
||||
/** A stand-in for GitHub: `platform` is private, everything else public. */
|
||||
const isPublic = async (repo) => repo !== 'platform';
|
||||
|
||||
test('a git dependency on a private ever-co repository fails, naming the file and line', async () => {
|
||||
const refs = findRefs(fixture('package.json'), 'fixtures/private-deps/package.json');
|
||||
const result = await check(refs, isPublic);
|
||||
assert.equal(result.exit, 1);
|
||||
assert.deepEqual(
|
||||
result.privateRefs.map((r) => `${r.file}:${r.line} ${r.repo}`),
|
||||
['fixtures/private-deps/package.json:5 platform']
|
||||
);
|
||||
});
|
||||
|
||||
test('a published npm version and a public ever-co repository pass', async () => {
|
||||
const text = JSON.stringify(
|
||||
{
|
||||
dependencies: {
|
||||
'@ever-co/connect-sdk': '1.0.0-rc.5',
|
||||
'@nestjs/axios': 'github:ever-co/nestjs-axios#master',
|
||||
x: 'git+https://github.com/ever-co/ever-connect-sdk.git#main'
|
||||
}
|
||||
},
|
||||
null,
|
||||
'\t'
|
||||
);
|
||||
const refs = findRefs(text, 'package.json');
|
||||
assert.deepEqual(refs.map((r) => r.repo).sort(), ['ever-connect-sdk', 'nestjs-axios']);
|
||||
assert.equal((await check(refs, isPublic)).exit, 0);
|
||||
});
|
||||
|
||||
test('every dependency form is recognized', () => {
|
||||
const forms = {
|
||||
'github:ever-co/one#main': 'one',
|
||||
'ever-co/two': 'two',
|
||||
'ever-co/three#v1': 'three',
|
||||
'git+https://github.com/ever-co/four.git': 'four',
|
||||
'https://github.com/ever-co/five.git#main': 'five',
|
||||
'git+ssh://git@github.com:ever-co/six.git': 'six',
|
||||
'git+ssh://git@github.com/ever-co/seven.git': 'seven',
|
||||
'git@github.com:ever-co/eight.git': 'eight',
|
||||
'git://github.com/ever-co/nine': 'nine',
|
||||
'https://codeload.github.com/ever-co/ten/tar.gz/main': 'ten',
|
||||
'https://github.com/ever-co/eleven/archive/main.tar.gz': 'eleven'
|
||||
};
|
||||
for (const [spec, repo] of Object.entries(forms)) assert.equal(repoOfSpec(spec), repo, spec);
|
||||
for (const spec of ['1.2.3', '^1.0.0', 'npm:@ever-co/x@1', 'github:other-org/x', 'file:../x', 'https://github.com/ever-co/x'])
|
||||
assert.equal(repoOfSpec(spec), null, spec);
|
||||
});
|
||||
|
||||
test('a manifest that installs only a GitHub tarball of a private repository fails', async () => {
|
||||
const text = JSON.stringify({ dependencies: { x: 'https://codeload.github.com/ever-co/platform/tar.gz/main' } });
|
||||
const result = await check(findRefs(text, 'package.json'), isPublic);
|
||||
assert.equal(result.exit, 1);
|
||||
assert.deepEqual(result.privateRefs.map((r) => r.repo), ['platform']);
|
||||
});
|
||||
|
||||
test('only dependency fields are read: repository and bugs links are not dependencies', () => {
|
||||
const text = JSON.stringify({
|
||||
repository: 'github:ever-co/platform',
|
||||
bugs: { url: 'https://github.com/ever-co/platform/issues' },
|
||||
dependencies: { a: '1.0.0' },
|
||||
overrides: { b: { c: 'ever-co/nested' } },
|
||||
resolutions: { d: 'git+https://github.com/ever-co/resolved.git' }
|
||||
});
|
||||
assert.deepEqual(
|
||||
findRefs(text, 'package.json').map((r) => r.repo),
|
||||
['resolved', 'nested']
|
||||
);
|
||||
});
|
||||
|
||||
test('yarn.lock entries and workflow uses are found; other workflow text is not', () => {
|
||||
const lock = [
|
||||
'"@nestjs/axios@github:ever-co/nestjs-axios#master":',
|
||||
' resolved "https://codeload.github.com/ever-co/nestjs-axios/tar.gz/abc"',
|
||||
'"@ever-co/legal@^0.1.0":',
|
||||
' resolved "https://registry.npmjs.org/@ever-co/legal/-/legal-0.1.2.tgz"'
|
||||
].join('\n');
|
||||
assert.deepEqual(
|
||||
findRefs(lock, 'yarn.lock').map((r) => r.repo),
|
||||
['nestjs-axios', 'nestjs-axios']
|
||||
);
|
||||
const moreLock = [
|
||||
'"x@git@github.com:ever-co/ssh-repo.git":',
|
||||
' resolved "git@github.com:ever-co/ssh-repo.git#abc"',
|
||||
'"y@ever-co/short#main":',
|
||||
' version "1.0.0"',
|
||||
' dependencies:',
|
||||
' "@ever-co/connect-contracts" "1.0.0-rc.3"'
|
||||
].join('\n');
|
||||
// The SSH form, and the shorthand as a selector's spec (a package name before the @); never the
|
||||
// npm scope of a dependency line.
|
||||
assert.deepEqual(
|
||||
[...new Set(findRefs(moreLock, 'yarn.lock').map((r) => r.repo))].sort(),
|
||||
['short', 'ssh-repo']
|
||||
);
|
||||
assert.deepEqual(findRefs(' "@ever-co/connect-contracts" "1.0.0-rc.3"', 'yarn.lock'), []);
|
||||
// The shorthand after a quote or a space counts too.
|
||||
assert.deepEqual(
|
||||
findRefs('"y" "ever-co/short#main"', 'yarn.lock').map((r) => r.repo),
|
||||
['short']
|
||||
);
|
||||
const workflow = [
|
||||
' - uses: ever-co/five/.github/actions/x@abc',
|
||||
' uses: ever-co/six@v1',
|
||||
' with: { repository: ever-co/other }',
|
||||
' # see https://github.com/ever-co/seven'
|
||||
].join('\n');
|
||||
assert.deepEqual(
|
||||
findRefs(workflow, '.github/workflows/x.yml').map((r) => r.repo),
|
||||
['five', 'six']
|
||||
);
|
||||
});
|
||||
|
||||
test('an answer other than 200 or 404 is inconclusive (exit 2), never a pass', async () => {
|
||||
const result = await check([{ repo: 'x', file: 'f', line: 1 }], async () => null);
|
||||
assert.equal(result.exit, 2);
|
||||
assert.equal(await isPublicOnGitHub('x', async () => ({ status: 403 }), 1), null);
|
||||
assert.equal(await isPublicOnGitHub('x', async () => ({ status: 404 }), 1), false);
|
||||
assert.equal(await isPublicOnGitHub('x', async () => ({ status: 200 }), 1), true);
|
||||
});
|
||||
|
||||
test('the request carries no credentials', async () => {
|
||||
let seen;
|
||||
await isPublicOnGitHub('x', async (url, init) => {
|
||||
seen = { url, headers: init.headers };
|
||||
return { status: 200 };
|
||||
});
|
||||
assert.equal(seen.url, 'https://github.com/ever-co/x');
|
||||
assert.equal(
|
||||
Object.keys(seen.headers).some((h) => h.toLowerCase() === 'authorization'),
|
||||
false
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// Allowed: the API's plugin list imports the modules through their entry points.
|
||||
import { EverConnectPlugin } from '@gauzy/plugin-ever-connect';
|
||||
import { EverStatsPlugin } from '@gauzy/plugin-ever-stats';
|
||||
export const plugins = [EverConnectPlugin, EverStatsPlugin];
|
||||
@@ -0,0 +1,5 @@
|
||||
// Known-bad: a module's file instead of its entry point, and a lazy import of a module.
|
||||
import { readEverConnectConfig } from '@gauzy/plugin-ever-connect/src/lib/ever-connect-config';
|
||||
export const lazy = () => import('@gauzy/plugin-ever-stats-ui');
|
||||
export { readEverConnectConfig };
|
||||
// A specifier inside a string is not an import: "import x from '@gauzy/plugin-ever-connect'"
|
||||
@@ -0,0 +1,3 @@
|
||||
// Known-bad: core code depending on an optional module.
|
||||
import { EverConnectModule } from '@gauzy/plugin-ever-connect';
|
||||
export const modules = [EverConnectModule];
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// Known-bad: the sign-in plugin importing the connection.
|
||||
import { EverConnectConnectionService } from '@gauzy/plugin-ever-connect';
|
||||
export { EverConnectConnectionService };
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// Known-bad: a web part importing a server module.
|
||||
import { EVER_CONNECT_FETCH } from '@gauzy/plugin-ever-connect';
|
||||
export { EVER_CONNECT_FETCH };
|
||||
@@ -0,0 +1,6 @@
|
||||
// Known-bad: the connection's entry point exporting its entitlement code.
|
||||
export * from './lib/ever-connect.plugin';
|
||||
export {
|
||||
// the service, over several lines
|
||||
EverConnectEntitlementService
|
||||
} from './lib/ever-connect-entitlement.service';
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// Known-bad: the instance identity with an HTTP client.
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
export { HttpService };
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// Known-bad: the statistics importing the connection and an analytics plugin.
|
||||
import { EverConnectService } from '@gauzy/plugin-ever-connect';
|
||||
const posthog = require('@gauzy/plugin-posthog');
|
||||
export { EverConnectService, posthog };
|
||||
@@ -0,0 +1,11 @@
|
||||
<!-- markdownlint-disable MD013 MD043 -->
|
||||
|
||||
# A known-bad README for outbound-calls.test.mjs
|
||||
|
||||
| # | Request | When |
|
||||
| --- | --------------------------------------- | ---------- |
|
||||
| 1 | `GET /.well-known/ever-keys.json` | at connect |
|
||||
| 3 | `POST /v1/connect/redeem` | at connect |
|
||||
| 25 | `POST /v1/instances/me/billing-links` | never |
|
||||
| 4 | `POST /v1/instances/token/refresh` | hourly |
|
||||
| 5 | `POST /v1/instances/me/tenant-links` | on link |
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "private-deps-fixture",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ever-platform/x": "github:ever-co/platform#main",
|
||||
"@ever-co/connect-sdk": "1.0.0-rc.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// cspell:words posthog
|
||||
/**
|
||||
* The import boundary of the optional Ever Platform modules, in one place for two readers: the
|
||||
* ESLint rule `ever-platform/import-boundary` (root `eslint.config.js`, so editors and `nx lint`
|
||||
* show it) and `check-import-boundary.mjs` (the CI gate, which needs no workspace install).
|
||||
*
|
||||
* - Only the modules themselves and the two plugin lists (the API's and the web app's) import a
|
||||
* module (`@gauzy/plugin-ever-*`), and only through its public entry point: no core code depends
|
||||
* on a module, so switching one off removes it whole.
|
||||
* - The statistics never import the connection or an analytics plugin; the instance identity makes
|
||||
* no request and depends on neither module; the connection and the Ever ID sign-in plugin never
|
||||
* import each other; a web part never imports a server part.
|
||||
* - Nothing outside the connection reads an entitlement document: its entry point exports no
|
||||
* entitlement code.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
/** The module packages (`packages/plugins/<name>`) and their package names. */
|
||||
const MODULES = ['ever-connect', 'ever-connect-ui', 'ever-stats', 'ever-stats-ui', 'ever-instance'];
|
||||
const SERVER_MODULES = ['ever-connect', 'ever-stats', 'ever-instance'];
|
||||
const pkg = (name) => `@gauzy/plugin-${name}`;
|
||||
|
||||
/** The plugin lists: the only files outside the modules that import them. */
|
||||
const PLUGIN_LISTS = ['apps/api/src/plugins.ts', 'apps/gauzy/src/plugin-ui.config.ts'];
|
||||
|
||||
const ANALYTICS = ['@gauzy/plugin-jitsu-analytics', '@gauzy/plugin-posthog'];
|
||||
const HTTP_CLIENTS = ['@nestjs/axios', 'axios', 'undici'];
|
||||
|
||||
/** The package directory a repository path is in (`packages/plugins/<name>`), or null. */
|
||||
function pluginOf(file) {
|
||||
const match = /^packages\/plugins\/([^/]+)\//.exec(file);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** `name` or a subpath of it. */
|
||||
const isPackage = (specifier, name) => specifier === name || specifier.startsWith(`${name}/`);
|
||||
const anyOf = (specifier, names) => names.find((name) => isPackage(specifier, name)) ?? null;
|
||||
|
||||
/** The Ever Platform module a specifier names (`ever-connect`), or null. */
|
||||
function moduleOf(specifier) {
|
||||
const match = /^@gauzy\/plugin-(ever-[a-z0-9-]+)(\/.*)?$/.exec(specifier);
|
||||
return match && MODULES.includes(match[1]) ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `file` (a repository path with forward slashes) may not import `specifier`, or null.
|
||||
*/
|
||||
function violation(file, specifier) {
|
||||
const from = pluginOf(file);
|
||||
const target = moduleOf(specifier);
|
||||
|
||||
if (target) {
|
||||
if (!MODULES.includes(from ?? '') && !PLUGIN_LISTS.includes(file)) {
|
||||
return `${specifier} is an optional Ever Platform module: only the modules and the plugin lists (${PLUGIN_LISTS.join(', ')}) import it, so switching it off removes it whole`;
|
||||
}
|
||||
if (specifier !== pkg(target) && from !== target) {
|
||||
return `${specifier}: import a module through its entry point (${pkg(target)}), never its files`;
|
||||
}
|
||||
}
|
||||
|
||||
if (from === 'ever-stats' || from === 'ever-stats-ui') {
|
||||
const hit = anyOf(specifier, [pkg('ever-connect'), pkg('ever-connect-ui'), ...ANALYTICS]);
|
||||
if (hit)
|
||||
return `${hit}: the anonymous statistics are independent of the connection and of every analytics plugin`;
|
||||
}
|
||||
if (from === 'ever-instance') {
|
||||
const hit = anyOf(specifier, [pkg('ever-connect'), pkg('ever-stats'), ...ANALYTICS, ...HTTP_CLIENTS]);
|
||||
if (hit) return `${hit}: the instance identity makes no request and depends on neither module`;
|
||||
}
|
||||
if (from === 'ever-connect' && /^@gauzy\/plugin-auth-zitadel/.test(specifier)) {
|
||||
return `${specifier}: the connection and the Ever ID sign-in plugin never import each other`;
|
||||
}
|
||||
if (from && from.startsWith('auth-zitadel') && isPackage(specifier, pkg('ever-connect'))) {
|
||||
return `${specifier}: the Ever ID sign-in plugin and the connection never import each other`;
|
||||
}
|
||||
if ((from === 'ever-connect-ui' || from === 'ever-stats-ui') && target && SERVER_MODULES.includes(target)) {
|
||||
return `${specifier}: a web part never imports a server module`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The connection's entry point: it exports no entitlement code. */
|
||||
const CONNECT_ENTRY = 'packages/plugins/ever-connect/src/index.ts';
|
||||
|
||||
function exportViolation(file, source) {
|
||||
if (file === CONNECT_ENTRY && /entitlement/i.test(source)) {
|
||||
return `${source}: nothing outside the connection reads an entitlement document, so its entry point does not export the entitlement code`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The known-bad fixtures of the boundary's test: never checked as part of the tree. */
|
||||
const FIXTURES = 'tools/ever-platform/fixtures/';
|
||||
|
||||
/** A repository path (forward slashes) for an absolute or relative file name, or null outside it. */
|
||||
function repoPath(filename) {
|
||||
const rel = path.relative(REPO_ROOT, path.resolve(filename)).split(path.sep).join('/');
|
||||
return rel.startsWith('..') ? null : rel;
|
||||
}
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'The import boundary of the optional Ever Platform modules (tools/ever-platform/import-boundary.cjs)'
|
||||
},
|
||||
schema: []
|
||||
},
|
||||
create(context) {
|
||||
const file = repoPath(context.filename ?? context.getFilename());
|
||||
if (!file || file.startsWith(FIXTURES)) return {};
|
||||
const report = (node, message) => context.report({ node, message });
|
||||
const check = (node, source) => {
|
||||
if (typeof source !== 'string') return;
|
||||
const message = violation(file, source);
|
||||
if (message) report(node, message);
|
||||
};
|
||||
const checkExport = (node) => {
|
||||
if (!node.source) return;
|
||||
check(node.source, node.source.value);
|
||||
const message = exportViolation(file, node.source.value);
|
||||
if (message) report(node.source, message);
|
||||
};
|
||||
return {
|
||||
ImportDeclaration: (node) => check(node.source, node.source.value),
|
||||
ImportExpression: (node) => node.source.type === 'Literal' && check(node.source, node.source.value),
|
||||
TSImportEqualsDeclaration: (node) =>
|
||||
node.moduleReference?.type === 'TSExternalModuleReference' &&
|
||||
check(node.moduleReference.expression, node.moduleReference.expression.value),
|
||||
ExportAllDeclaration: checkExport,
|
||||
ExportNamedDeclaration: checkExport,
|
||||
CallExpression: (node) => {
|
||||
if (
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'require' &&
|
||||
node.arguments[0]?.type === 'Literal'
|
||||
) {
|
||||
check(node.arguments[0], node.arguments[0].value);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const plugin = { meta: { name: 'ever-platform' }, rules: { 'import-boundary': rule } };
|
||||
|
||||
module.exports = { FIXTURES, MODULES, PLUGIN_LISTS, CONNECT_ENTRY, violation, exportViolation, repoPath, plugin };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user